←

2026-10-16 Decrypting FortiOS 8.0.x Firmware Image

The materials here were partly given out in my talk “Cracking Hard(ware) : The World of Network Appliance Forensics” at STANDCON 2026 in Singapore, this is a partial writeup for those who missed it.

Decrypting FortiGate’s 8.0.x Firmware Image

The materials here were partly given out in my talk “Cracking Hard(ware) : The World of Network Appliance Forensics” at STANDCON 2026 in Singapore. This is a partial (but more focused and indepth, i think, on the important stuff) writeup for those who missed it. I will be mainly focusing on my work in the Fortigate FortiOS appliance as its one of the few parts of the presentation that alot of audience members had found to be interesting and novel enough to current industry applications.

Decrypting a firmware image’s root filesystem is usually the first move in a white-box audit of an embedded device, and thats no different with Fortigate devices which are especially well researched as they are extensively used in enterprise environments and are frequent targets to APT hacks. Bishop Fox and Optistream had done early works in decoding the FortiOS firmware, and in April 2024 GreyNoise Labs documented a way to decrypt FortiOS 7.0.x. There the firmware used stock ChaCha20, and the whole cipher state (a 32-byte key and 16-byte IV) sat in the clear in the kernel (flatkc). You could recover it with lief to resolve a symbol offset and objdump to dump the static memory.

The newer FortiOS 8.0.x seems to introduce stronger protections against firmware decryption, as searching for the usual ChaCha20 fingerprint (the constant "expand 32-byte k" (0x61707865 0x3320646e …)) turns up nothing. The secure-boot and integrity path of the OS has seemingly been rebuilt around a root of trust

  • The symmetric key is no longer stored in the clear, it is RSA-wrapped and appended to rootfs.gz, and the public key used to unwrap it is itself obfuscated in the kernel
  • ChaCha20 and AES were dropped for a customized RC4 variant whose keystream matches no reference implementation

The verifier sub_FFFFFFFF81710483() delivers the 32-byte disk key as a 256-byte block appended to the end of rootfs.gz. Since the image can only be verified with the public exponent, that block was produced with Fortinet’s private key. This is RSA signing with message recovery, not encryption. The decryption core sub_FFFFFFFF81710334() initializes a standard 256-byte S-box but passes the output through an obfuscated keystream function. The customization lives entirely in the output stage.

ROOTFS.GZ ON DISKencrypted bodyfull[:-256] · RC4 streamRSA blockfull[-256:] · 256 Bm = c^e mod nSHA256(body)== recovered[160:192]decrypt with recovered[224:256](the 32-byte RC4 key)

However, on 8.0.x the symbols are gone. The reliable pivot is the fail-closed invariant of secure-boot code: if verification fails, the device must brick. So the verifier sits one or two hops up the call graph from a halt. Search the kernel image in IDA for machine_halt() / panic() / emergency_restart(), then cross-reference backward. One short walk lands on sub_FFFFFFFF81710483(). The function is full of unnamed variables, but there seems to be a 270-iteration for loop carrying an & 0x1F (mod-32) XOR.

GREP THE KERNEL FORpanic() machine_halt() emergency_restart()fail-closed · verify fails ⇒ brick1–2 xrefs upsub_FFFFFFFF81710483()the verifier270-iteration loop — each byte XORed withseed[i & 0x1F]
rsa_parse_pub_key(v36, n6291648_1, 270);

That line resolves the loop’s purpose — the XOR reconstructs the RSA public key the next call parses. Double-clicking the two source pointers gives both inputs: a 32-byte de-obfuscation seed at byte_FFFFFFFF8179A2C0 in .init.data (e.g. 5C 19 C6 E1 …), and a 270-byte obfuscated certificate at the adjacent byte_FFFFFFFF8179A1A0.

.INIT.DATA — TWO BLOBS, SIDE BY SIDEbyte_FFFFFFFF8179A1A0byte_FFFFFFFF8179A2C0obfuscated cert · 270 bytesDER pubkey, XOR-maskedde-obf seed · 32 bytes5C 19 C6 E1 …XOR0xA2C0 − 0xA1A0 = 288 = 270 + 18 pad → the two sit adjacent

270 is not just a random byte size, it is the exact length of a DER-encoded PKCS#1 RSAPublicKey (the bare SEQUENCE { modulus, publicExponent }, not a SubjectPublicKeyInfo wrapper) for a 2048-bit modulus with a 3-byte exponent.

