Full Report
Introduction Decrypting Fortinet’s FortiGate FortiOS firmware is a topic that has been thoroughly covered, in part because of the many variants and permutations of FortiOS firmware, all differing based on hardware architecture and versioning — we may have avoided a couple of complications ourselves had we used the VM64 image instead of the one for our specific aarch64 hardware. Regardless, the latest round of vulnerability disclosures from Fortinet was met with many updated blogs about decrypting their devices’ rootfs.gz file in the firmware. To my knowledge, none of which covered how to do so on 7.0.x. So, using the preexisting research as a template, the GreyNoise Research team had some fun developing a decryption script to do so ourselves. That said, this blog would likely not exist without the great work from other teams. Our primary references being: Further Adventures in Fortinet Decryption — Bishop Fox FortiGate Firmware Analysis — Optistream What We Know From the aforementioned blogs, we learned of a really good starting point: a function named, fgt_verify_decrypt. Bishop Fox: Fortinet Adventures in Decryption figure 1 However, in 7.0.x, instead of the function fgt_verifier_key_iv, we noticed this: 7.0.x fgt_verify_decrypt Do you see it? If not, try looking again at what appears to be the start of a hardcoded key held in DAT_ffffffc00070fa80 on line fc0006eccf0. Further evidence can be found when looking into the function crypto_chacha20_init; a quick Google for this function returns the following result, as implemented in some Android kernel source code: void crypto_chacha20_init(u32 *state, struct chacha20_ctx *ctx, u8 *iv) { static const char constant[16] = "expand 32-byte k"; state[0] = le32_to_cpuvp(constant + 0); state[1] = le32_to_cpuvp(constant + 4); state[2] = le32_to_cpuvp(constant + 8); state[3] = le32_to_cpuvp(constant + 12); state[4] = ctx->key[0]; state[5] = ctx->key[1]; state[6] = ctx->key[2]; state[7] = ctx->key[3]; state[8] = ctx->key[4]; state[9] = ctx->key[5]; state[10] = ctx->key[6]; state[11] = ctx->key[7]; state[12] = le32_to_cpuvp(iv + 0); state[13] = le32_to_cpuvp(iv + 4); state[14] = le32_to_cpuvp(iv + 8); state[15] = le32_to_cpuvp(iv + 12); } And after cleaning up some of the Ghidra data structures, it looks like the Fortinet implementation is nearly identical: void crypto_chacha20_init(state_struct *state,key_struct *chacha20_ctx,iv_struct iv) { dword *pdVar1; pdVar1 = iv._0_8_; state->const0 = 0x61707865; state->const1 = 0x3320646e; state->const2 = 0x79622d32; state->const3 = 0x6b206574; (state->key).key0 = chacha20_ctx->key0; (state->key).key1 = chacha20_ctx->key1; (state->key).key2 = chacha20_ctx->key2; (state->key).key3 = chacha20_ctx->key3; (state->key).key4 = chacha20_ctx->key4; (state->key).key5 = chacha20_ctx->key5; (state->key).key6 = chacha20_ctx->key6; (state->key).key7 = chacha20_ctx->key7; (state->count_iv).count = *pdVar1; (state->count_iv).nonce0 = pdVar1[1]; (state->count_iv).nonce1 = pdVar1[2]; (state->count_iv).nonce2 = pdVar1[3]; return; } The only difference is the constant. However, for those of you who have read the other blogs, you may recognize the constant implemented by Fortinet as the same one used in the RFC 7539 example. RFC 7539 ChaCha20 Block Function After following DAT_ffffffc00070fa80 to where it exists in memory, we created some new data types in Ghidra based on the RFC, and after applying them to this memory block, it rendered some pretty nice results, which revealed to us that the entire ChaCha20 state is held statically in memory. Static ChaCha20 state Decrypting Adopting the method used by Bishop Fox, we used python’s lief SDK to grab the offsets needed to run aarch64-linux-gnu-objdump. Piping that through some cursed regex, we successfully extracted the keys and decrypted rootfs.gz from 7.0.13 and 7.0.14. import argparse from Crypto.Cipher import ChaCha20 import lief import subprocess parser = argparse.ArgumentParser(description="This is a decrypter for aarch64l versions of fortigate, specifically 7.0.x. It has been tested on FGT_40F-v7.0.x.") parser.add_argument('flatkc_elf') parser.add_argument('rootfs_gz') parser.add_argument('rootfs_decrypted_gz') args = parser.parse_args() BINARY=args.flatkc_elf ROOTFS=args.rootfs_gz OUTPUT=args.rootfs_decrypted_gz def objdump(start, stop, f): cmd = f"aarch64-linux-gnu-objdump -d --start-address={start} --stop-address={stop} {BINARY} | grep \"ff.*:\" | grep -v \">:\" | cut -f{f}" return subprocess.check_output(cmd, shell=True).decode().split('\n') def get_ctx_iv_address(): binary = lief.parse(BINARY) start = binary.get_static_symbol("fgt_verify_decrypt").value+0x4 stop = start + 0xc lines = objdump(hex(start), hex(stop), 4) values = [] for line in lines: for i in line.split(' '): if len(i) > 0: if i[0] == 'f': values.append('0x'+i) if i[0] == '#': values.append(i.strip('#')) return int(int(values[0], 16)+int(values[1], 16)+int(values[2], 16)) def get_bytes(word, wbs): while word > 0: wbs.append(hex(word & 0xff)) word >>= 8 def get_ctx_iv(addr): wbs = [] key_words = objdump(hex(addr), (hex(addr+0x30)), 2) for word in key_words: if word: get_bytes(int(word, 16), wbs) bs = bytes([int(x, 0) for x in wbs]) key = bs[:32] iv = bs[32:] print(f"key: {key.hex()}\niv: {iv.hex()}") return (key, iv) def decrypt(key, iv): chacha = ChaCha20.new(key=key, nonce=iv[4:]) counter = int.from_bytes(iv[:4], 'little') chacha.seek(counter*64) with open(ROOTFS, 'rb') as fi: decrypted = chacha.decrypt(fi.read()) with open(OUTPUT, 'wb') as fo: fo.write(decrypted) def main(): addr = get_ctx_iv_address() (key, iv) = get_ctx_iv(addr) decrypt(key, iv) print("Done!") if __name__ == "__main__": try: main() except KeyboardInterrupt: exit(0) One more hurdle remained before finally retrieving the decrypted binary we sought, and that was actually unpacking it. Fortinet already has an xz binary sitting in /bin, but it’s compiled for aarch64. A simple workaround for this (on Linux) is installing qemu-aarch64-static. Here’s a short and sweet script for doing this in a docker instance: docker run --rm -it -v ./rootfs_decrypted:/mnt/rootfs ubuntu:latest #In container apt update && apt install -y qemu-user-static qemu-user binfmt-support #Move qemu into the rootfs in path cp /usr/bin/qemu-aarch64-static /mnt/rootfs/sbin #chroot to the mounted rootfs, call qemu to run the new xz with the path to the xz file chroot /mnt/rootfs/ qemu-aarch64-static /sbin/xz -d /bin.tar.xz Conclusion Having decrypted rootfs.gz, we can now research the relevant vulnerabilities. In our research, we were unable to ascertain why 7.0.x uses a hardcoded key. Our guess is as good as yours. In any case, encrypting software as a defense against attackers continues to be only a weak roadblock for the undetermined and a brief — albeit entertaining — delay for researchers seeking to help users.
Analysis Summary
# Tool/Technique: FortiGate FortiOS 7.0.x Firmware Decryption Methodology
## Overview
This summary details the technical steps and findings used by the GreyNoise Research team to decrypt the `rootfs.gz` file contained within Fortinet FortiGate FortiOS 7.0.x firmware images, specifically for `aarch64` hardware architectures. This effort modified previous decryption techniques by identifying differences in the 7.0.x implementation, primarily involving a statically held ChaCha20 state and unique constants within the cryptographic initialization function.
## Technical Details
- Type: Tool (Custom Decryption Script utilizing existing libraries) / Technique (Firmware Reverse Engineering and Cryptanalysis)
- Platform: Fortinet FortiGate running FortiOS 7.0.x (specifically tested on FGT\_40F-v7.0.x running on aarch64 architecture).
- Capabilities: Extracting hardcoded ChaCha20 keys and Initialization Vectors (IVs) from the binary, decrypting the encrypted filesystem archive (`rootfs.gz`), and unpacking the resulting `xz`-compressed file using QEMU for cross-architecture execution.
- First Seen: The specific methodology for 7.0.x detailed here was developed after the release of 7.0.x vulnerability disclosures (April 2024 context).
## MITRE ATT&CK Mapping
The methodology itself is defensive/analytical, but the target artifacts (firmware encryption) relate to protecting proprietary code.
- **T1027 - Obfuscated Files or Information** (Relevant to the decryption process overcoming vendor protection)
- T1027.002 - Interpret Untrusted File
- **T1622 - Deceptive Hosting** (By analyzing the firmware structure)
- T1622.001 - Infrastructure as Code Hosting
## Functionality
### Core Capabilities
1. **Locating Cryptographic Initialization:** Identified a difference in the `fgt_verify_decrypt` function pointer in 7.0.x compared to previous versions.
2. **Hardcoded Key Discovery:** Located a hardcoded key starting at memory address `DAT_ffffffc00070fa80` within the binary.
3. **Cryptographic Analysis:** Confirmed the use of the ChaCha20 cipher in the `crypto_chacha20_init` function, noting that the Fortinet implementation uses the constant defined in RFC 7539, unlike other observed variances.
4. **Static State Revelation:** Determined that the entire ChaCha20 state (key and IV components) is held statically in memory within the analyzed binary.
### Advanced Features
1. **Automated Extraction:** Utilized Python's `lief` SDK to parse the binary format, locate static symbols (e.g., `fgt_verify_decrypt`), and calculate memory offsets using `aarch64-linux-gnu-objdump`.
2. **ChaCha20 Decryption using Counter Mode:** Extracted the 32-byte key and IV components. Crucially, the script adjusted the decryption by setting the counter value (`iv[:4]` converted to an integer) before seeking to the correct block offset (`chacha.seek(counter*64)`) before decrypting the `rootfs.gz` content using Python's `Crypto.Cipher.ChaCha20`.
3. **Cross-Architecture Unpacking:** Overcame the `aarch64` compiled nature of the embedded `xz` utility by using `qemu-aarch64-static` within a Docker/chroot environment to unpack the decrypted filesystem.
## Indicators of Compromise
*Note: This summary describes a research/analysis tool, not malware. IoCs are related to the analysis materials used.*
- File Hashes: N/A (Tool execution)
- File Names: `rootfs.gz` (Encrypted firmware component), `flatkc_elf` (The target binary containing the decryption logic).
- Registry Keys: N/A
- Network Indicators: N/A
- Behavioral Indicators: Execution of `aarch64-linux-gnu-objdump` and `qemu-aarch64-static` against system binaries during analysis.
## Associated Threat Actors
* **GreyNoise Research Team:** Developed and utilized this methodology.
* This finding aids researchers and defenders in understanding the protective measures employed by Fortinet, which malicious actors might attempt to bypass for vulnerability exploitation.
## Detection Methods
Detection focuses on identifying the process of firmware decryption post-exploitation or during supply chain compromise:
- Monitoring for unexpected execution of cross-compilation tools (`qemu-aarch64-static`) or specialized debugging/analysis tools (`lief`, `objdump`, `Ghidra`) against system files.
- Signature-based detection for the specific custom Python decryption script (if distributed).
- Behavioral analysis detecting file manipulation of firmware images (`rootfs.gz`).
## Mitigation Strategies
1. **Timely Patching:** Applying Fortinet updates immediately following vulnerability disclosures, as the decryption relies on analyzing pre-patched firmware binaries based on public disclosures.
2. **Root Filesystem Integrity Checks:** Implement mechanisms to verify the integrity of the root filesystem post-boot, utilizing known firmware hashes if possible.
3. **Hardware Root of Trust:** Relying on platform-level protections inherent in secure boot mechanisms to prevent loading unauthorized or tampered firmware images.
## Related Tools/Techniques
* **Previous Fortinet Decryption Methods:** Techniques documented by Bishop Fox and Optistream focusing on earlier FortiOS versions.
* **QEMU:** Used as an emulation layer for executing cross-architecture binaries.
* **LIEF (Library to Instrument Executable Formats):** Used for programmatic binary analysis and offset calculation.
* **ChaCha20:** The underlying symmetric encryption algorithm used by the firmware.