Stack overflow with a 60-byte post-EIP budget
acmeprintsvc.exe is a 32-bit Windows Server 2008 R2 service you are assessing. A command handler memcpy's the data portion of a framed request into a 256-byte stack buffer using the 16-bit length from the header. Framing is CR/LF terminated, so any request containing 0x0A or 0x0D is discarded by the transport before it reaches the parser; every other byte, including 0x00, survives intact. The transport also caps a request at 328 payload bytes, and the saved return address sits at offset 268 from the start of the buffer. DEP is enforced process-wide, and every system DLL is ASLR'd, but acmeprintsvc.exe is linked /DYNAMICBASE:NO (image base 0x00400000) and so is the third-party acmeplug.dll (base 0x6AD00000). Each accepted request line is also appended verbatim to a 64 KB global log buffer at 0x0046A000 in the main image, and nothing clears that buffer between requests or between sessions. You have a debugger attached and can reliably land EIP on any dword you choose.
1. DEP is on and you have only 60 bytes after the saved return address. Lay out what those 60 bytes must contain and justify the design instead of simply jumping to the buffer.
Read the worked answer
Sixty bytes is 15 dwords, and a useful reverse-shell payload with an encoder is several hundred bytes, so no amount of cleverness makes the payload fit. DEP also means the stack is not executable, so the only thing those 15 dwords can do is buy code execution elsewhere.
The elsewhere is 0x0046A000. The log buffer is in a non-ASLR image, it accumulates every accepted line, and it is not cleared, so an earlier, entirely benign request can deposit an encoded payload at a known offset there. That gives a fixed, writable target with no address guessing.
Budget the chain:
- 4 dwords of
push imm32gadgets for VirtualProtect's arguments (lpAddress 0x0046A000, dwSize 0x1000, flNewProtect 0x40, lpflOldProtect pointing at a scratch dword in .data). - 1 dword for a
call dword ptr [0x0040A0C4]gadget, where the slot is the exe's IAT entry for VirtualProtect. - 4 dwords of padding, because VirtualProtect is stdcall and returns with
ret 16; those dwords absorb the pops that follow the call gadget's return address. - 1 dword holding the address of the payload inside the now-executable page.
That is 10 dwords with 5 spare, which is exactly the kind of headroom you want, because the first chain you write never survives contact with the target. The payload staged in the log buffer must itself be encoded, since the CR/LF filter will reject any request carrying a 0x0A or 0x0D.
2. VirtualProtect lives in kernel32, which is randomized. Explain how the chain reaches it without knowing the kernel32 base, and what you must verify about the mechanism you pick.
Read the worked answer
The mistake is to treat the IAT entry as a pointer to a string or to a thunk. In a normally linked binary the loader writes the resolved VirtualProtect VA into the slot at load time, so the dword at 0x0040A0C4 already holds the randomized kernel32 address. Because acmeprintsvc.exe is not rebased, the address of the slot is fixed, and call dword ptr [0x0040A0C4] is a one-dword way to reach a randomized function. The alternative, push dword ptr [0x0040A0C4]; ret, costs two dwords and works the same way but is more fragile about stack layout.
Things to verify before committing:
- The slot address and the gadget address must contain neither 0x0A nor 0x0D in any byte. 0x0040A0C4 is fine; a slot at 0x00400A0C would not be, and the whole request would be silently discarded by the framing layer rather than producing a crash, which is a miserable debugging experience.
- Confirm the import is not delay-loaded. A delay-load slot contains a stub into the delay-load helper, not the real function. Calling it often still works, but it resolves the DLL by name first and can fail under a reduced token; a plain static import never has that problem.
- Do not fall back to walking kernel32's export table. That requires the kernel32 base, which is precisely the unknown you are trying to avoid, and it costs far more than 60 bytes anyway.
3. Your first attempt crashes inside the call gadget with a wild read rather than at the payload. What do you examine first, in order?
Read the worked answer
Because a bad byte would have caused the request to be rejected outright rather than to crash, the request reached the parser, so the framing constraint is satisfied. That narrows the fault to chain mechanics.
First, calling convention. VirtualProtect is stdcall and cleans 16 bytes with ret 16. When the call is made through a call [slot] gadget, the return address pushed by the call points at the dword immediately following the gadget in the chain, while the four arguments live below it. After ret 16 the stack pointer moves 16 bytes past the return address, so the next gadget must be preceded by four throwaway dwords. If the crash happens on the first instruction of the next gadget, this is almost always the cause.
Second, argument order and page alignment. lpAddress must be 0x0046A000 (page-aligned), dwSize 0x1000, flNewProtect 0x40, and lpflOldProtect a writable address. Pointing lpflOldProtect at a guessed stack address produces a write to an unmapped page and a fault that looks nothing like a protection failure.
Third, the call target itself. Set a breakpoint on the IAT slot and confirm the dword there is a valid kernel32 address. A value of 0x41414141 or a pointer into a mapped but non-code region means the slot you chose is not the one you thought.
Only after those three do you suspect the gadget offsets themselves.