DER RSAPublicKey — 270 BYTES30 82 01 0A· SEQUENCE len 266 · 4 B02 03 01 00 01· e = 65537 · 5 B02 82 01 01 00 ‖ 256-byte modulusINTEGER n (leading 0x00) · 261 Btotal = 270 bytes

So the 270-byte blob is a raw DER public key, XOR-masked byte-for-byte, and it must begin 30 82 01 0A once unmasked. That gives a free correctness check before touching RSA at all. The & 0x1F loop is a repeating 32-byte XOR keyed by the seed.

uint8_t pubkey_der[270];
for (int i = 0; i < 270; i++)
    pubkey_der[i] = obf_cert[i] ^ seed[i & 0x1F];   /* seed = 32 B @ 8179A2C0 */

rsa_parse_pub_key(&rsa_ctx, pubkey_der, 270);

So in a script we can do something like :

def deobfuscate_pubkey(obf_cert: bytes, seed: bytes) -> bytes:
    der = bytes(obf_cert[i] ^ seed[i & 0x1F] for i in range(len(obf_cert)))
    assert der[:4] == bytes.fromhex("3082010A"), "seed or offset is wrong"
    return der   # load with RSA.import_key(...) as a DER RSAPublicKey

The seed at …8179A2C0 is 32 bytes and lives in kernel data, exactly like the disk key, so the tempting wrong guess is that it is the key for the big blob. It isn’t; it only unmasks the public key in memory. The DER-header assertion above disproves the misread — a wrong 32-byte value won’t XOR the certificate into a valid SEQUENCE. The second trap is an off-by-one in the payload slice. After grabbing the 256-byte tail and running m = c^e mod n, you recover a structured 256-byte block. The C code reads the key at a fixed struct offset into that buffer (v11 + 223), anchored from the front. Reproducing that naïvely in Python breaks:

pow(...).to_bytes(256, "big") renders the recovered integer big-endian, and any recovered value whose top byte is zero — the PKCS#1 leading 00, or simply m < 2^2040 — shifts a front-anchored slice by one byte. The key lands one byte early and the output is uniform garbage. The fix is the general rule for PKCS#1 message recovery: anchor to the tail, not to a hardcoded front index. recovered[-32:] and the disassembly’s v11 + 223 (byte 224 of a 256-byte struct) then coincide.

RECOVERED = POW(c, e, n) — 256 BYTES, BIG-ENDIAN00 01 · FF…FF padding · 00 · body0 … 160SHA256160:192field_B192:224RC4 key224:256tail-anchoredrecovered[-32:] → [224:256] ✓front-anchoredfixed index (v11+223) → slips 1 byte, byte0 = 00 ✗

With the slice corrected, the trailing 96 bytes resolve into three aligned fields:

recovered = pow(c_tail, e, n).to_bytes(256, "big")

#  [00 01] [FF … FF] [00]  ||  <---------- payload (96) ---------->
#                              SHA256(body)  |  field_B (32)  |  RC4 key (32)
#                              [160:192]        [192:224]        [224:256]
expected_sha256 = recovered[-96:-64]   # 160:192
rc4_key         = recovered[-32:]      # 224:256

The middle 32 bytes ([192:224]) are recovered but unused by the decryptor. Given the layout, the candidates are a second digest (SHA256 of the decrypted body, as a post-decrypt check), a per-image nonce, or a build/model tag binding the key to one firmware. Diffing that field across two images settles it: constant means a tag or reserved field, per-image means a digest or nonce. The loop itself is self-verifying, you only trust the extracted key if SHA256(encrypted_body) equals recovered[160:192], which confirms the offsets. The key schedule uses key[i & 0x1F], which is ordinary RC4 key scheduling with the key length hardcoded to 32 (% keylen collapses to & 0x1F). In the x86_64 disassembly of sub_FFFFFFFF81710334() it shows up as an and eax, 0x1F inside a 256-iteration loop that also swaps S-box entries. That pairing is the signature for “RC4-family, 32-byte key,” and it’s the first thing to grep for.

The custom part seems to be in the PRGA output, and the classic RC4 byte is still in there. After the swap, S[i] = old_sj and S[j] = old_si, so the standard RC4 output S[(S[i] + S[j]) & 0xFF] is really just S[(old_sj + old_si) & 0xFF] — which is comp_z in the code. So Fortinet does the normal RC4 thing first, computes the plain RC4 byte, and then whitens it by running it through two extra table lookups:

