diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c353f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.venv*/ diff --git a/README.md b/README.md index 939079b..fbe9c24 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,165 @@ # FortiGate `rootfs.gz` decryption tool -Fortinet regularly updates crypto obfuscation for their firmware. +`decrypt_rootfs.py` decrypts FortiGate VM `rootfs.gz` ramdisk payloads using +the matching `flatkc` kernel ELF from the same firmware image. -`decrypt_rootfs.py` is an all-in-one tool allowing decryption of `rootfs.gz` (ramdisk file). +Fortinet changes this boot-time crypto code across releases. The tool supports +both the older ChaCha20/AES path and the newer XOR/RSA/custom-stream path, and +selects the supported path automatically. -Tested on: - - FortiGate 7.4.7 +## Supported versions + +Tested targets: + +| Version | Status | Notes | +| --- | --- | --- | +| FortiGate 7.4.7 | Supported | Original ChaCha20-obfuscated RSA key and AES body decrypt path. | +| FortiGate 7.6.6 | Supported | New verifier path with XOR-recovered RSA key and custom stream body decrypt. | +| FortiGate 8.0.0 | Supported | New verifier path with alternate stream PRGA initialization. | + +Community-reported targets: + +| Version | Status | Notes | +| --- | --- | --- | +| FortiGate 7.4.6 | Reported working | Legacy decrypt path with the stack-size fallback locator. | +| FortiGate 7.6.3 ALI build3510 | Not merged | Upstream PR reports a different legacy layout; not validated in this fork. | + +Other FortiGate releases may work if they use one of these verifier layouts. +Run with `--debug` if a nearby release fails, then compare the verifier code in +`.init.text`. + +## Inputs + +The script needs two files from the same firmware image: + +- `flatkc`: the extracted kernel ELF, usually named `flatkc.elf` after unpacking. +- `rootfs.gz`: the encrypted root filesystem payload. The final 256 bytes are + the RSA signature/trailer used to recover and verify the body decryption key. + +The output is a decrypted gzip-compressed cpio archive. It is still compressed; +extract it after decryption. + +## Requirements + +- Python 3 +- GNU `objdump` for the legacy verifier path +- Python packages from `requirements.txt` + +The Python dependencies include `miasm`, `pycryptodome`, `pyasn1`, +`pyasn1_modules`, `pyfiglet`, and `tqdm`. ## Setup ```bash -$ python3 -m venv .venv -$ . .venv/bin/activate -(.venv) $ pip install -r requirements.txt +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt ``` ## Usage ```bash -user@randorisec:~/fortigate-crypto$ python decrypt_rootfs.py flatkc.elf.x64.7.4.7 ./vmdk/fs/rootfs.gz rootfs.gz.dec - ____ _ _ _ ____ ___ ____ ___ ____ _____ ____ -| _ \ / \ | \ | | _ \ / _ \| _ \|_ _/ ___|| ____/ ___| -| |_) | / _ \ | \| | | | | | | | |_) || |\___ \| _|| | -| _ < / ___ \| |\ | |_| | |_| | _ < | | ___) | |__| |___ -|_| \_\/_/ \_\_| \_|____/ \___/|_| \_\___|____/|_____\____| - - - https://randorisec.fr - - -[INFO] Retrieving crypto material... -[INFO] Decrypting ./vmdk/fs/rootfs.gz... -73822496it [00:19, 3812345.28it/s] -[INFO] DONE. -user@randorisec:~/fortigate-crypto$ file rootfs.gz.dec -rootfs.gz.dec: gzip compressed data, last modified: Mon Jan 20 18:44:06 2025, from Unix, original size modulo 2^32 119727260 -user@randorisec:~/fortigate-crypto$ gzip -dc -S .dec < rootfs.gz.dec > rootfs.cpio -user@randorisec:~/fortigate-crypto/tmp$ mkdir tmp; cd tmp; sudo cpio -idv < ../rootfs.cpio -user@randorisec:~/fortigate-crypto/tmp$ ll -total 47212 -drwxr-xr-x 13 user user 4096 Feb 20 13:32 ./ -drwxr-xr-x 5 user user 4096 Feb 20 13:32 ../ --r--r--r-- 1 root root 33109932 Feb 20 13:32 bin.tar.xz -drwxr-xr-x 2 root root 4096 Feb 20 13:32 boot/ -drwxr-xr-x 3 root root 4096 Feb 20 13:32 data/ -drwxr-xr-x 2 root root 4096 Feb 20 13:32 data2/ -drwxr-xr-x 8 root root 20480 Feb 20 13:32 dev/ -lrwxrwxrwx 1 root root 8 Feb 20 13:32 etc -> data/etc/ -lrwxrwxrwx 1 root root 1 Feb 20 13:32 fortidev -> // -lrwxrwxrwx 1 root root 10 Feb 20 13:32 init -> /sbin/init* -drwxr-xr-x 5 root root 4096 Feb 20 13:32 lib/ -lrwxrwxrwx 1 root root 4 Feb 20 13:32 lib64 -> /lib/ --r--r--r-- 1 root root 14456836 Feb 20 13:32 migadmin.tar.xz --r--r--r-- 1 root root 549180 Feb 20 13:32 node-scripts.tar.xz -drwxr-xr-x 2 root root 4096 Feb 20 13:32 proc/ -drwxr-xr-x 2 root root 4096 Feb 20 13:32 sbin/ -drwxr-xr-x 2 root root 4096 Feb 20 13:32 sys/ -drwxr-xr-x 2 root root 4096 Feb 20 13:32 tmp/ -drwxr-xr-x 3 root root 4096 Feb 20 13:32 usr/ --r--r--r-- 1 root root 148572 Feb 20 13:32 usr.tar.xz -drwxr-xr-x 9 root root 4096 Feb 20 13:32 var/ +python decrypt_rootfs.py +``` + +Example: + +```bash +python decrypt_rootfs.py \ + flatkc.elf \ + rootfs.gz \ + rootfs.cpio.gz +``` + +For additional locator/decryption details: + +```bash +python decrypt_rootfs.py --debug flatkc.elf rootfs.gz rootfs.cpio.gz +``` + +## Verify the output + +The decrypted output should start with gzip magic and pass `gzip -t`: + +```bash +gzip -t rootfs.cpio.gz +file rootfs.cpio.gz +``` + +Expected `file` output is similar to: + +```text +rootfs.cpio.gz: gzip compressed data, from Unix +``` + +## Extract the decrypted rootfs + +```bash +mkdir rootfs +gzip -dc rootfs.cpio.gz | (cd rootfs && sudo cpio -idmv) +``` + +`sudo` is useful because FortiGate ramdisks contain root-owned files, device +nodes, and absolute symlinks. If you only need a quick listing, you can inspect +the archive without extraction: + +```bash +gzip -dc rootfs.cpio.gz | cpio -it | less ``` + +## How it works + +The script first splits the encrypted `rootfs.gz` into: + +- encrypted rootfs body +- 256-byte RSA trailer/signature + +It then tries the newer verifier path: + +1. Parse `flatkc` as an ELF64 file. +2. Use `__ksymtab` to locate `rsa_parse_pub_key`, even when kallsyms are + tampered. +3. Locate the init-time verifier callsite in `.init.text`. +4. Recover the DER-encoded RSA public key by XORing a 270-byte blob with a + repeating 32-byte pad. +5. Verify the RSA trailer and `sha256(encrypted-rootfs)`. +6. Recover the 32-byte stream key and decrypt the body with the FortiOS stream + transform. + +If that path is unavailable, the script falls back to the legacy path: + +1. Locate `fgt_verify_initrd` from the `rsa_parse_pub_key` callsite. +2. If the callsite is unavailable in the symbolized kernel, try a stack-size + heuristic reported for nearby 7.4.x builds. +3. Recover the ChaCha20-obfuscated RSA public key. +4. Verify the RSA trailer and encrypted rootfs hash. +5. Decrypt the body using the AES-derived stream used by older releases. + +## Troubleshooting + +`New verifier path unavailable, falling back to legacy path` + +This is normal for older supported releases. Use `--debug` to see the exception +that caused the newer path to be skipped. + +`expected a little-endian ELF64 flatkc` + +The first argument is not the expected x86-64 kernel ELF. Re-check your firmware +extraction and make sure you are passing `flatkc.elf`, not the compressed kernel +container. + +`rootfs hash check failed` + +The `flatkc` and `rootfs.gz` probably came from different firmware images, or +the encrypted `rootfs.gz` was truncated or modified. + +`neither stream mode produced gzip magic` + +The RSA trailer verified, but the body stream layout did not match the tested +7.6.6/8.0.0 variants. Diff the verifier's body-decrypt helper against a known +supported build. + +## Notes + +This tool is intended for firmware analysis and defensive research. It does not +modify the input files. diff --git a/decrypt_rootfs.py b/decrypt_rootfs.py index e60c0a9..aa02fcb 100644 --- a/decrypt_rootfs.py +++ b/decrypt_rootfs.py @@ -12,6 +12,18 @@ import subprocess import binascii import pyfiglet +import struct + + +RSA_PUBKEY_LEN = 0x10E +XOR_PAD_LEN = 0x20 +RSA_BLOCK_LEN = 0xFF +SIG_TRAILER_LEN = 0x100 +PKCS1_PAD_END = 0x9E +NEW_HASH_OFF = 0x9F +NEW_STREAM_KEY_OFF = 0xDF +GZIP_MAGIC = b"\x1f\x8b\x08\x00" +CHUNK_SIZE = 1024 * 1024 class crypto_ctx_ctr(ctypes.Structure): @@ -45,20 +57,325 @@ def print_logo(): print("https://randorisec.fr\n\n\n".center(max_width)) +def sx32(value): + value &= 0xFFFFFFFF + if value & 0x80000000: + value -= 0x100000000 + return value & 0xFFFFFFFFFFFFFFFF + + +def parse_sections(data): + if data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1: + raise RuntimeError("expected a little-endian ELF64 flatkc") + + e_shoff = struct.unpack_from("> 3)) & 0xFF + idx2 = ((j << 5) ^ (i >> 3)) & 0xFF + idx3 = ((sbox[idx2] + sbox[idx1]) ^ 0xAA) & 0xFF + idx4 = (a + b) & 0xFF + idx5 = (b + j) & 0xFF + stream_byte = ((sbox[idx4] + sbox[idx3]) & 0xFF) ^ sbox[idx5] + out[off] = char ^ stream_byte + self.i = i + self.j = j + return bytes(out) + + +def select_new_stream_mode(rootfs_enc, stream_key): + for reset_prga in (True, False): + trial = FortiStream(stream_key, reset_prga=reset_prga).crypt(rootfs_enc[:16]) + logging.info( + "New verifier: trial reset_prga=%s -> %s", + reset_prga, + trial[:4].hex(), + ) + if trial.startswith(GZIP_MAGIC): + return reset_prga + raise RuntimeError("neither stream mode produced gzip magic") + + +def decrypt_new_rootfs(file_rootfs_dec, rootfs_enc, stream_key, reset_prga): + stream = FortiStream(stream_key, reset_prga=reset_prga) + with open(file_rootfs_dec, "wb") as fd_out, tqdm(total=len(rootfs_enc)) as pbar: + for off in range(0, len(rootfs_enc), CHUNK_SIZE): + chunk = rootfs_enc[off : off + CHUNK_SIZE] + fd_out.write(stream.crypt(chunk)) + pbar.update(len(chunk)) + + +def try_new_decrypt(file_flatkc, file_rootfs_dec, rootfs_enc, rootfs_sig): + decoded_key = recover_new_public_key(file_flatkc) + stream_key = recover_new_stream_key(rootfs_enc, rootfs_sig, decoded_key) + reset_prga = select_new_stream_mode(rootfs_enc, stream_key) + logging.info("New verifier: selected reset_prga=%s", reset_prga) + decrypt_new_rootfs(file_rootfs_dec, rootfs_enc, stream_key, reset_prga) + with open(file_rootfs_dec, "rb") as fd: + magic = fd.read(4) + if magic != GZIP_MAGIC: + raise RuntimeError( + f"decrypted output does not start with gzip magic: {magic.hex()}" + ) + + +def objdump_init_text(file_flatkc, intel=False): + cmd = ["objdump", "-d", "--section=.init.text"] + if intel: + cmd.extend(["-M", "intel"]) + cmd.append(file_flatkc) + return subprocess.check_output(cmd).decode().splitlines() + + +def parse_objdump_addr(line): + addr, sep, _ = line.partition(":") + if not sep: + return None + try: + return int(addr.strip(), 16) + except ValueError: + return None + + +def locate_fgt_verify_initrd_by_symbol(file_flatkc): + last_push_rbp = None + for line in objdump_init_text(file_flatkc): + if "push" in line and "rbp" in line: + last_push_rbp = parse_objdump_addr(line) + if "rsa_parse_pub_key" not in line: + continue + if last_push_rbp is None: + break + return last_push_rbp + raise RuntimeError("could not locate fgt_verify_initrd from rsa_parse_pub_key") + + +def locate_fgt_verify_initrd_by_stack_size(file_flatkc): + candidates = [] + for line in objdump_init_text(file_flatkc, intel=True): + if "sub" not in line or "rsp,0x390" not in line: + continue + addr = parse_objdump_addr(line) + if addr is not None: + candidates.append(addr - 0x11) + if len(candidates) != 1: + candidate_text = [hex(c) for c in candidates] + raise RuntimeError( + f"expected one legacy stack-size candidate, got {candidate_text}" + ) + return candidates[0] + + def locate_fgt_verify_initrd(file_flatkc): - output = subprocess.check_output( - f""" - objdump -d --section=.init.text {file_flatkc} | - egrep "rsa_parse_pub_key|push.*rbp" | - egrep "rsa_parse_pub_key" -B1 | - head -1 | - cut -d':' -f1 - """, - shell=True, - ).decode() - - seed_addr = int(output, 16) - logging.debug(f"SEED address found: {hex(seed_addr)}") + try: + seed_addr = locate_fgt_verify_initrd_by_symbol(file_flatkc) + logging.debug( + "SEED address found by rsa_parse_pub_key callsite: %s", + hex(seed_addr), + ) + return seed_addr + except Exception as exc: + logging.debug("Legacy symbol locator unavailable: %s", exc) + + seed_addr = locate_fgt_verify_initrd_by_stack_size(file_flatkc) + logging.debug("SEED address found by stack-size fallback: %s", hex(seed_addr)) return seed_addr @@ -131,30 +448,23 @@ def decrypt_rootfs(file_rootfs_dec, rootfs_enc): cipher = AES.new(bytes(sig_struct.aes_key), AES.MODE_ECB) blk_off = 0 - rootfs_dec = bytes() - fd_out = open(file_rootfs_dec, "wb") - with tqdm(total=len(rootfs_enc)) as pbar: + with open(file_rootfs_dec, "wb") as fd_out, tqdm(total=len(rootfs_enc)) as pbar: while blk_off < len(rootfs_enc): + chunk = rootfs_enc[blk_off : blk_off + AES.block_size] keystream = cipher.encrypt(sig_struct.u.counter) fd_out.write( bytes( [ b ^ k for b, k in zip( - rootfs_enc[blk_off : blk_off + AES.block_size], keystream + chunk, keystream ) ] ) ) sig_struct.u.ctr.counter += max(ctr_increment, 1) - blk_off += AES.block_size - pbar.update(AES.block_size) - - if len(rootfs_enc) % AES.block_size > 0: - keystream = cipher.encrypt(sig_struct.u.counter) - fd_out.write( - bytes([b ^ k for b, k in zip(rootfs_enc[blk_off:], keystream)]) - ) + blk_off += len(chunk) + pbar.update(len(chunk)) if __name__ == "__main__": @@ -171,10 +481,21 @@ def decrypt_rootfs(file_rootfs_dec, rootfs_enc): with open(options.rootfs, "rb") as fd: data = fd.read() - rootfs_enc, rootfs_sig = data[:-256], data[-256:] + rootfs_enc, rootfs_sig = data[:-SIG_TRAILER_LEN], data[-SIG_TRAILER_LEN:] logging.info(f"Retrieving crypto material...") + try: + try_new_decrypt(options.flatkc, options.rootfs_dec, rootfs_enc, rootfs_sig) + logging.info("DONE.") + raise SystemExit(0) + except Exception as exc: + logging.debug( + "New verifier path unavailable, falling back to legacy path: %s", + exc, + exc_info=options.debug, + ) + fgt_verify_initrd_addr = locate_fgt_verify_initrd(options.flatkc) loc_db = LocationDB() container = Container.from_stream(open(options.flatkc, "rb"), loc_db)