Decrypting FortiOS 7.0.x

This article steps through decrypting FortiGate FortiOS 7.0.x firmware.
fortinet
vulnerabilities
disclosure
decryption
Author

GreyNoise Labs Research Team

Published

April 23, 2024

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:

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.