PRGA OUTPUT — CLASSIC RC4 BYTE, WHITENEDK =comp_x^ ((comp_y+comp_z) & 0xFF)comp_z= classic RC4 output S[S[i] + S[j]]comp_y= S[(S[idx1] + S[idx2]) ^ 0xAA]nonlinear diffusioncomp_x= S[(j + old_sj) & 0xFF]extra lookupidx1 = (j >> 3) ^ (i << 5), idx2 = (i >> 3) ^ (j << 5)

The disassembly tells for the added layer are shr …, 3 / shl …, 5 (the >>3 / <<5 on the counters) and a literal xor …, 0xAA, none of which appear in stock RC4.

; sub_FFFFFFFF81710334
    shr   eax, 3              ; j >> 3        ┐  idx1 = (j>>3) ^ (i<<5)
    shl   edx, 5              ; i << 5        ┘
    xor   eax, edx
    movzx ecx, byte [S+eax]   ; S[idx1]
    ; … idx2 = (i>>3) ^ (j<<5) ; S[idx2] the same way …
    add   ecx, esi            ; S[idx1] + S[idx2]
    xor   ecx, 0AAh           ; ← whitening tell, absent from stock RC4
    movzx ecx, byte [S+ecx]   ; comp_y = S[(S[idx1] + S[idx2]) ^ 0xAA]

The motive is the same as dropping "expand 32-byte k": keep a proven cipher core, add whitening so the emitted keystream matches no reference RC4, and defeat constant- and signature-matching without designing a cipher from scratch. Once the key boundary is aligned and the PRGA operator precedence is reproduced, the SHA256 computed over the firmware body matches the digest recovered from the RSA block.

obf cert 270 ⊕ seed 32XOR (i & 0x1F)DER pubkey (n, e)RSA block · full[-256:]256 Brecovered[256]SHA256 160:192 · field_B 192:224 ·RC4 key 224:256c^e mod nSHA256(body) == recovered[160:192]rc4_key = recovered[-32:]forti_custom_rc4_decrypt(body, key)rootfs_decrypted.gz
import hashlib
from Crypto.PublicKey import RSA

CERT_FILE             = "forti_pubkey.der"      # de-obfuscated DER RSAPublicKey (270 B)
ROOTFS_ENCRYPTED_FILE = "rootfs.gz"             # original encrypted firmware
ROOTFS_DECRYPTED_FILE = "rootfs_decrypted.gz"

# 256-byte RSA-wrapped block from the tail of rootfs.gz
rootfs_tail_hex = (
    "31C6C1E3E33F19F477E38CB15845DFC919C60D0CE33975E34A63815BB8BCB47C"
    "4FDCA8AC8F30C64053C00A045FA876F2B3C2D0AA2D82B64842E2C6B43C7A4A6C"
    "169577A860171DE17019EE1CE7C89E6F44833B959104E4E0B61525C214EA68AE"
    "D4557BC902597E15B8C5A38C18217FB0E9F77EE1F3E50CEE4D57166DC6FA358B"
    "5E6FAF48B11D26399209D792018729E1AF9040C14026250EF18DA11EBE14A8F9"
    "B9D2F002B2772DA82796E755DCFD238DD563611C323175ED4E238B23EB76D6FA"
    "EE0A6FB93EA9CC5149A62AB2685B44ECFB43BE3185D3976B0CE58E6BC9A2B01F"
    "0D8D2BCA4CD928C9F874E4F6D5A81460257C31C6CE2CC09B49F772878E08BEA0"
)

def deobfuscate_pubkey(obf_cert: bytes, seed: bytes) -> bytes:
    """Rebuild the DER RSAPublicKey from the 270-B obfuscated cert (byte_..A1A0)
    and the 32-B seed (byte_..A2C0). Must start with SEQUENCE 30 82 01 0A."""
    der = bytes(obf_cert[i] ^ seed[i & 0x1F] for i in range(len(obf_cert)))
    assert der[:4] == bytes.fromhex("3082010A"), "seed or offset is wrong"
    return der

