Series0-dayNo CVEHighPublic

Hunting the Fortinet Hunter 0-Day · Phase 2

The Gate that Wasn't

YogSotho put Argon2id on a 56-megabyte ELF and told people brute force was the wrong path. He was right. I unpacked the Nuitka onefile, recovered the gate, and re-keyed a local copy without his passphrase.

Name
The Gate that Wasn't
Series
Hunting the Fortinet Hunter 0-Day
Phase
2 · The Gate that Wasn't
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Fortinet
Affected
Fortinet Hunter 2026 CTF binary (Nuitka onefile, linux/amd64)
Published
29 Aug 2026
Updated
02 Sept 2026
Tags
0-day, fortinet, nuitka, argon2, reverse-engineering, ctf

@YogSoth0 put a password on a 56-megabyte ELF, named the challenge after TESO, and then told the internet that Argon2 exists specifically so I would waste a decade guessing. He was right about Argon2. The README stated the intended path in the first paragraph. I needed to read it twice, which is on me:

You just stumbled on the encrypted binary of Fortinet Hunter 2026! What a luck! Now it's time to go full reverse engineering.

I did, eventually. First I built a wordlist client, then I built a sandbox, then I walked a pile of TESO lore into a hash that did not care - because he sized it so it would not - then I reversed the launcher the way the README asked. The rest was plumbing.

This is the sequence. Every step was mine. Every dead end is included, because the dead ends are the method.


A stripped Nuitka onefile and a trust problem

Inside the zip:

Plain text
7350FH/7350FH     56,879,312 bytes
7350FH/README.md       3,201 bytes

Linux x86-64 PIE, stripped. file was unhelpful in the way stripped PIE always is. The README was not. Usage was:

Plain text
FH_PASS='<password>' ./7350FH <command>

Wrong passphrase prints [!] access denied and exits 1. Right passphrase is "the FULL complete package (not the slim version)." It is a 56 MB untrusted ELF that claims to be a multi-product offensive framework for Fortinet appliances - scan, exploit, chain, agent, persist, C2, firmware extract, the works. I had permission from the CTF author to open it. I did not have permission from my own laptop to ./7350FH on the host and find out whether "lab-gated" meant what I hoped.

So the next tool had two jobs that were not the same job:

  1. Recover or replace the gate without executing the payload.
  2. If the payload ever had to run, run it in a cage.

That tool is GateX. Same IRC TUI aesthetic as GoFileX. Same session model. Completely different threat model. Late-90s IRC chrome, green-on-black, Elite Hacker Edition, because if you are going to sit with a 56-megabyte untrusted ELF you might as well do it in a client that looks like it could have been an #teso window in 2001. Source is public: github.com/abraxas/GateX. Also on the Open Source shelf.

GateX 1.0.0 TUI in action - Elite Hacker Edition banner, hash-mode running


Building the cage before touching the gate

Untrusted CTF malware-shaped code does not get a chmod +x on the workstation. GateX never execs 7350FH on the host. Three modes, in increasing intimacy:

Mode What happens When
hash Static unpack, XOR-reassemble the PHC, argon2-cffi verify Wordlist walking. ~40 ms/guess on Apple Silicon. No ELF execution.
cage docker exec of the ELF under linux/amd64 Ground truth. Slow. Nuitka extracts tens of megabytes per run, qemu on ARM.
bypass Re-key the scramble blobs, exec the inner ELF in the cage Open the CLI without the author's passphrase.

The cage image is gatex-cage:noble: Ubuntu 24.04, glibc 2.39, uid 65532, --network none, --read-only plus tmpfs on /tmp and /home/cage (Nuitka unpacks there, so exec on tmpfs is required), --cap-drop ALL, --security-opt no-new-privileges:true, --memory 2g --cpus 1 --pids-limit 128. Password only via FH_PASS. Never argv. The zip is bind-mounted read-only and unzipped inside the container.

