Tool Reference¶
All tools take an absolute path to a PE file on the machine (or container) where the server runs, and return one text content item holding JSON. The path can also point inside a file, at its overlay or a resource, and every tool works on that layer in memory: see Look inside a file.
Failures (file missing, not a PE, bad argument, a crafted file that exhausts memory) come back as tool results with isError: true and a readable message, so the assistant can recover and the session keeps running. Only malformed requests are JSON-RPC errors.
| Tool | Use it for | Typical size |
|---|---|---|
triage_pe | Start here: what the file's structure shows, as plain facts | 1–2k tokens |
analyze_pe | Any section of the full analysis, capped and pageable | ≤ 10k tokens by default |
get_hashes | Hashes and entropy | ~150 tokens |
list_imports / list_exports | Imports, exports | ≤ 10k tokens by default |
check_signature | Authenticode details | < 1k tokens |
get_strings | Filtered, paged strings | ≤ 10k tokens by default |
read_bytes | Raw bytes by file offset or RVA | ≤ 4096 bytes per call |
decode_bytes | The ASCII/UTF-16 strings in a range, in file order; undo XOR, base64, RC4, zlib, LZNT1, … on a range or a text; find the key that reveals a known text | text up to max_text, 256 bytes of hex, 100 strings per page |
hash_range | MD5/SHA/CRC32/entropy/ssdeep/TLSH of any range, or of every section | < 3k tokens |
disassemble | x86/x64 instructions at the entry point, a TLS callback, an export or an address | ≤ 500 instructions per call |
get_xrefs | Where an import, address or string is used in the code, with lead-in | ≤ 10k tokens by default |
list_functions | Function starts found by the code scan | pageable |
get_callers / get_callees | Who calls a function, or what it calls (with the imports), as a tree | ≤ 300 functions |
get_resources | Dialogs, version info and string tables, decoded, IDs linked to code | < 5k tokens |
list_types | A .NET assembly's types and methods, filterable by name | ≤ 100 types per call |
search_bytes | Find hex patterns (with wildcards) or up to 16 texts at once, with the surroundings as text | ≤ 500 matches per call |
extract_payload | Hash and describe the overlay, a resource or a section; carve it in write mode | < 1k tokens |
list_container | What an archive or installer holds: ZIP, CAB, PyInstaller, MSI, RAR | ≤ 200 entries per call |
get_iocs | URLs, domains, IPs, registry keys, paths, pipes, PDB paths | ≤ 10k tokens by default |
check_similarity | Local "seen before?" lookup | < 1k tokens |
patch_pe | Write a patched copy (opt-in) | small |
Look inside a file¶
Droppers carry their payload in the overlay or a resource. Don't extract it: add # steps to the path and analyse the layer where it is, in memory. Nothing is written to disk, so no AV quarantines it, and no write mode is needed.
| Path | Layer |
|---|---|
C:\Samples\drop.exe#overlay | The data after the last section |
drop.exe#resource:RT_RCDATA/101 | A resource: TYPE/NAME, optionally /LANGUAGE. RT_RCDATA, RCDATA and 10 all work |
app.jar#entry:META-INF/MANIFEST.MF | A ZIP entry (JAR, APK, …) or a PyInstaller entry, by path or #entry:#3 by position, stored or deflated (zlib for PyInstaller), read whole. list_container gives each entry's path. Refused when encrypted, over 256 MB, or when its data does not inflate to exactly the size it declares |
drop.exe#netres:Payload.bin | A .NET managed resource, by name or #netres:#2 by position: exactly its bytes, at the length its 4-byte prefix gives |
drop.exe#netres:App.Resources.resources#entry:Payload | One resource of a .NET resource set (.resources): a string, byte[], stream or serialized bitmap a loader hides its payload in |
drop.exe#section:.rsrc | A section's raw data, or #section:#3 by position |
drop.exe#offset:0x5000+0x2000 | A byte range, or #offset:0x5000 to the end |
drop.exe#overlay#resource:CABINET/1 | Steps nest, left to right |
Every tool accepts these paths: triage_pe, disassemble, get_iocs, get_strings, list_container, all of them.
Real example: WannaCry's launcher.dll (9487edf9b75f4c15e3ba6ccbae23588ee3dc9c4983417f1b469278af17fc3847.exe). Triage points at the payload:
"resources": { "notable": [
{ "resource": "W/101", "size": 5242880, "entropy": 4.16, "detectedAs": "PE File (at offset +0x4)" } ] }
The PE starts 4 bytes in, behind a length field. read_bytes on the layer shows it:
{ "name": "read_bytes", "arguments": { "path": "/samples/9487edf9….exe#resource:W/101", "offset": 0, "length": 16 } }
{ "section": "not a PE: raw bytes", "hexdump": "00000000: 00 D0 22 00 4D 5A 90 00 03 00 00 00 04 00 00 00 ..\".MZ.........." }
So triage the PE inside the resource, skipping those 4 bytes:
{ "size": 5242876,
"identity": { "kind": "EXE", "architecture": "x86", "timestamp": { "utc": "2010-11-20T09:03:08Z" } },
"resources": { "notable": [ { "resource": "R/1831", "size": 2061938, "entropy": 7.82, "detectedAs": "PE File" } ] },
"runtimeAnalysis": [ { "runtime": "Code", "facts": {
"Network": "InternetOpenA (1), InternetOpenUrlA (1)",
"Registry and services": "OpenSCManagerA (2), CreateServiceA (1), StartServiceA (1)" } } ] }
That matches WannaCry's worm component (mssecsvc.exe): it opens a URL (the kill switch) and installs a service, and it carries another PE in R/1831. Keep going with …#resource:W/101#offset:4#resource:R/1831.
A step that does not fit says what there is instead:
Where to point
triage_pe tells you: resources.notable lists embedded PEs and high-entropy blobs by name, overlay.detectedAs says what the appended data is (ZIP, CAB, PyInstaller, NSIS, an embedded PE, …). For archives and installers, call list_container.
Response size and paging¶
Assistants can only use a tool result that fits their context, and clients cap it (Claude Code, for example, warns above 10,000 tokens). PPEE therefore keeps every JSON result within a character budget and tells the assistant exactly what it left out.
| Argument | Default | Meaning |
|---|---|---|
max_chars | 40000 (~10k tokens) | Budget for the whole response, 4,000 to 1,000,000 |
limit | 100 | Most items kept per list. If the result is still too big, lists are halved until it fits |
select | A dotted path to one list or value, returned on its own instead of the whole document | |
offset | 0 | With select: the first item to return |
When lists are cut, the response carries a _truncated array and a _hint:
"_truncated": [ { "path": "exception.entries", "total": 12411, "shown": 100 } ],
"_hint": "Lists were cut to 100 items each to keep this response under max_chars=40000. Page through a list with select=<path> …"
Paging that list:
{ "name": "analyze_pe", "arguments": { "path": "C:\\Samples\\explorer.exe", "sections": ["exception"],
"select": "exception.entries", "offset": 100, "limit": 3 } }
{ "path": "…/explorer.exe", "select": "exception.entries", "total": 12411, "offset": 100, "returned": 3, "nextOffset": 103,
"items": [ { "beginAddress": "6D2C", "endAddress": "6DFC", … }, … ] }
selectpaths use the JSON keys and list indexes:imports.12.functions,resources.types.0.names,analysis.runtimes.0.views.1.table.rows. Keys that themselves contain dots work too:headers.OptionalHeader.DllCharacteristics.- A wrong path returns an error listing the keys available at that point.
- Strings longer than 1,000 bytes (400 in
get_strings) are shortened, ending with…[+N bytes]. - To get more in one call, raise both, for example
"limit": 5000, "max_chars": 1000000. Only do this if your client accepts large results.
Typical result sizes, default settings
| Call | Uncapped | Capped (default) |
|---|---|---|
analyze_pe with no sections, 6 MB explorer.exe | ~640k tokens | ~7k |
analyze_pe strings, same file | ~5.8M tokens | ~8.5k |
analyze_pe with no sections, 13 MB Go DLL | ~1.6M tokens | ~6k |
list_imports, 14 MB Rust exe | ~48k tokens | ~9k |
triage_pe¶
Start here. A compact summary of one file: the facts that stand out in its structure, then the details behind them. PPEE says what it sees; you draw the conclusion. There is no severity and no verdict.
| Argument | Required | Description |
|---|---|---|
path | ✔ | File to triage, or a layer inside one |
| Part | Contents |
|---|---|
findings | The facts, each with a stable id (for scripts), an area and a sentence. Facts that one explanation covers are grouped under it (accountsFor, see below) |
findingCounts | How many facts per area |
identity | Kind (EXE, DLL, driver, .NET), architecture, bitness, subsystem, image base, timestamp (a note when it is a deterministic-build hash, not a date), and textStrings: how many readable strings the file has outside its code, headers and import/export names |
hashes | MD5, SHA-1, SHA-256, ImpHash, Authentihash, SSDEEP, TLSH, CRC32, entropy |
sections | Name, addresses, sizes, RWX flags and entropy of each |
entryPoint | rva, the section it falls in, and instructions: its first instructions (x86/x64), up to the first jmp or ret |
hardening | ASLR, high-entropy VA, DEP, CFG (enabled, or requested without a target table), /GS cookie, SafeSEH, relocations stripped, … |
imports | Counts, largest modules (one row per DLL; descriptors when the file imports it through several import descriptors, as Delphi does), capability hints (process injection, memory execution, run-time API resolution, network, crypto, credential access, persistence, anti-debugging, privileges, process execution, input capture) and importNotes on import combinations. The hints also count APIs the file reaches without importing them, marked: (P/Invoke), (GetProcAddress), (named in strings). See Capability hints |
overlay | Appended data: size, entropy and detectedAs: what it is, from its own bytes (PyInstaller, ZIP, 7-Zip, RAR, CAB, NSIS, MSI, zlib, a PKCS#7 signature outside the certificate table, the COFF symbol table, an embedded PE) |
resources | Counts by type, and notable: embedded PEs (also behind a short prefix: PE File (at offset +0x4)) and large high-entropy blobs |
exports, signature, tls, debug, other | Present when relevant: signer and whether the digest still matches the file, TLS callbacks, PDB path, manifest execution level |
runtimeAnalysis | For .NET, Go, Rust, NativeAOT and PyInstaller: the key facts from runtime analysis. For x86/x64, the Code runtime: entry-point checks, APIs in use, decoded coverage |
Real output for a UPX-packed ransomware dropper (77549422a5306f905d68153b1f649745d330d45e564c927046132ecc1d20ae3e.exe, hashes left out):
{ "findingCounts": { "debug": 1, "hardening": 3, "layout": 1, "signature": 1 },
"findings": [
{ "id": "aslr_off", "area": "hardening", "fact": "ASLR (DYNAMIC_BASE) is off: the image always loads at its preferred base" },
{ "id": "dep_off", "area": "hardening", "fact": "DEP (NX_COMPAT) is off" },
{ "id": "relocs_stripped", "area": "hardening", "fact": "relocations are stripped, so ASLR is impossible" },
{ "id": "unsigned", "area": "signature", "fact": "the file is not Authenticode-signed" },
{ "id": "no_debug_directory", "area": "debug", "fact": "no debug directory" },
{ "id": "entry_section_profile", "area": "layout",
"fact": "the entry point is in section #2 'UPX1', which is writable and executable and has entropy 7.61; sections like it: 'UPX0', 'UPX1'",
"accountsFor": [
{ "id": "section_wx", "area": "sections", "fact": "2 sections are writable and executable: #1 'UPX0', #2 'UPX1'" },
{ "id": "section_exec_entropy", "area": "sections", "fact": "1 executable section has entropy above 7.2: #2 'UPX1'" },
{ "id": "code_note", "area": "code", "fact": "The entry point is in UPX1, not in the first code section, UPX0." },
{ "id": "code_note", "area": "code", "fact": "The entry point starts with pushad (saves every general register on the stack).",
"evidence": [ " 40A360 pushad", " 40A361 mov esi, 0x409015", " 40A366 lea edi, dword ptr [esi-0x8015]",
" 40A36C push edi", " 40A36D jmp 0x40A37A" ] } ] } ] }
Read it top-down. The loose facts come first; the entry-section profile gathers everything that belongs to one picture: the code starts in a writable, executable, near-random section, the shape of a stub that unpacks the rest at run time. Your next step: disassemble from the entry point to the popad / jmp that ends the stub (here jmp 0x4021D1, the original entry point).
How facts are grouped¶
Group (id) | When | What it gathers |
|---|---|---|
entry_section_profile | The entry point is in a section that is writable and executable, has no data in the file, or holds near-random code | The sections like it and their facts: W+X, entropy, empty sections, blank or unprintable section names, the entry point outside the first code section, unreadable unwind data |
known_layout | Section names an ordinary toolchain writes: MSVC incremental-link Debug (.textbss), Delphi/C++Builder (.itext), a driver's discardable INIT | The facts that layout always produces, so a Debug build doesn't read like a packer |
The profile describes structure, not a product: it works the same for UPX, Enigma or a protector nobody has a signature for. To name a packer or protector, use a signature tool such as Detect It Easy or YARA.
Fact ids¶
Scripts can filter on id. A few you will meet often:
id | Fact |
|---|---|
section_wx, section_exec_entropy, section_exec_virtual_only, section_data_entropy | Writable + executable sections, near-random code, code sections filled only at run time, large near-random data |
embedded_zip | ZIP archives of 64 KB and more carried inside the sections (not appended), with where and how large; list_container lists them |
section_compressed_debug | Debug sections holding zlib-compressed DWARF (.zdebug_* with a ZLIB header, as Go builds write them): their high entropy is explained, so they are not counted in section_data_entropy |
section_names_blank, section_names_unprintable | Section names that are empty, or bytes that are not printable text |
entry_last_section, entry_not_executable, entry_outside_sections, entry_in_headers | Where the entry point is |
overlay, overlay_high_entropy, overlay_identified, overlay_embedded_pe | Appended data, by what it turned out to be |
resource_embedded_pe, resource_high_entropy | Payload-looking resources |
extension_differs | The file name says .exe, the headers say DLL (and so on). For a layer, the entry's name (#entry:app.dll) is compared; an overlay, resource or range has no name, so nothing is compared |
export_name_differs | The export directory names a different module than the file name (for a layer, the entry's name, as above) |
signature_digest_mismatch, signer_self_signed, unsigned | Authenticode |
timestamp_future, pdb_user_profile, tls_callbacks | Build traces |
few_text_strings | Under 10 readable strings: the text is encrypted, packed or built at run time |
managed_resource_high_entropy | .NET resources of 64 KB or more with entropy above 7.2: where .NET droppers hide their payload |
Capability hints¶
Imports only tell part of the story. Droppers and .NET stealers reach the Windows API without importing it, and the hints count those calls too, each marked with how it is reached:
| Mark | Source |
|---|---|
| (none) | The import table |
(P/Invoke) | A .NET DllImport declaration |
(GetProcAddress) | A name the code passes to GetProcAddress |
(named in strings) | An API name that is only in the file's text: Go's NewProc("…"), a loader's list of names |
Gomorrah (2f8a79b12a7a989ac7e5f6ec65050036588a92e65aeb6841e08dc228ff0e21b4), a .NET stealer that imports one function:
"imports": { "functions": 1, "pinvokeFunctions": 13, "capabilityHints": {
"input_capture": { "apis": [ "GetForegroundWindow (P/Invoke)", "GetAsyncKeyState (P/Invoke)" ] },
"crypto": { "apis": [ "BCryptOpenAlgorithmProvider (P/Invoke)", "BCryptEncrypt (P/Invoke)", "BCryptDecrypt (P/Invoke)" ] },
"dynamic_api_resolution": { "apis": [ "GetProcAddress (P/Invoke)", "LoadLibrary (P/Invoke)" ] } } }
A keylogger's pair of APIs and Chrome's AES-GCM decryption, none of it in the import table.
RemusStealer (847d8f4998d22fde37eb76f99b6d91012c42965b741fe2cec453ca5876cdf147), Go, 46 kernel32 imports:
"privilege_manipulation": { "apis": [ "LogonUser (named in strings)", "DuplicateTokenEx (named in strings)",
"ImpersonateLoggedOnUser (named in strings)" ] },
"network": { "apis": [ "WSAStartup (named in strings)", "WSASocketW (named in strings)", "ConnectEx (named in strings)", … ] }
Go names APIs too
Go's own standard library declares some network and token functions the same way, so most Go binaries show a few (named in strings) entries. Read them together with what the program's own code does.
Little readable text¶
identity.textStrings counts the readable strings outside code, headers and import names. When there are fewer than 10, triage says so. The 12 KB tool 106710acf6eb16b36c504fe0bdbdd9f065d22d90649d8327040561c174daee77 decodes every string at run time:
"identity": { "kind": "EXE", "architecture": "x64 (AMD64)", "textStrings": 1, … },
"findings": [ { "id": "few_text_strings", "area": "strings",
"fact": "only 1 text string(s) of 6+ characters (ASCII or UTF-16, mostly letters, outside code and headers) besides import, export and section names" }, … ]
Ask get_strings for group: "code" next: the strings that code builds.
Facts, not verdicts
A fact says what the structure is, not whether the file is malicious. Protected crackmes, installers and licensing DLLs have the same structure as packed malware; Microsoft's own ClipUp.exe has encrypted code. PPEE reports the structure and leaves the judgement to you. On clean Windows system and SDK files, none of the facts packed files produce (W+X sections, the entry point in the last section, near-random code) appears without an explanation.
analyze_pe¶
Returns chosen sections of the full analysis (the document ppee-cli --json prints), within the size budget.
| Argument | Type | Description |
|---|---|---|
path | string | File to analyze |
sections | string[] | Any of headers, dirs, sections, hashes, similarity, imports, exports, basereloc, tls, debug, bound-imports, delay-imports, resources, exception, security, loadconfig, net, richheader, appmanifest, analysis, analysis-deep, strings |
limit, max_chars, select, offset | Size and paging |
Section names match the CLI switches without the leading --. Default: everything except strings, similarity and analysis-deep. Asking for the sections you need gives more room to each; use triage_pe first to decide which.
{ "name": "analyze_pe", "arguments": { "path": "C:\\Samples\\app.exe", "sections": ["headers", "sections", "loadconfig"] } }
get_hashes¶
Returns fileInfo: CRC32, MD5, SHA-1, SHA-256, ImpHash, Authentihash, SSDEEP, TLSH and entropy. CLI equivalent: --hashes.
list_imports¶
Returns imports, delayImports and boundImports. Capped and pageable, for example "select": "imports.12.functions" for one DLL's functions.
list_exports¶
Returns exports with ordinals, RVAs and forwarders. Page with "select": "exports.functions".
check_signature¶
Returns security (certificate table, signatures, signer and timestamp certificates, validity) plus fileInfo. Compare embeddedDigest with authentihash: a difference means the file changed after signing. Validity (WinVerifyTrust) is available when the server runs on Windows.
get_strings¶
The string scan, filtered and paged per group.
| Argument | Default | Description |
|---|---|---|
path | File | |
group | all | ascii, unicode, url, registry, suspicious, code or all |
min_length | 5 | Drop ASCII/Unicode strings shorter than this |
contains | Keep only strings containing this text (case-insensitive) | |
limit | 100 | Strings returned per group |
offset | 0 | Strings to skip in each group |
max_chars | 40000 | Response budget |
The response gives matches (per group, after filtering) and nextOffset when more remain. For x86/x64 files each string the code refers to carries codeRefs (the number of instructions pointing at it); no codeRefs means none were found. A string the code uses matters more than one that just sits in the file. The name passed to GetProcAddress gets it; the same text in the import-name table does not.
{ "strings": { "ascii": [
{ "offset": "117310", "codeRefs": 3, "sectionName": ".rdata [R] (#2 section)", "text": "PK11SDR_Decrypt" },
{ "offset": "117320", "codeRefs": 3, "sectionName": ".rdata [R] (#2 section)", "text": "PK11_Authenticate" } ] },
"matches": { "ascii": 4 }, "nextOffset": 3 }
STEALERDLL.dll with group: "ascii", contains: "PK11": Firefox NSS decryption functions, each used three times by code.
Strings the code builds (group: "code")¶
Malware hides its strings from a strings scan by building them in code: storing them byte by byte on the stack, writing them into memory in 8-byte pieces, or comparing a process name against an inline constant. None of that text is in the file as a string. The code group reads it back from the instructions (x86/x64), each with its va and how it was built.
{ "strings": { "code": [
{ "va": "0x1400254C0", "built": "compared or pushed", "text": "xmrig.ex" },
{ "va": "0x140025543", "built": "compared or pushed", "text": "svchost_cpu.exe" },
{ "va": "0x140033F05", "built": "in memory", "text": "Telegram Stealer thread started." },
{ "va": "0x140035895", "built": "in memory", "text": "Wifi Stealer thread started." },
{ "va": "0x140032BD2", "built": "on the stack", "text": "Unknown-PC" },
{ "va": "0x140052E72", "built": "compared or pushed", "text": "set_wallpaper" },
{ "va": "0x140053AA2", "built": "compared or pushed", "text": "deploy_miner" },
{ "va": "0x140053722", "built": "compared or pushed", "text": "proxy_connect " },
{ "va": "0x140056DEB", "built": "compared or pushed", "text": "dwm_update.exe" }, … ] },
"matches": { "code": 86 } }
A stealer with a miner (0b909c97aba0af87516cf00b6c1f801f3086f1e306e39cee002c789b214e4e98): its stealer threads, its C2 commands, the miners it looks for (xmrig) and the names it hides them under (svchost_cpu.exe, dwm_update.exe). The ascii group had none of these. Pass a va to disassemble to see the code around it.
built | What the code does |
|---|---|
on the stack | Stores the text into a stack buffer, piece by piece |
in memory | Writes it into another buffer, in pieces that may overlap and come out of order: PPEE puts them back in order |
compared or pushed | Compares a buffer against the text (an inlined strcmp), or pushes it |
get_xrefs with to: "string:PK11SDR_Decrypt" shows where. |
To read a whole group, ask for it alone and follow nextOffset:
{ "name": "get_strings", "arguments": { "path": "/samples/x.exe", "group": "url", "contains": "http", "limit": 20, "offset": 20 } }
read_bytes¶
A bounded hex dump, or, with as, a table of pointers followed to their strings. Give either a file offset or an rva; RVAs are translated through the section table.
| Argument | Description |
|---|---|
path | File |
offset | File offset: a JSON number or a hex string such as "0x1F0" |
rva | Relative virtual address, same forms |
length | Bytes to read, default 256, at most 4096 |
as | hex (default), go_strings, len_ptr or pointers: read the bytes as a pointer table (below) |
count / max_chars_per_entry | With as: entries to read (default 64, max 1000); characters kept of each string (default 1000) |
{ "name": "read_bytes", "arguments": { "path": "C:\\Samples\\explorer.exe", "rva": "0x1010", "length": 32 } }
{ "fileSize": 6089584, "fileOffset": "410", "rva": "1010", "section": ".text", "length": 32, "nextOffset": "430",
"hexdump": "00000410: 41 54 41 56 48 8D 6C 24 80 48 81 EC 80 01 00 00 ATAVH.l$.H......\n00000420: 48 8B 05 41 0C 43 00 48 33 C4 48 89 45 70 48 8B H..A.C.H3.H.EpH.\n",
"hex": "41544156488D6C24804881EC80010000488B05410C43004833C448894570488B" }
To read these bytes as instructions, use disassemble with target: "rva:0x1010" instead.
A table of string pointers: as¶
Malware keeps lists of strings as arrays of pointers: a Go []string is {ptr, len} headers whose text sits elsewhere, a C char*[] points to NUL-terminated strings. With as, read_bytes reads the bytes as such a table and follows every entry, so a list of hundreds of strings is one call:
as | Each entry |
|---|---|
go_strings | {ptr, len}: 16 bytes in a 64-bit file, 8 in a 32-bit one. Go string headers, a []string's backing array |
len_ptr | {len, ptr} |
pointers | A pointer to a NUL-terminated ASCII or UTF-16 string (encoding: "utf-16le") |
{ "name": "read_bytes", "arguments": { "path": "/samples/09088616….exe", "rva": "0x3B3BE0", "as": "go_strings", "count": 243 } }
{ "fileOffset": "3B2FE0", "rva": "3B3BE0", "section": ".rdata", "as": "go_strings", "entrySize": 16, "count": 243, "resolved": 243,
"entries": [ { "index": 0, "at": "0x3B2FE0", "ptr": "0x724FBF", "length": 14, "text": "JERRY-TRUJILLO" },
{ "index": 1, "at": "0x3B2FF0", "ptr": "0x721852", "length": 4, "text": "WORK" }, …,
{ "index": 242, "at": "0x3B3F00", "ptr": "0x7254F0", "length": 15, "text": "AMAZING-AVOCADO" } ] }
A Go info-stealer (a Trap Stealer derivative): the 243 analyst and sandbox host names main.checkUsername exits on. The table was found with get_xrefs to: "va:0x7B3BE0", whose one site is main.checkUsername+0x3E. An entry that is null or points outside the file's data has a note instead of text; nextOffset continues a longer table.
An RVA in memory-only data (such as .bss) or outside every section is refused with an explanation.
read_bytes also reads a layer that is not a PE, by offset: a resource blob, an archive in the overlay.
read_bytes gives hex only. For the text in a range (UTF-16 included), use decode_bytes without steps.
decode_bytes¶
The decoding you would otherwise write a script for. Without steps it shows a range as it is: its hex and every ASCII and UTF-16LE string in it, in file order (below). With steps it passes the range (or a text, such as a base64 string from get_strings) through them; the result comes back as text (ASCII/UTF-8 or UTF-16LE) when it is text, otherwise as a hex dump with the strings in it, plus what it looks like (PE image, zlib stream, …), its entropy and hashes. Nothing runs: PPEE only transforms the bytes.
| Argument | Default | Description |
|---|---|---|
at | - | off:X (file offset), rva:X, va:X, section:NAME (or section:#N), overlay, headers, file. A JSON number is a file offset |
length | whole section or overlay; 4096 bytes from an address | Bytes to read, up to 16 MB |
text | - | Decode this text instead of the file's bytes (then path is optional) |
steps | - | The steps, in order: "xor:5A, base64" or ["xor:5A", "base64"]. Leave out to view the range as it is |
find_key | - | A text the plain bytes should contain (http, MZ, This program): lists the keys that reveal it |
max_text / hex_bytes | 4000 / 256 | How much of the result to return |
strings_offset / strings_limit | 0 / 100 | Page the strings list (max 2000 per page); nextStringsOffset says where to continue |
min_length / max_string | 5 / 2000 | Shortest string listed (printable characters); characters kept of each string (max 32000) |
| Step | Does |
|---|---|
xor:KEY, add:KEY, sub:KEY | Byte-wise with a repeating key: hex bytes (5A, DEADBEEF) or quoted text ('secret') |
rol:N, ror:N | Rotate each byte N bits |
not, reverse | Invert each byte; reverse the order |
base64, base64:'ALPHABET' | Standard (and URL-safe) base64, or one with a custom 64-character alphabet |
hex | Hex text to bytes (4D 5A, 0x4d,0x5a, \x4d\x5a) |
rc4:KEY | RC4 with the key |
inflate, zlib, gzip | Raw deflate (.NET DeflateStream), zlib, gzip. Truncated data gives what came out, with a note |
lznt1 | RtlDecompressBuffer's LZNT1 |
skip:N, take:N | Drop the first N bytes; keep the first N |
The text in a range: no steps¶
Strings between two file offsets, the readable text of a UTF-16 blob, fragments that sit between length prefixes: get_strings pages the whole file, read_bytes gives hex. Without steps, decode_bytes lists every ASCII and UTF-16LE string in the range, in file order, each with its file offset (encoding: "utf-16le" on the UTF-16 ones). Tabs and line breaks stay inside a string, so a usage message comes back whole.
{ "name": "decode_bytes", "arguments": { "path": "/samples/tunnel.exe", "at": "off:0x43CE00", "length": "0x600" } }
{ "source": { "offset": "0x43CE00", "size": 1536, "in": ".rdata" }, "size": 1536, "entropy": 4.75,
"hex": "0043CE00 02 0C 0E 15 14 00 F2 1D …",
"strings": [ …,
{ "at": "0x43D123", "encoding": "utf-16le", "text": " Hardware Device: " },
{ "at": "0x43D150", "encoding": "utf-16le", "text": " Key Container Name: " }, …,
{ "at": "0x43D2F2", "encoding": "utf-16le",
"text": "-c -ti <ip|domain> -tp <port>\r\n\r\n-h | --help : show help\r\n-s | --server : the process is server\r\n…" } ],
"stringCount": 15 }
A NativeAOT SOCKS5-over-WebSocket tunnel: its help text, which get_strings would only find among thousands of framework strings. A wider range pages: 32 KB of the tool's own string cluster holds 711 strings, read 100 at a time with strings_offset.
Find the key: find_key¶
With a text the plain bytes should contain, decode_bytes tries every single-byte XOR, ADD and ROL key and repeating XOR keys of 2 to 16 bytes (a repeating key needs a known text at least twice its length). A repeating key is given as it lines up with the start of the range, so it works as a step on the same range.
{ "name": "decode_bytes", "arguments": { "path": "/samples/106710ac….exe", "at": "section:.rdata", "find_key": "https://" } }
{ "source": { "offset": "0x2200", "size": 3072, "in": ".rdata" },
"keys": [ { "step": "xor:B5", "at": "0x2270", "preview": "https://kidsko.striawork/telepuzhop/files/telemePanasonicInd\\Hel" } ] }
A 12 KB downloader (hybrid-analysis): no URL in its strings, because the text at 0x2270 is XORed with 0xB5. The address comes out in 16-byte pieces, with other text between them (PanasonicInd\Hel…). Decode the range:
{ "name": "decode_bytes", "arguments": { "path": "/samples/106710ac….exe", "at": "off:0x2270", "length": 64, "steps": "xor:B5" } }
{ "source": { "offset": "0x2270", "size": 64, "in": ".rdata" }, "steps": [ "xor:B5" ], "size": 64, "entropy": 4.383,
"encoding": "text", "text": "https://kidsko.striawork/telepuzhop/files/telemePanasonicInd\\Hel", … }
A string from get_strings: text¶
A ransomware sample (9600db53….exe) keeps a base64 string in .rdata. Give it as text:
{ "name": "decode_bytes", "arguments": { "text": "aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQyMjk5OTMz…", "steps": "base64" } }
{ "source": { "text": 164 }, "steps": [ "base64" ], "size": 121, "encoding": "text",
"text": "https://discord.com/api/webhooks/1422999331195191336/5dWTzBL9…" }
The data goes to a Discord webhook. A RustyStealer sample hides its list of analysis tools the same way: cGViZWFy… decodes to pebear, ghidra, ghidraRun, jd-gui, jadx-gui.
Steps chain: "base64, zlib" for a compressed and encoded config, "xor:'key', lznt1" for a payload that is encrypted and then compressed, "skip:4, inflate" for a .NET resource stored with a 4-byte length in front.
hash_range¶
The hashes of any range: MD5, SHA-1, SHA-256, CRC32, entropy, ssdeep and TLSH. Without at it hashes each part of the file: the headers, every section's data, the overlay and the whole file. Section hashes are what threat-intel lookups and comparisons between samples use; the hash of a carved payload identifies it without saving it.
| Argument | Default | Description |
|---|---|---|
at | every part | off:X, rva:X, va:X, section:NAME, overlay, headers, file |
length | to the end of its section, or the whole region | Bytes from at |
{ "name": "hash_range", "arguments": { "path": "/samples/[email protected]" } }
{ "ranges": [
{ "name": "headers", "offset": "0x0", "size": 1024, "md5": "23477438c417453c8f4ed98ac089b2be", … },
{ "name": ".text", "offset": "0x400", "size": 178688, "md5": "f10df8e94d500c375ae989cf5ed4b360",
"sha256": "eb41b7fea88ccad49e4214c487ea79bc89103fe4d100760cec3a5899b8ba72d4", "crc32": "59FA2EA5", "entropy": 6.586,
"ssdeep": "3072:+uNwer8AM7flLb2McXXlonBVdJBgmLPcLhc5+VAzmO4A6OaUpVgN8B:+swDdb2MemnBVlz0SoVbO4A6OA4",
"tlsh": "T156048D62B8418032C67260715A7EDB77A93CA932072922DBD7F88C351F741E2773679B" },
{ "name": ".rdata", "offset": "0x2BE00", "size": 124928, "md5": "5d2a2367c2b053f87b6b143e28a27e04", … },
…
{ "name": "file", "offset": "0x0", "size": 322560, "md5": "9f8bc96c96d43ecb69f883388d228754", … } ] }
[email protected], ransomware. Two samples whose .text hashes match share their code, whatever their resources and overlays hold. TLSH needs 50 bytes or more with enough variety, and is left out otherwise.
disassemble¶
x86/x64 instructions at a code location, in plain Intel syntax with VAs. Calls through the import table and references to exports, the entry point and TLS callbacks show by name, and so do the Control Flow Guard pointers (call qword ptr [__guard_dispatch_icall_fptr]); a referenced string is the instruction's comment (a string passed with its length, as Go passes every string, is shown at that length, see Go strings), and so is what CFG says about the address (CFG call target, XFG hash 0x…). CLI equivalent: --disasm.
| Argument | Default | Description |
|---|---|---|
target | ep | ep, func:NAME (a named function: func:main.main), tls / tls:N, export:Name, export:#N, va:X, rva:X, off:X (file offset; outside every section it is decoded raw), or a hex VA. A JSON number is a VA |
count | 40 | Instructions to decode (max 500) |
stop_at_flow_end | false | Stop after the first ret or unconditional jmp |
before | 0 | Also list this many instructions before the target (max 100): its lead-in, the argument setup or the compare before a branch. The target is marked anchor: true. Works for IL too |
Each instruction has va, rva, offset, bytes, text, and for branches flow and target: pass a target back to follow a call or jump. When the listing stopped at count, nextVa is where to continue.
Functions are named wherever the file says how: exports, and for Go every function from its pclntab (main.main, runtime.mapassign_faststr), and for GNU-built binaries (MinGW, GNU Rust) the COFF symbol table, demangled. func:NAME takes an exact name or its end (sendData for main.sendData); when several match, the error lists them. Real output, the end of the UPX stub of the sample above:
{ "name": "disassemble", "arguments": { "path": "/samples/77549422….exe", "target": "0x40A4D5", "count": 8, "stop_at_flow_end": true } }
{ "target": "0x40A4D5", "arch": "x86", "section": "UPX1", "executable": true, "stopReason": "jmp",
"instructions": [
{ "va": "0x40A4D5", "rva": "0xA4D5", "offset": "0x16D5", "bytes": "61", "text": "popad" },
{ "va": "0x40A4D6", …, "text": "lea eax, dword ptr [esp-0x80]" },
{ "va": "0x40A4DA", …, "text": "push 0x0" },
{ "va": "0x40A4DC", …, "text": "cmp esp, eax" },
{ "va": "0x40A4DE", …, "text": "jnz 0x40A4DA", "flow": "jcc", "target": "0x40A4DA" },
{ "va": "0x40A4E0", …, "text": "sub esp, 0xFFFFFF80" },
{ "va": "0x40A4E3", …, "text": "jmp 0x4021D1", "flow": "jmp", "target": "0x4021D1" } ] }
stop_at_flow_end stopped at the tail jump: 0x4021D1 is the original entry point.
Go strings at their length¶
Go keeps string literals back to back with no NUL between them, and passes each as a pointer and a length. Where the length follows the lea (in the next argument register, lea rax, [s] then mov ebx, 7, or stored next to the pointer), the comment shows exactly that string, not a run into the next literal:
0x6A8329 lea rax, qword ptr [0x7220D7] ; "APPDATA"
0x6A8330 mov ebx, 0x7
0x6A8335 call os.Getenv
0x6A836E lea rcx, qword ptr [0x72238C] ; "discord"
0x6A8389 lea rcx, qword ptr [0x724956] ; "Local Storage"
0x6A83A4 lea rdx, qword ptr [0x7223D2] ; "leveldb"
0x6A83CF call path/filepath.join
main.init of the same stealer: one disassemble (target: "func:main.init", count: 500) lists the seven Discord and browser Local Storage\leveldb paths it builds. Before, each comment read "APPDATAAppDataAvestanBengali…".
Rust &str and Go string headers. Rust keeps a string as a {ptr, len} header in .rdata and the code loads the header's address. A data reference to such a header (pointer-aligned, ptr leading to exactly len bytes of text) counts as a reference to the string: disassemble comments lea rax, [0x140138538] with "Installer-", get_xrefs string:Installer- finds its sites, and get_strings counts them in codeRefs.
.NET: IL¶
A .NET assembly's code is IL, not x86. Give target: "method:Type::Name" (or the method's token from list_types) and disassemble returns the method's IL, every operand resolved: strings, called methods with their assembly, fields, branch targets. Each ldstr also has stringOffset, the file offset of the string's text in the #US heap. For an IL-only assembly, ep is the managed entry point.
An rva:, va: or off: target inside a method's IL body (or its header) is decoded as IL too, from that instruction, with a note saying so: the managed code of a mixed-mode (C++/CLI) assembly, reached by address, would otherwise be read as x64 junk. Pass mode: "x64" (or "x86") to decode the bytes as native code anyway. before lists the IL leading up to the address, from the start of the method at most, and marks the anchor.
count goes up to 2000 for IL (500 for x86/x64). A longer body is read in pages: a listing that stops at count has more: true and nextTarget ("off:0x…"), the next instruction, to pass back as target. An obfuscator's 22 KB .cctor is 5 calls of 2000, with no gap or overlap.
{ "arch": "CIL", "method": "update_windows10.My.MyApplication::Main", "token": "0x06000003", "codeSize": 32,
"instructions": [
{ "il": "IL_0002", "opcode": "call", "token": "0x0A0000AE",
"operand": "[Microsoft.VisualBasic]Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase::get_UseCompatibleTextRendering" },
{ "il": "IL_0007", "opcode": "call", "operand": "[System.Windows.Forms]System.Windows.Forms.Application::SetCompatibleTextRenderingDefault" },
{ "il": "IL_000D", "opcode": "leave.s", "operand": "IL_0011" },
{ "il": "IL_0012", "opcode": "call", "operand": "update_windows10.My.MyProject::get_Application", "token": "0x06000009" },
{ "il": "IL_0018", "opcode": "callvirt", "operand": "[Microsoft.VisualBasic]Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase::Run" }, … ] }
Gomorrah (2f8a79b12a7a989ac7e5f6ec65050036588a92e65aeb6841e08dc228ff0e21b4): a VB.NET Forms app's Main. A body that is not valid IL (encrypted by a protector) stops with stopReason saying where.
get_xrefs¶
Where something is used in the code, each site with its containing function and the instructions leading up to it, so argument setup is visible (push 0x40 before VirtualAlloc = PAGE_EXECUTE_READWRITE). CLI equivalent: --xrefs.
| Argument | Default | Description |
|---|---|---|
to | (required) | An import (VirtualAlloc, or kernel32.VirtualAlloc), an address (va:X, rva:X, a hex VA or func:NAME), a constant (imm:0x3FAE), string:TEXT (every referenced string containing TEXT), or a whole range: section:NAME / section:#N or range:LO-HI (VAs, end exclusive) |
max_sites | 20 | Sites listed per target (max 200); total always gives the full count |
context | 3 | Instructions shown before each site (max 12) |
{ "name": "get_xrefs", "arguments": { "path": "/samples/STEALERDLL.dll", "to": "CryptUnprotectData", "max_sites": 1, "context": 2 } }
{ "target": "CryptUnprotectData", "instructionsDecoded": 243512,
"targets": [ { "va": "0x1800FE070", "kind": "import", "name": "crypt32.CryptUnprotectData", "total": 3,
"sites": [ { "va": "0x18008F283", "function": "sub_18008F140+0x143",
"context": [ "0x18008F27C xor edx, edx", "0x18008F27E mov dword ptr [rsp+0x40], r14d",
"0x18008F283 call qword ptr [crypt32.CryptUnprotectData]" ] } ] } ] }
Calls through import thunks (jmp [IAT]) count as calls to the import. Code reached only through computed jumps is not covered.
Constants: imm:¶
imm:0x421 lists the instructions that use the value as an immediate: a dialog control ID, a record magic, a hash. Unlike a hex search, it doesn't match bytes that only happen to contain the value. Values from 0x100 up are indexed (below that, counts and flags are everywhere).
{ "name": "get_xrefs", "arguments": { "path": "/samples/[email protected]", "to": "imm:0x421", "max_sites": 2, "context": 2 } }
{ "targets": [ { "kind": "immediate", "name": "0x421", "total": 7,
"sites": [ { "va": "0x418A10", "function": "sub_418898+0x178",
"context": [ "0x418A0B push dword ptr [eax]", "0x418A0D push dword ptr [ebp+0x14]", "0x418A10 push 0x421" ] },
{ "va": "0x4333AB", "function": "sub_43327E+0x12D", … } ] } ] }
0x421 is the Install new instance button of the dropper's installer dialog (get_resources lists it): these are the functions that read or set it.
Code with no caller: indirectCandidates¶
For a code address, the response also lists indirectCandidates: where the address is stored as a pointer in the file's data (a vtable, an MFC message map, a callback table). With no direct caller, a note says that is how such code is reached, instead of leaving "0 callers" to look like dead code.
{ "targets": [ { "va": "0x401260", "kind": "code", "total": 0,
"indirectCandidates": [ { "va": "0x42D29C", "fileOffset": "0x2C29C", "section": ".rdata" } ],
"note": "no direct call or jump to it in the decoded code; its address is stored as data (indirectCandidates), the usual way code like this is reached" } ] }
[email protected], ransomware: a function called only through a table in .rdata.
Into a whole section or range¶
Is a section used at all? section:NAME (a long name works either way, .zdebug_line or /19), section:#N or range:LO-HI lists every decoded instruction whose jump/call target or memory operand falls anywhere in it, and counts the pointer-sized values in data (non-executable sections, outside the range) that point into it (pointersInto, the first 50 in pointers). Instructions whose immediate merely has a value in the range come as a separate rangeImmediate target: usually a constant, not an address.
{ "name": "get_xrefs", "arguments": { "path": "/samples/09088616….exe", "to": "range:0x9DF000-0xC14000", "max_sites": 2 } }
{ "targets": [
{ "va": "0x9DF000", "end": "0xC14000", "kind": "range", "total": 0, "pointersInto": 4,
"pointers": [ { "va": "0x78E398", "section": ".rdata", "value": "0xC00000" }, { "va": "0x940188", "section": ".data", "value": "0xBAB9B8" }, … ],
"note": "no decoded instruction refers into the range (jump/call target or memory operand); the pointers are aligned values in data that may be numbers, not addresses -- check them with read_bytes" },
{ "kind": "rangeImmediate", "total": 6,
"sites": [ { "function": "net/http.(*http2Transport).newClientConn+0x7E6", "context": [ "0x639DE6 mov edx, 0xA00000" ] }, … ] } ] }
The Go info-stealer again: its five high-entropy sections are compressed DWARF (triage_pe says so with section_compressed_debug), and nothing in the code refers into them. The six immediates are 0xA00000, 10 MB, an HTTP/2 window size; the four data values are byte tables, as read_bytes on them shows.
.NET: from the IL¶
For a .NET assembly the answer comes from the IL of every method. string:TEXT gives the ldstr sites (each string with its stringOffset in the file); method:Type::Name or method:0x06xxxxxx gives the calls to that one method, by token; any other to is a member name or part of one (Process::Start, WebClient, GetAsyncKeyState) matched against call, callvirt, newobj and ldftn.
{ "name": "get_xrefs", "arguments": { "path": "/samples/2f8a79b1….bin", "to": "string:http", "max_sites": 2 } }
{ "arch": "CIL", "methodsDecoded": 262, "targets": [
{ "kind": "string", "text": "…Developed By th3darkly [ https://gomorrah.pw ]…", "total": 1,
"sites": [ { "method": "update_windows10.main::main_Load", "methodToken": "0x060000D7", "il": "IL_041B", "opcode": "ldstr" } ] },
{ "kind": "string", "text": "http://ip-api.com/json/", "total": 1,
"sites": [ { "method": "update_windows10.main::get_countryCode", "il": "IL_0007", "opcode": "ldstr" } ] },
{ "kind": "string", "text": "HTTP Password", "total": 2,
"sites": [ { "method": "update_windows10.OutkookStealer::GetOutlookPasswords", "il": "IL_0026", "opcode": "ldstr" }, … ] }, … ] }
The author's banner, a geolocation lookup, and an Outlook password stealer, each with the method that uses it. With to: "GetAsyncKeyState": 134 call sites, all in update_windows10.main::KeyLogs. Pass a methodToken to disassemble as method:0x060000D7 to read the code.
Mixed-mode (C++/CLI) assemblies hold native code and IL in one module. get_xrefs searches both: the native code's sites as for any PE, the IL's under il, or the IL's answer alone when the native code has none. A C++/CLI implant's WebSocket commands:
{ "arch": "CIL", "methodsDecoded": 482, "targets": [
{ "kind": "string", "text": "ws_;;_", "token": "0x70000507", "stringOffset": "0x26D6C", "total": 1,
"sites": [ { "method": "<Module>::WSThread", "methodToken": "0x060000EA", "il": "IL_0015", "opcode": "ldstr" } ] },
{ "kind": "string", "text": "getws_;;_", "token": "0x70000597", "stringOffset": "0x26DFC", … },
{ "kind": "string", "text": "sendws_;;_", … }, { "kind": "string", "text": "closews_;;_", … } ] }
to: "method:run_DLL" then lists its 9 call sites (in WSThread, self_execute, execute, ThreadFunc and the exported EnableThemeDialogTexture) in one call.
list_functions¶
Function starts found by decoding from the entry point, TLS callbacks, exports, .pdata, the CFG function table, relocated pointers and direct call targets, each with its source, name when known and number of direct callers. Pageable with select: "functions.list"; then disassemble any va.
{ "functions": { "count": 4102, "instructionsDecoded": 243512,
"list": [ { "va": "0x180001000", "source": ".pdata", "callers": 0 }, … ] },
"_truncated": [ { "path": "functions.list", "total": 4102, "shown": 3 } ] }
get_callers and get_callees¶
The call tree around a function, from the code scan: callees (what it calls, each with the imports it calls: an outline of what the code does) or callers (who calls it; a function with none lists storedAsPointerAt). Walk the code without picking addresses by hand.
| Argument | Default | Description |
|---|---|---|
target | ep | The function, in disassemble's forms (func:NAME, export:, va:, …). An address inside a function means that function. For .NET, method:Type::Name or method:0x06xxxxxx |
depth | 1 | Levels of the tree (max 3) |
A ransomware watchdog (fcf1da46d66cc6a0a34d68fe79a33bc3e8439affdee942ed82f6623586b01dd1.exe, 14 KB), whose triage has nothing to say:
{ "name": "get_callees", "arguments": { "path": "/samples/fcf1da46….exe", "target": "ep", "depth": 3 } }
{ "tree": { "name": "EntryPoint", "callees": [ { "name": "sub_140001404", "imports": [ "api-ms-win-crt-runtime-l1-1-0._initterm", … ],
"callees": [ { "name": "sub_140001070",
"imports": [ "kernel32.CreateToolhelp32Snapshot", "kernel32.Sleep", "kernel32.Process32NextW", "kernel32.Process32FirstW",
"kernel32.CloseHandle", "kernel32.CreateProcessW", "api-ms-win-crt-string-l1-1-0._wcsicmp" ] }, … ] } ] } }
One function enumerates processes, compares names and starts a process, with a Sleep: a loop that restarts a process when it disappears.
A function already in the tree is marked seenAbove and not expanded again; the tree stops at 300 functions. Calls through a register or a computed pointer are not followed.
.NET: the IL call tree¶
With a method: target (and ep of an IL-only assembly) the tree comes from the IL: the methods whose IL calls it, or what its IL calls, each with the IL offsets of the calls (callsAt / calledAt). This works on mixed-mode assemblies too, where the native scan cannot see the managed methods.
{ "name": "get_callers", "arguments": { "path": "/samples/implant.dll", "target": "method:self_execute", "depth": 3 } }
{ "arch": "CIL", "method": "<Module>::self_execute", "token": "0x060000EB", "depth": 3,
"callers": [ { "method": "<Module>::execute", "token": "0x060000EC", "callsAt": [ "IL_0031" ],
"callers": [ { "method": "<Module>::execute", "token": "0x060000E9", "callsAt": [ "IL_0038" ],
"callers": [ { "method": "<Module>::WSThread", "token": "0x060000EA", "callsAt": [ "IL_0089" ] },
{ "method": "<Module>::ThreadFunc", "token": "0x060000ED", "callsAt": [ "IL_0009" ] } ] } ] } ] }
A recursive call is marked recursive; the tree stops at 400 entries.
No IL caller, reflection in the assembly. Obfuscators bind handlers with Type.GetMethod and Delegate.CreateDelegate, the names decrypted at run time, so no IL names the method. When get_callers finds no caller and the assembly calls such APIs, it adds reflection: the API counts and the methods holding the most binding sites, where to read next:
{ "method": "Helios.Commerce.Services.InternalCatalogContext370::LoadEndpoint999", "callers": [],
"reflection": { "apis": { "System.Delegate::CreateDelegate": 49, "System.Type::GetMethod": 49, "System.Type::GetType": 12,
"System.Reflection.Module::ResolveMethod": 1 },
"bindingMethods": [ { "method": "<PrivateImplementationDetails>Layoutbyq::.cctor", "sites": 32 },
{ "method": "<PrivateImplementationDetails>Segmentoph::.cctor", "sites": 20 }, … ] } }
get_resources¶
What a file's dialogs, version info and string tables hold, decoded from their own formats (on any platform). Control and string IDs of 0x100 and up get codeUses: how many instructions use the ID as an immediate, the code behind a button or a LoadString.
| Argument | Default | Description |
|---|---|---|
kind | all | dialog, version, string or all |
code_refs | true | Count the code's uses of each ID (scans the code) |
{ "name": "get_resources", "arguments": { "path": "/samples/[email protected]", "kind": "dialog" } }
{ "dialogs": [ { "resource": "RT_DIALOG/223/1033", "caption": "Multiple Instances", "font": "MS Shell Dlg 8pt",
"controls": [ { "id": "0x421 (1057)", "class": "Button", "text": "Install new instance:", "codeUses": 7 },
{ "id": "0x422 (1058)", "class": "Button", "text": "Maintain or upgrade an already installed instance:", "codeUses": 4 },
{ "id": "0x420 (1056)", "class": "SysListView32", "codeUses": 7 }, … ] }, … ] }
The version info is often the first thing a sample fakes. A fake "Security Essentials 2011" antivirus ([email protected]):
{ "version": { "fileVersion": "1.3.0.0", "strings": { "CompanyName": "Four-F", "FileDescription": "Kernel Mode Driver Manager",
"InternalName": "KmdManager", "OriginalFilename": "KmdManager.exe", "ProductName": "Kernel Mode Driver Manager", … } } }
Its version info names a different, freeware driver tool: compare it with the file name, the signer and what the code does.
list_types¶
The types of a .NET assembly with their methods: namespace and name, base type, visibility, and each method's token and IL size. Behaviour shows in the names, so start here on a .NET sample.
| Argument | Default | Description |
|---|---|---|
filter | Keep types whose full name, or methods whose name, contains this text (case-insensitive) | |
methods | true | List each type's methods |
offset, limit | 0, 100 | Page through the types (max 2000) |
{ "typeCount": 49, "methodCount": 287, "entryPoint": "update_windows10.My.MyApplication::Main (0x06000003)", "matched": 8,
"types": [
{ "token": "0x0200000A", "type": "update_windows10.ChromeRecovery.Account", "extends": "[mscorlib]System.Object", "fields": 4,
"methods": [ { "token": "0x0600002F", "name": "get_UserName", "ilSize": 11 }, { "token": "0x06000031", "name": "get_Password", "ilSize": 11 }, … ] },
{ "token": "0x0200000B", "type": "update_windows10.ChromeRecovery.AesGcm",
"methods": [ { "token": "0x06000038", "name": "Decrypt", "ilSize": 295 }, … ] }, … ] }
A Chrome credential recovery module with its own AES-GCM decryption. disassemble with method:0x06000038 reads Decrypt.
search_bytes¶
Finds a hex pattern, a text string, or up to 16 texts at once in the file and reports where each match is, with the surroundings as hex, as contextAscii, and as contextText: read as UTF-16 for a UTF-16 match, line breaks kept.
| Argument | Default | Description |
|---|---|---|
hex | Byte pattern: hex pairs, ?? for any byte, e.g. "E8 ?? ?? ?? ?? 5D C3". Give hex or text | |
text | Text to find (UTF-8 in the request), or an array of up to 16 texts: each match then names its text, and totalPerText counts each. An array sent as its JSON text ("[\"a\", \"b\"]", as some clients do) is read as the array | |
encoding | both | For text: ascii, utf16 (UTF-16LE) or both |
ignore_case | true | For text: ASCII case-insensitive |
start, end | whole file | File-offset range (numbers or hex strings) |
limit | 50 | Matches returned (max 500) |
offset | 0 | Matches to skip (paging) |
context | 16 | Bytes shown on each side (max 512) |
Real output (explorer.exe, text: "createprocess"):
{ "pattern": "createprocess", "searched": "0-5CEB70", "total": 1, "offset": 0, "returned": 1,
"matches": [ { "fileOffset": "427EFA", "rva": "429CFA", "section": ".rdata", "encoding": "ascii", "contextStart": "427EEA",
"contextHex": "6E 6F 77 6E 5F 53 65 74 53 69 74 65 00 00 03 00 43 72 65 61 74 65 50 72 6F 63 65 73 73 57 …",
"contextAscii": "nown_SetSite....CreateProcessW....SHGetValueW" } ] }
total counts every match (it stops at 100,000 and says so). Page with offset, and use read_bytes to look further around a hit.
Several texts, with the text around a UTF-16 match:
{ "name": "search_bytes", "arguments": { "path": "/samples/tunnel.exe", "text": [ "<ip|domain>", "running socks5 on" ],
"encoding": "utf16", "context": 160 } }
{ "totalPerText": { "<ip|domain>": 2, "running socks5 on": 1 },
"matches": [ { "text": "<ip|domain>", "encoding": "utf16", …,
"contextText": "…-c -ti <ip|domain> -tp <port>\r\n\r\n-h | --help : show help\r\n-s | --server …" }, … ] }
extract_payload¶
Locates a region, then reports its size, MD5, SHA-256, entropy, detected type, and for an embedded PE its machine, bitness and kind.
| Argument | Description |
|---|---|
source | overlay, resource, section or range (required) |
resource_type, resource_name, resource_language | For resource: e.g. RT_RCDATA or 10, then a name or ID; the first language by default |
section | For section: its name, or its position as #N |
offset, length | For range |
include_signature | For overlay: include a trailing Authenticode certificate table (default false) |
output_path | Write mode only: also write the bytes to this new file |
Real output for the overlay of a Go implant:
{ "source": "overlay (data after the last section, excluding the certificate table)",
"fileOffset": "C5F800", "size": 1205906, "md5": "FA53EC3336A24BDEA47F62A1E0B86E68", "sha256": "6A093C5E…",
"entropy": 5.435, "detectedType": null,
"looksLikeCode": { "verdict": false, "arch": "x86", "decodedRatio": 0.999, "dataLikeRatio": 0.723,
"note": "decodes, but into many instructions compilers never emit: data (compressed, encrypted or text), not code" },
"firstBytesAscii": ".file...&.......g.crtdll.c.........." }
The first bytes (.file) show this overlay is a COFF symbol table left by the linker, not a hidden payload, and looksLikeCode agrees it is not shellcode: a quick way to rule out a false alarm.
For a real embedded payload, see the RemusStealer dropper (3c6b036f2eebc124c17db51960d9f6c9b39e236e9a33d5ac0c3a3a2cabe36833.exe):
{ "name": "extract_payload", "arguments": { "path": "/samples/3c6b036f….exe", "source": "resource",
"resource_type": "RT_RCDATA", "resource_name": "100" } }
{ "source": "resource RT_RCDATA/100 language 1033", "fileOffset": "47300", "rva": "4C500", "size": 1515568,
"md5": "AC7A167EE7269BD790F220BB104CCA22", "sha256": "CEE89827F4E8E7E32EC9F0B484586283CD7E345BFA06F458CE631D4FA9C098BC",
"entropy": 6.496, "detectedType": "PE File",
"embeddedPe": { "machine": "0x8664", "bitness": "64-bit (PE32+)", "kind": "EXE" },
"firstBytesAscii": "MZ......................@......" }
A 64-bit EXE making up 83% of the file: the dropper's second stage. Look its SHA-256 up, or analyse it in place: every tool takes /samples/3c6b036f….exe#resource:RT_RCDATA/100 as its path (Look inside a file). No need to carve it.
- Without
--mcp-allow-writethe tool only measures, so the assistant can look up the hashes or read the bytes withread_bytes. - With
--mcp-allow-write,output_pathwrites the bytes to a file that must not exist yet. It never overwrites, and refuses payloads over 512 MB. - A resource whose data isn't in the file (packed with UPX, for example) is reported as such, with no bytes invented.
- When the bytes have no recognised type,
looksLikeCodesays whether they read as x86/x64 instructions (possible shellcode): the first 4 KB are decoded as both, and the share of bytes that decode and of instructions typical of data decides. Code sections pass; data and certificate overlays don't. Thenotenames thedisassembletarget to look further, for exampleoff:0x400.
"looksLikeCode": { "verdict": true, "arch": "x64", "decodedRatio": 0.999, "dataLikeRatio": 0.000,
"note": "the first 4 KB decode as plausible instructions: possibly shellcode; disassemble with target off:0x400" }
list_container¶
What an archive or installer holds: names, sizes, how each entry is stored, encryption. Read from the container's own directory: nothing is extracted.
| Argument | Default | Description |
|---|---|---|
path | A self-extractor or PyInstaller EXE as it is, or a layer such as file.exe#overlay or file.exe#resource:RT_RCDATA/CABINET. A file with ZIPs inside it lists where they are (below) | |
filter | Only entries whose name contains this text (case-insensitive) | |
offset | 0 | First entry to return |
limit | 200 | Most entries returned (max 5000) |
| Format | What you get |
|---|---|
| ZIP (also appended to a self-extractor) | Names, sizes, method, encrypted entries, comment |
| CAB (IExpress droppers, installers) | Files and each folder's compression (MSZIP, LZX, Quantum) |
| PyInstaller | Python version and library, the program's entry-point script, every bundled module, DLL and data file |
.NET resource set (.resources, e.g. file.exe#netres:NAME) | Each resource's name and type: string, byte[], stream, System.Drawing.Bitmap (serialized), … |
| OLE / MSI | Stream and storage names; MSI's encoded names decoded (Binary. streams hold custom-action DLLs and scripts) |
| RAR 4 and 5 | Names, sizes, method, encryption, or that the headers themselves are encrypted |
| 7-Zip, NSIS, Inno Setup | Identified, with the version when stored, and a note that the file list is compressed |
An IExpress dropper (1aa57d4894da1b29af368401f33c856560517e77792a143419640a005c9f8465.exe). Triage shows a cabinet in its resources ("resource": "RT_RCDATA/CABINET", "detectedAs": "Microsoft Cabinet file"). List it:
{ "name": "list_container", "arguments": { "path": "/samples/1aa57d48….exe#resource:RT_RCDATA/CABINET" } }
{ "format": "Microsoft cabinet (CAB)", "facts": { "Version": "1.3", "Files": "2", "Folders (compression units)": "1" },
"entryCount": 2,
"entries": [ { "name": "y6277044.exe", "size": 768512, "method": "LZX", "kind": "file" },
{ "name": "n0922420.exe", "size": 926407, "method": "LZX", "kind": "file" } ] }
Two executables, dropped and run by IExpress.
For ZIP, PyInstaller and .NET resource-set entries, each entry also has:
| Field | Meaning |
|---|---|
fileOffset | Where the entry starts in the file (or layer) list_container read; offset is relative to the container |
content | What the entry holds, read from its first 4 KB in memory (PE executable, gzip, text, binary data, …) |
mismatch | When the name says otherwise: a .txt or .js that is binary, an .exe that is gzip or not an executable at all. Archives and images are flagged only for another recognised format, any MZ-family executable counts as an executable, and AppleDouble (._*) files are never flagged |
path | A layer path (…#entry:NAME, or #entry:#N when the name has a #) that opens the entry with any tool |
ZIP archives inside a layer¶
When no container starts at the beginning of path, list_container looks inside it for ZIP archives (JAR, APK, DOCX, …) from their end-of-central-directory records, and keeps one only when its central directory and first local header are where the record says. Each comes with its range, its entry count, inside (the archive that holds it) and a path that opens it. A Rust installer that carries a Java application and runtime in .rdata:
{ "format": "none at the start", "embedded": [
{ "format": "ZIP archive", "offset": "0x137370", "length": 21642599, "entries": 4098, "path": "/samples/7de37b22….exe#offset:0x137370+0x14A3D67" },
{ "format": "ZIP archive", "offset": "0x15DB150", "length": 47343161, "entries": 164, "path": "/samples/7de37b22….exe#offset:0x15DB150+0x2D26639" },
{ "format": "ZIP archive", "offset": "0x2A79022", "length": 109443, "entries": 60, "inside": 1, "path": "…" },
{ "format": "ZIP archive", "offset": "0x42A80CC", "length": 230382, "entries": 1458, "inside": 1, "path": "…" } ] }
triage_pe says the same up front (embedded_zip: 2 ZIP archives are carried inside the sections, 67368 KB (97% of the file)). Then read entries as layers with #entry::
{ "name": "decode_bytes", "arguments": { "path": "/samples/7de37b22….exe#offset:0x137370+0x14A3D67#entry:META-INF/MANIFEST.MF", "at": "file", "steps": "skip:0" } }
{ "size": 104, "encoding": "text", "text": "Manifest-Version: 1.0\nCreated-By: Maven JAR Plugin 3.4.1\nBuild-Jdk-Spec: 21\nMain-Class: complexer.NUL.L\n" }
The obfuscated main class; decode_bytes without steps on #entry:complexer/NUL/J.class lists the class's readable constants, among them nameOnCard;expirationMonth;expirationYear;cardNumber;cvv.
A PyInstaller sample (e21e0977284f9eacbb69e04b132f923586837d2b405cee03bdca81d2944bf4c5.exe), passed as it is. Ask for the program's own code with filter:
{ "format": "PyInstaller archive", "containerOffset": "44A00",
"facts": { "Python version": "3.10", "Python library": "python310.dll", "Entries": "170", "Entry-point script": "main" },
"entryCount": 170, "matched": 1,
"entries": [ { "name": "main", "size": 19400, "packedSize": 19416, "method": "zlib", "kind": "script (run at start)", "offset": "4E85",
"fileOffset": "49885", "content": "binary data", "path": "/samples/e21e0977….exe#entry:main" } ] }
main is the script to decompile, and its path reads it, decompressed, with read_bytes or decode_bytes; the other 169 entries are Python, its libraries and PyInstaller's own bootstrap. The PyInstaller analysis shows the same in the GUI.
A rogue antivirus ([email protected]) with a RAR archive appended:
{ "name": "list_container", "arguments": { "path": "/samples/[email protected]#overlay" } }
{ "format": "RAR archive (version 4)", "entryCount": 4,
"entries": [ { "name": "pthreadVC2.dll", "size": 86070, "packedSize": 19650, "method": "RAR method 3", "kind": "file" },
{ "name": "bzip2.dll", "size": 69120, … },
{ "name": "avpc2009.exe", "size": 9421312, "packedSize": 1070571, … },
{ "name": "libltdl3.dll", "size": 35328, … } ] }
A 9 MB avpc2009.exe and the DLLs it needs. When the RAR headers are encrypted, the response says so in notes instead.
get_iocs¶
Candidate indicators of compromise from the file's strings, deduplicated, each with its occurrence count, first file offset, section and encoding.
| Argument | Default | Description |
|---|---|---|
include_common | false | Also list well-known hosts (certificate authorities, Microsoft, W3C, …) |
limit | 50 | Most values returned per kind |
max_chars | 40000 | Response budget |
Kinds: urls, domains (with inUrl when the domain came from a URL, the most reliable kind), ipv4, emails, registry, filePaths, namedPipes, pdbPaths.
For x86/x64 files, referencedByCode: true marks an IOC found in a string the code actually uses: stronger evidence than one that only sits in the file. For .NET it means an ldstr loads the string: in Gomorrah, https://gomorrah.pw and ip-api.com are marked, while its web-panel URL, stored outside the IL's strings, is not. Real output for STEALERDLL.dll (shortened):
"registry": [
{ "value": "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\firefox.exe", "occurrences": 1, "firstOffset": "117500",
"section": ".rdata [R] (#2 section)", "encoding": "ascii", "referencedByCode": true },
{ "value": "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\Thunderbird.exe", …, "referencedByCode": true } ],
"pdbPaths": [ { "value": "D:\\Mktmp\\StealerDLL\\Release.x64\\STEALERDLL.pdb", "section": "debug directory", "encoding": "codeview" } ]
The code looks up where Firefox and Thunderbird are installed (to find nss3.dll and the profiles), and the PDB path names the project.
Real output for a Go C2 implant (shortened):
{ "counts": { "urls": 5, "domains": 3, "ipv4": 2, "emails": 1, "namedPipes": 5, … },
"iocs": {
"urls": [ { "value": "https://127.0.0.1:443", "occurrences": 3, "firstOffset": "5B0B6B", "section": ".data [RW] (#2 section)" }, … ],
"ipv4": [ { "value": "127.0.0.1", … }, { "value": "192.168.1.1", … } ],
"domains": [ { "value": "github.com", "inUrl": true, … }, … ] } }
To keep the list useful, PPEE leaves out things that look like indicators but almost never are:
- certificate OIDs (
2.5.29.17) and version numbers (6.0.0.0) that look like IP addresses - well-known CA, Microsoft and W3C hosts found in signatures and manifests (counted in
hiddenCommon) - binary junk that looks like a path (
C:\:k:}:) - artifacts of Go's packed string data (
1github.com, words glued to a domain, and symbol names such asruntime.link)
Go and Rust strings come out whole
Go and Rust store string literals back to back, with no terminator. PPEE cuts them where the code refers to each one and at the length the code passes with it, so an IOC is the literal itself. AfroRat (dbccfce8d0ebc5ea70b601130d6453cb31db779c002149c9f2a3c6b0236fe8af, Rust) gives C:\ProgramData\AfroRat\config.bin, not C:\ProgramData\AfroRat\config.binSVC_CFG_EXTERNAL_CONFIG_PATHDPAPI decrypt….
Candidates, not verdicts
IOCs come from static strings: content that is packed or encrypted is not visible, and some values are ordinary library strings. Treat the list as leads to check.
check_similarity¶
Hashes the file, compares it with the server's local similarity database and records it. Returns fileInfo and similarity.matches[]. Annotated as not read-only because it writes the database.
patch_pe¶
Only present with --mcp-allow-write. Applies edits with the --set syntax and writes the result.
| Argument | Required | Description |
|---|---|---|
path | ✔ | Input file |
edits | ✔ | {field, value}[], for example {"field": "FileHeader.TimeDateStamp", "value": "0"} |
output_path | ✔ (unless dry_run) | Where to write |
overwrite | Needed to replace an existing file | |
dry_run | true applies the edits in memory only and returns the patched headers. Nothing is written and output_path is not needed | |
full_headers | true returns every header field of the patched file instead of the compact reply |
Rules, all enforced by the server:
output_pathmust not exist, unlessoverwrite: true.- Even with
overwrite: true, only an existing PE file (or the input itself) can be replaced. Any other file is never overwritten. - The input is recognised through symlinks and different spellings of its path.
- Edits are all or nothing: if one fails, nothing is written. The error says what the address or value should have looked like.
- Besides field names,
editstake theCell:,RawOffset:,RawBits:,RawBytes:(alsorva:/va:) andString:addresses.RawOffsetwrites its value as one little-endian integer and refuses one wider than its width; useRawBytesfor code. OptionalHeader.CheckSumwith the valueautorecomputes the checksum after the other edits.- The reply is compact: the edits, the checksum now stored and the edited header fields.
{ "name": "patch_pe",
"arguments": { "path": "/work/app.exe", "output_path": "/work/app.noaslr.exe",
"edits": [ { "field": "OptionalHeader.DllCharacteristics", "value": "8120" } ] } }
Example prompts¶
| Prompt | Tools the assistant typically uses |
|---|---|
"Triage C:\Samples\invoice.exe." | triage_pe, then focused tools for the top findings |
"Is setup.exe signed, and was it modified after signing?" | check_signature (or triage_pe) |
"What can svc.exe do? Group its imports by capability." | triage_pe, list_imports |
"Show me the code at the entry point of x.exe." | disassemble (target: "ep") |
| "This looks packed. Where does the unpacking stub jump to?" | triage_pe, then disassemble with stop_at_flow_end |
"Does x.dll actually call CreateRemoteThread, and with what arguments?" | get_xrefs (to: "CreateRemoteThread", context: 8) |
| "Which of these strings does the code really use?" | get_strings (codeRefs), then get_xrefs with string: |
| "This file has almost no strings. What does it hide?" | triage_pe (textStrings), then get_strings with group: "code" |
| "Which Windows APIs does this .NET or Go binary really reach?" | triage_pe (capability hints marked P/Invoke, named in strings) |
| "What does this .NET stealer steal? Show me the method that decrypts." | list_types with filter, then disassemble with method:… |
| "Which method loads the C2 URL?" | get_xrefs with string:http (IL ldstr sites; mixed-mode C++/CLI too) |
| "Who calls this .NET method, and what does it call?" | get_callers / get_callees with method:Type::Name |
| "What does this small program do, function by function?" | get_callees from ep with depth: 3 |
| "Which code handles the button with ID 1057?" | get_resources (codeUses), then get_xrefs with imm:0x421 |
| "Nothing calls this function. How is it reached?" | get_xrefs on its va (indirectCandidates) or get_callers (storedAsPointerAt) |
"Show me Go's main.main." | disassemble with func:main.main |
| "There's no URL in the strings. Is one XORed?" | decode_bytes on section:.rdata (or .data) with find_key: "http" |
| "Decode this base64 config." | decode_bytes with text and steps: "base64" (add zlib, gzip when the result looks compressed) |
| "Give me the section hashes for a lookup." | hash_range with no at |
| "Is this overlay shellcode?" | extract_payload (looksLikeCode), then disassemble with off: |
"Extract the IOCs from dropper.bin." | get_iocs |
"Where does x.exe contain the bytes E8 ?? ?? ?? ?? 5D C3?" | search_bytes |
"This Go function copies a list of 243 strings from 0x7B3BE0. What are they?" | read_bytes with as: "go_strings" and count: 243 |
| "Five sections are near-random. Does anything use them?" | triage_pe (section_compressed_debug), then get_xrefs with section:NAME or range:LO-HI |
| "What text is between these two offsets? It's UTF-16." | decode_bytes with at and length, no steps |
| "Find these six strings and show the text around each." | search_bytes with text as an array and a larger context (contextText) |
"Is the appended data in setup.exe another executable?" | triage_pe (overlay.detectedAs), then any tool on setup.exe#overlay |
| "What does this installer drop?" | triage_pe, then list_container on the overlay or the CAB resource |
"Triage the PE hidden in resource W/101 of launcher.dll." | triage_pe with launcher.dll#resource:W/101 (plus #offset:N when it starts later) |
| "Which Python script does this PyInstaller EXE run?" | list_container (facts["Entry-point script"]) |
"Find URLs and registry keys in dropper.bin." | get_strings with group and contains |
"What was agent.dll built with, and does its metadata reveal any configuration?" | analyze_pe with analysis |
"List every exception entry of big.exe." | analyze_pe with select: exception.entries, paging with offset |
"Make a copy of app.exe with ASLR disabled." | patch_pe (write mode) |
References¶
- Model Context Protocol specification: the protocol PPEE's MCP server implements.