# Two Bytes is Plenty: FortiGate RCE with CVE-2024-21762

Out-of-bounds 0x0a0d write in FortiGate sslvpnd chunked-transfer trailer parsing lets a pre-auth attacker corrupt a saved heap pointer and call system() for RCE.

- Payout: Research
- Severity: critical
- Program: FortiGate
- Credited: Assetnote
- Disclosed: 2025-02-12
- CVE: CVE-2024-21762
- Write-up quality: 9/10
- Original: https://www.assetnote.io/resources/research/two-bytes-is-plenty-fortigate-rce-with-cve-2024-21762
- Library page: https://bugbountylibrary.com/r/blog%3Aassetnote-io-resources-research-two-bytes-is-plenty-fortigate-rce-with-cve-2024-21762
- Tags: domain:network impact:rce kind:research platform:research sev:critical tech:linux technique:incomplete-fix technique:reverse-engineering technique:source-review vuln:dos vuln:memory-corruption vuln:rce

**Target:** FortiGate SSL VPN sslvpnd HTTP chunked-transfer parser
**Root cause:** When processing chunked trailers, the write offset field654_0x2d8 is incremented by two and used to advance the buffer pointer but is never checked against remaining buffer size, so 0x0a0d is written past the stack buffer. Padding the zero chunk length with leading zeros inflates amount_read, seeding the offset high.
**Steps:**
1. Extract /bin/init from vmdk rootfs.gz, decompress bin.tar.xz via chroot /sbin/xz and /sbin/ftar
2. Diff 7.2.5 vs 7.2.7 init in Ghidra; find FUN_01701ee0 chunked parser gained a 0x400 trailer-length check and a 0x11 chunk-length-string check
3. Enable `diagnose debug application sslvpn -1`; find default_handler calls parser with param_2 != 1 (no endpoint needed)
4. Send `POST / HTTP/1.1` with `Transfer-Encoding: chunked`, body `0\r\n` + `A: X\r\n`*89 to crash
5. Backdoor /bin/init by patching do_halt to `RET`, repack rootfs.gz padded with 256 zero bytes, bypass fgt_verify_initrd via GDB `set $rax=0`
6. Refine payload: `0`*4133 + `\0` + `A\r\n\r\n` to seed field654_0x2d8=4136 and write 0x0a0d at chosen stack offset
7. Overwrite low 2 bytes of saved r13 (heap pointer) to point into a controlled heap allocation
8. Heap groom via form params on /remote/hostcheck_validate sized ~1840 bytes to place 0x00433748 at rdi+0x298
9. Chain: r13->[+0x70]=0x042C5730 -> [+0x40]=0x00440ee0 (system) -> call rax
**How found:** Historical memory-corruption bugs in HTTP parsing prompted diffing the chunked-transfer parser between 7.2.5 and 7.2.7; new length checks revealed the vulnerable trailer loop. Debug logging showed 'chunked Transfer-Encoding forbidden' only when param_2==1, so they traced call sites where param_2!=1 and found default_handler (no endpoint). Fuzzing the two patched constraints produced a crash with 89 trailers; GDB triage showed 0x0a0d clobbering a saved r13, and reading the hex decoder revealed leading-zero skipping to inflate amount_read.
**Exploitation:** Pre-auth attacker sends a crafted chunked HTTP request to sslvpnd. Zero-padded chunk length sets amount_read high; trailer processing writes 0x0a0d past the stack buffer, clobbering the low two bytes of a saved r13 heap pointer. Heap grooming via form parameters places a fake structure so that FUN_01828e10 dereferences rdi+0x298 -> r13+0x70 -> r15+0x40 to resolve the PLT entry for system, then calls it with attacker-controlled rdi.
**Escalation:** Two-byte stack write chained through heap pointer corruption and multi-level pointer dereference to redirect an indirect call to system(), converting a limited OOB write into pre-auth RCE.
**Insight:** When a patch adds a length cap, look for a sibling field that is incremented but never bounds-checked; and check whether a length parser (hex_decode) skips leading zeros so you can inflate a 'read amount' while keeping the decoded value zero. Also: a 2-byte write of a fixed value can still yield RCE if it lands on the low bytes of a saved heap pointer that is later used as a struct base for an indirect call.