I learned those constraints the expensive way:

  • Docker Desktop was down the first time I needed it. Start your hypervisor before you start your mystery.
  • First image was debian:bookworm-slim. The inner payload imports GLIBC_2.38. Bookworm does not have it. Ubuntu 24.04 does. The binary told me, via a loader error I initially classified as a password hit because exit-nonzero-but-not-access-denied looked like success if you were being optimistic. I tightened classify_output so loader failures (version \glibc, error while loading shared libraries`, qemu noise, segfault) never count as a hit, and the session rewinds on error.
  • docker cp onto a --read-only rootfs is a philosophical argument you will lose. Bind-mount the zip.
  • Original inner ELF landed mode 0400. Cage user 65532 cannot execute a file it cannot execute. chmod 0700 on the host copy.
  • Running the original from /tmp broke Nuitka's $ORIGIN library lookup. The inner has to live as /opt/fh/7350FH.bin with LD_LIBRARY_PATH=$ORIGIN.
  • list and --help then died with UnicodeEncodeError on ascii. Cage locale was a museum. PYTHONIOENCODING=utf-8 and LANG=C.UTF-8.

None of that opens a password. All of it is why the later evidence is real.


Static unpack: KAY, zstd, and a decoy with a sense of humour

I did not start by guessing. I started by not running it.

The outer ELF is a Nuitka onefile bootstrap. .rodata begins with magic KAY followed by a zstd stream (\x28\xb5\x2f\xfd). Packed-file records are the usual Nuitka layout: filename, NUL, flags u8, size u64le, payload. The first interesting file is 7350FH.bin - the compiled slim_launcher - roughly 117 MB of inner ELF sitting inside the 56 MB outer because compression is a hell of a drug.

Needle search on the inner image found the things the README promised, plus one thing it didn't:

  • FH_PASS
  • [!] access denied
  • [!] no passphrase provided
  • [!] core integrity check failed
  • Fortinet Hunter passphrase:
  • _verify_password
  • _decrypt_core
  • slim_launcher.py
  • a Nuitka compile line pointing at build/slim_launcher.py
  • an Argon2id PHC string sitting in plaintext

That last one was a trap. It was the doctest hash from the argon2-cffi README:

Plain text
$argon2id$v=19$m=65536,t=3,p=4$MIIRqgvgQbgj220jfp0MPA$YfwJSVjtjSU0zzV/P3S9nnQ/USre2wvJMjfCIjrTQbg

Password: correct horse battery staple. I know because I checked, and because the library authors published it as an example, and because hiding a real gate hash in the clear next to a copy of the library's own README would be a choice. The real hash was not in the clear.

The real hash was fourteen XOR-scrambled base64 blobs sitting immediately before a marker:

Plain text
cfh-slim-hardened-v2

That leading c will matter later. Nuitka's blob tag for BYTES is c. What looks like an AAD string in a hex dump is c + fh-slim-hardened-v2.


_xunmask, three fragments, and a hash I was not supposed to brute-force

The launcher stores the PHC as three ciphertext/key pairs: _HASH_A_CT/_KEY, _HASH_B_CT/_KEY, _HASH_C_CT/_KEY. Unmask is not cryptography. Unmask is a build-time scramble:

Python
plaintext = bytes(a ^ b for a, b in zip(b64decode(ct), cycle(b64decode(key)))).decode("utf-8")

Concatenate A, B, C. You get a well-formed PHC:

Plain text
$argon2id$v=19$m=65536,t=3,p=4$+kf2Ve803CePRtOKqd7WUQ$91dU14f2nYCVOViPadLo/xPtDTBdkwn0EFG+nmYdqgY

Parameters: m=65536, t=3, p=4, 32-byte hash, 16-byte salt. That is ~64 MiB of RAM per verifier and, on my machine, about 40 milliseconds per guess. Argon2id with those costs is doing exactly what Argon2id is for.

The rest of the fourteen blobs were the AES side:

Blob Unmasked meaning
salt / key HKDF salt fh-slim-v2-salt-2026
info / key HKDF info fh-slim-v2-core
nonce / key AES-GCM nonce
core / key AES-256-GCM ciphertext (95 bytes = 79-byte banner + 16-byte tag)

Pipeline, reconstructed from strings and the scramble layout:

  1. Read FH_PASS or prompt.
  2. _verify_password - Argon2id against the assembled PHC. Fail → [!] access denied.
  3. HKDF-SHA256, length 32, salt and info as above, IKM = the password bytes.
  4. _decrypt_core - AES-256-GCM with AAD. Fail → [!] core integrity check failed.
  5. cli.main().

The important word in step 5 is main. cli.main(). Nuitka --onefile --standalone emits a native image that is the Python program, so the plugins live in the inner ELF. The AES-GCM payload authenticates a short banner and then hands off to code that was compiled in. That is how this class of launcher is built. It is a gate, and it is a real one.

At this point I had two ways forward: guess the password that verifies the hash and decrypts the banner, or reverse the gate the way the README asked and re-key a local copy.


The author, on the record

Then the hint arrived, in his register:

Bro, it's argon2. Lemme know how it goes with argon2 bruteforcing in some dozen of years. It has been created exactly to avoid this. Bruteforcing. I would call this wrong approach but who knows? Others grabbed the hash already. And? Time for some ol' good RE.

He was not lying. Other people had the hash. The hash does not care. Argon2id at m=65536,t=3,p=4 is a rate limit with a cryptographic costume. If the passphrase is a scene in-joke with enough entropy, you will not walk into it with rockyou-75.txt.

I walked into it anyway, because leaving a high-signal list untried is how you get to write a blog post that ends with "we never checked h0h0h0." Custom TESO/x2 first: teso, 7350, teamteso, tesox2, 7350wurm, h0h0h0, zip/TESO!, mozilla@, SOTE, thekeyandthegate, author handles, the GoFile password again for luck. Then the public shortlists. Hash-mode, no ELF execution, four workers, crash-safe offsets.

The high-signal list missed. Rate: about 40 ms per Argon2id guess on my machine, which is another way of saying "a dozen of years" if the password is actually random. The hash did not move.

The README had already told me what to do. Reverse the gate. Don't date it.


The insight: Nuitka already compiled the framework into the image

Nuitka --onefile --standalone with --include-module=... emits a native image that is the Python program. slim_launcher.py is a gate in front of cli.main(). The exploit plugins, detectors, CVE database, score --ml - they are not in the AES blob. They are in .text and the packed .so files sitting next to 7350FH.bin in the zstd payload. YogSotho still made you earn cli.main(). Argon2id, HKDF, AES-GCM, AAD: the checks run. Fail any of them and you get [!] access denied or [!] core integrity check failed.

What a correct FH_PASS buys you:

  • Argon2 says yes.
  • HKDF produces the AES key.
  • GCM unwraps 79 bytes of banner and authenticates them against an AAD.
  • Control reaches cli.main() in the native image.

If I replace the stored PHC with a hash of a password I know, and I replace the stored ciphertext with a banner encrypted under that same password, the original instruction stream still runs. It is a data patch, not an instruction NOP. The gate still gates. I cut keys for this copy. His keys still fit his copy.

I called the new password gatex, because I am not made of poetry.


Re-keying, and the one-byte lie in the hex dump

/bypass does this, statically, on the inner ELF, never executing it:

  1. Unpack the Nuitka payload to ~/.gatex/cache/<digest>/payload/.
  2. Extract the fourteen scramble blobs.
  3. Generate a new Argon2id PHC of gatex with the same cost parameters, same 97-character length.
  4. Split it the way the original split: 32 / 32 / remainder.
  5. Re-mask each fragment with the original keys, preserving ciphertext length (base64 padding games included - Nuitka stored specific widths).
  6. Derive AES-256-GCM from gatex via the original HKDF salt/info/nonce.
  7. Encrypt a 79-byte replacement banner under the original AAD.
  8. Re-mask the new core ciphertext into the original core blob slot.
  9. bytes.replace the four ciphertext blobs (A, B, C, core) in the inner image. Eight replacements on this build - each blob appeared twice, which is Nuitka being Nuitka.

First attempt: Argon2 accepted gatex. AES did not. [!] core integrity check failed.

I had used AAD cfh-slim-hardened-v2 because that is the needle in the file. That needle is a Nuitka typed constant: tag c (BYTES) plus the actual value fh-slim-hardened-v2. GCM AAD is the bytes value the Python source passed to AESGCM.decrypt, not the on-disk encoding of that constant.

One character. The whole integrity check. I shortened the AAD:

Python
GCM_AAD = b"fh-slim-hardened-v2"

Second attempt, 2026-08-26 18:16:12Z, session bypass-smoke:

Plain text
patched 7350FH.bin  replacements=8
exec patched inner  argv=['list']
BYPASS OK  FH_PASS='gatex'  rc=0  3.7s
Fortinet Hunter v2026.08 - YogSotho / BrokenSec

The original inner, same cage, same FH_PASS=gatex:

Plain text
[!] access denied
exit 1

The patched inner:

Plain text
fortinet-hunter 2026.08
exit 0

That is the experiment. Same binary family, same sandbox, same environment variable. The only difference is four scramble blobs. I did not recover YogSotho's passphrase. I do not have it. I re-keyed the lock.


What was on the other side of the gate

Cage: gatex-cage-bypass-smoke-bp, gatex-cage:noble, linux/amd64, net none, caps dropped, user 65532. Live docker exec, not a screenshot generator.

--version:

Plain text
fortinet-hunter 2026.08

--help is a full argparse tree: scan, exploit, chain, agent, path, list, score, report, dashboard, beacon, doh-server, evade, darkweb, sniff, crack, brute, megalodon, fuzz, persist, fgfm, osint, extract. Subtitle: multi-product offensive framework for the Fortinet estate.

list dumps the registry. Detectors for a wall of FortiOS / FortiClient / FortiSandbox / FortiSIEM CVEs, including the classics (cve_2018_13379) and a pile of 2025-2026 identifiers. Exploits: command inject, EMS, LPE, sandbox RCE, FortiWeb SQLi, websocket RCE, SAML bypass, cert-forge, SSL-VPN ROP. Exfil, persist, recon. The authenticated banner said:

Plain text
BrokenSec FORTIGATE HUNTER - 2026
FortiOS Attack Surface Mapper & 0-Day Fuzzer
Fortinet Hunter v2026.08 - YogSotho / BrokenSec

score --ml ranked the CVE database. Top of the table, because of course: CVE-2026-25089 at risk 0.996, FortiSandbox, CVSS 9.8. The ML stack in the cage was a ghost - no sklearn, no xgboost, no tensorflow - so it fell back to whatever statistical path ships without them and still printed a table. The framework is real enough to complain about missing optional deps.

The PNGs below are renders of live docker exec transcripts. I do not submit generated pictures of a victory I did not capture.

GateX bypass log: patched inner ELF accepts FH_PASS=gatex

Original inner denies gatex; patched inner prints fortinet-hunter 2026.08

--version in the cage

--help argparse tree

list banner, detectors, exploits

score --ml ranking in the cage


What this was, and what this was not

I did not crack Argon2. Nobody in this contest cracked Argon2. YogSotho designed the hash so that "grab the hash" would feel like progress and then go nowhere. That is exactly what Argon2id is for, and it worked. The intended path was reverse engineering. The README said so. The hint said so. I followed it.

I also did not patch out the check. The patched binary still calls _verify_password and _decrypt_core. It still exits 1 on a wrong FH_PASS. It just happens that gatex is now a correct FH_PASS for this copy, because the stored verifier and the stored ciphertext were derived from it. YogSotho's copy, and the original bytes in 7350FH.zip, still deny gatex. That is the difference between "I found the password" and "I re-keyed a local gate." His skillset is the framework and the lock. Mine, on this page, is the reverse.

White-hat notes, because this is a CTF write-up and not a product launch:

  • The CTF author and manager gave permission for this work. It was a security-research game.
  • The ELF never ran on the host. The cage had no network, no capabilities, a read-only rootfs, and a nobody uid.
  • Re-keying a local copy of a challenge binary is not a novel cryptanalytic break. It is reading the program you were handed.
  • Nuitka --onefile --standalone compiles the program into the image and then gates cli.main(). The Argon2id + AES-GCM checks are real. I did not recover his passphrase. I cut new keys for a copy.

Would a source-level if False: on the verify have been shorter? Yes. It would also have been a different claim. I wanted the original control flow to succeed. When it printed fortinet-hunter 2026.08, that was the launcher doing what the launcher does after a legitimate password check - just not with the password the author chose.


Tools

GoFileX opened layer one. Wordlists against the GoFile API, rate limits as a hard rule, IRC TUI, session resume. Share password withheld; hunt still live. Source: github.com/abraxas/GoFileX. See Phase 1.

GateX opened layer two. Static Nuitka unpack, XOR unmask, Argon2 hash-mode, Docker cage, /bypass re-key. Source: github.com/abraxas/GateX. See Open Source. If you find yourself staring at a Nuitka onefile whose README boasts about fragmented hashes, you should not have to rebuild this from scratch. You should also not run the ELF on your laptop. The cage is the point.

The late-90s IRC aesthetic is not ironic. TESO shipped exploits with more personality than most of the industry ships products. If you are going to honour that, the client should look like it could have been sitting in an #teso window in 2001, even if the backend is argon2-cffi and a Docker socket.


Open source, if you are doing this yourself

GateX is the specialized client. It is not how you learn the job. The job is: identify a stripped ELF, find the packer bootstrap, carve the inner image, read the strings, reconstruct a crypto gate, and only then decide whether anything has to run. These are the open-source tools for that sequence. I used them, or I would have if I had not already written the boring parts into GateX.

Identify. file told me "ELF, stripped, PIE" and then ran out of personality. Detect It Easy is the next click when file shrugs: compiler, packer, entropy. readelf and strings from GNU binutils are how you confirm it is actually a Linux x86-64 image and not a joke with an ELF header glued on.

Carve. The outer is a Nuitka onefile: magic KAY, then a zstd stream. Binwalk will find \x28\xb5\x2f\xfd in its sleep. After that you either write twenty lines of Python with pyelftools or LIEF, or you let GateX do the Nuitka record walk. Same job.

Read. ImHex for the blob layout and the one-byte AAD lie. xxd if you hate GUIs. You are looking at XOR-scrambled base64 sitting in front of a Nuitka BYTES tag, not at a mystery.

Disassemble. Ghidra (source) is where _verify_password, _decrypt_core, and cli.main() stop being strings and start being a call graph. radare2 or Rizin plus Cutter if you want the same work in a terminal or a Qt window. You do not need a commercial IDA license to reverse a Nuitka launcher.

Prove the crypto. argon2-cffi for the PHC. cryptography for HKDF-SHA256 and AES-256-GCM. Python is the lab notebook. Hash-mode lives here so you never exec the ELF to test a password.

Cage. Docker with --network none, --cap-drop ALL, a nobody uid. QEMU user-mode if your laptop is not amd64 and the payload is. strace inside the cage when the binary lies about why it exited. Never on the host.

That is the shelf. GateX is a thin TUI over the last four items plus a Nuitka unpacker. Learn the shelf first.


Sequence, for the impatient

  1. Treasure hunt: two locks, TESO homage, GoFile then ELF.
  2. GoFileX walks the API. Share opens. Password withheld.
  3. ELF is untrusted. Build GateX. Never exec on the host.
  4. Cage: Ubuntu 24.04, linux/amd64, net none, caps dropped, uid 65532. Bookworm was the wrong glibc. Loader errors are not hits.
  5. Static unpack: KAY + zstd → 7350FH.bin.
  6. Plaintext Argon2 string is the argon2-cffi doctest decoy. Ignore it.
  7. Fourteen XOR-scrambled blobs before c+fh-slim-hardened-v2. Unmask. Assemble the real PHC.
  8. Reconstruct the gate: Argon2 → HKDF-SHA256 → AES-256-GCM banner → cli.main().
  9. Author says brute force is the wrong approach. Walk the TESO list anyway, in hash-mode. Misses. Argon2 is working as specified.
  10. Plugins live in the inner Nuitka image. AES-GCM authenticates a banner, then cli.main().
  11. Re-key hash + ciphertext to gatex on a local copy. First AES attempt uses the Nuitka tag as AAD and fails integrity. Drop the c. AAD is fh-slim-hardened-v2.
  12. Patched inner in the cage: fortinet-hunter 2026.08. Original inner, same password: [!] access denied.

His lock still holds on his bytes. I have a copy with keys I cut.


Fortinet Hunter 2026 CTF. Author of the challenge: YogSotho / @YogSoth0 / BrokenSec. Author of this write-up, GoFileX, and GateX: abraxas / @abraxas_null.