def forti_custom_rc4_decrypt(ciphertext: bytes, key: bytes) -> bytes:
    # --- KSA: standard RC4 schedule, key length hardcoded to 32 (i & 0x1F) ---
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i & 0x1F]) & 0xFF
        S[i], S[j] = S[j], S[i]

    # --- PRGA: classic RC4 byte (comp_z) whitened by two extra lookups ---
    plaintext = bytearray(len(ciphertext))
    i = j = 0
    for idx, c in enumerate(ciphertext):
        i = (i + 1) & 0xFF
        old_si = S[i]
        j = (j + old_si) & 0xFF
        old_sj = S[j]
        S[i], S[j] = old_sj, old_si

        comp_x = S[(j + old_sj) & 0xFF]

        idx1 = ((j >> 3) ^ (i << 5)) & 0xFF
        idx2 = ((i >> 3) ^ (j << 5)) & 0xFF
        sum_idx = (S[idx1] + S[idx2]) & 0xFF
        comp_y  = S[(sum_idx ^ 0xAA) & 0xFF]

        comp_z  = S[(old_sj + old_si) & 0xFF]      # == standard RC4 output
        keystream = comp_x ^ ((comp_y + comp_z) & 0xFF)

        plaintext[idx] = c ^ keystream
    return bytes(plaintext)

# --- Load the reconstructed public key -------------------------------------
print("Parsing the RSA public key...")
with open(CERT_FILE, "rb") as f:
    pub = RSA.import_key(f.read())
n, e = pub.n, pub.e
print(f"  N (first 16 hex): {hex(n)[:18]}...")

# --- RSA message recovery over the tail block ------------------------------
print("Recovering the wrapped payload (m = c^e mod n)...")
c_tail = int.from_bytes(bytes.fromhex(rootfs_tail_hex), "big")
recovered = pow(c_tail, e, n).to_bytes(256, "big")

# Trailing 96 bytes = [ SHA256(body) | field_B | RC4 key ], tail-anchored
expected_sha256 = recovered[-96:-64]
rc4_key         = recovered[-32:]
print(f"  expected SHA256 : {expected_sha256.hex()}")
print(f"  core RC4 key    : {rc4_key.hex()}")

# --- Self-verifying integrity check ----------------------------------------
print("Reading and verifying firmware body...")
with open(ROOTFS_ENCRYPTED_FILE, "rb") as f:
    full = f.read()
encrypted_body = full[:-256]
actual_sha256  = hashlib.sha256(encrypted_body).digest()
print(f"  actual SHA256   : {actual_sha256.hex()}")

if actual_sha256 == expected_sha256:
    print("  SHA256 match — offsets and key are correct.")
else:
    print("  SHA256 MISMATCH — check tail slice / payload offsets.")

# --- Decrypt ---------------------------------------------------------------
print("Decrypting the filesystem with the RC4 variant...")
decrypted = forti_custom_rc4_decrypt(encrypted_body, rc4_key)
with open(ROOTFS_DECRYPTED_FILE, "wb") as f:
    f.write(decrypted)
print(f"Done — written to {ROOTFS_DECRYPTED_FILE}")

Why does Fortinet do This?

In the posts by Bishop Fox and GreyNoise, both were unable to figure out why Fortinet designed their firmware encryption scheme like this. The truth is that a FortiGate boots unattended with no password or network, so it has to carry everything needed to decrypt its own rootfs.gz, which means you can never get real confidentiality of the filesystem against someone with the image, because the decryption path ships inside the image. In the 7.0.x firmware version, the stock ChaCha20 with the key in the clear was pure obfuscation meant only to keep binwalk and casual readers out. Which is probably why the key was simply hardcoded (it has to live on the device somewhere, and the kernel is the easy spot).

The 8.0.x seems to try to wrap the key instead of leaving it as a plaintext (so only Fortinet’s private key could have produced it), the public key is XOR-obfuscated so the anchor is harder to find, and ChaCha20 is swapped for a whitened RC4 so there’s no magic constant or reference keystream to match. Crucially it’s RSA signing with message recovery, not encryption because the goal was to prove the image came from Fortinet. In this case i think they did well enough, but this demonstrates that firmware compromise of perimeter devices such as network appliances are becoming more common and defenders need to actually start preparing not only to defend their networks from these attacks but learn how to properly respond and investigate these types of incidents.

So what did we learn from all this? Probably that all of this is just very hard. Cryptographically function cracking, firmware decryption, linux kernel analysis, if you’re able to do all of that you won’t settle working in incident response. You would be working in vulnerability research to find zero days and easily make ten times more. The unfortunate truth is that so far there hasn’t really been an effective way to detect, analyze, and tackle these types of attacks.