207 lines
6.9 KiB
Python
Executable File
207 lines
6.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""ApiOid PE static-triage helper for reverse-skill.
|
|
|
|
Pure-Python Windows PE triage using pefile (+ optional capstone). No external
|
|
RE tools required. Emits a compact JSON + human summary: identity, hashes,
|
|
sections (entropy), imports (categorized), notable strings, TLS/exports,
|
|
signature presence, packer hints.
|
|
|
|
Usage: python omp-pe-triage.py <path-to.exe> [--strings-min 6] [--max-strings 60]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
|
|
try:
|
|
import pefile
|
|
except ImportError:
|
|
print("ERR: pefile not installed (python -m pip install pefile)", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
# Import-name buckets that hint at behavior.
|
|
API_BUCKETS = {
|
|
"process/inject": [
|
|
"VirtualAlloc", "VirtualAllocEx", "WriteProcessMemory", "CreateRemoteThread",
|
|
"NtCreateThreadEx", "QueueUserAPC", "SetThreadContext", "OpenProcess",
|
|
"CreateProcess", "ShellExecute", "WinExec", "NtUnmapViewOfSection",
|
|
],
|
|
"network": [
|
|
"WSAStartup", "socket", "connect", "send", "recv", "InternetOpen",
|
|
"InternetConnect", "HttpOpenRequest", "HttpSendRequest", "URLDownloadToFile",
|
|
"WinHttpOpen", "WinHttpConnect", "getaddrinfo", "gethostbyname",
|
|
],
|
|
"crypto": [
|
|
"CryptAcquireContext", "CryptEncrypt", "CryptDecrypt", "CryptGenKey",
|
|
"CryptHashData", "BCryptEncrypt", "BCryptDecrypt", "CryptStringToBinary",
|
|
],
|
|
"filesystem": [
|
|
"CreateFile", "WriteFile", "ReadFile", "DeleteFile", "MoveFile",
|
|
"CopyFile", "FindFirstFile", "SHGetFolderPath", "GetTempPath",
|
|
],
|
|
"registry": [
|
|
"RegOpenKey", "RegSetValue", "RegCreateKey", "RegQueryValue", "RegDeleteKey",
|
|
],
|
|
"persistence": [
|
|
"CreateService", "OpenSCManager", "SetWindowsHookEx", "RegisterServiceCtrlHandler",
|
|
],
|
|
"anti-analysis": [
|
|
"IsDebuggerPresent", "CheckRemoteDebuggerPresent", "NtQueryInformationProcess",
|
|
"GetTickCount", "QueryPerformanceCounter", "OutputDebugString", "Sleep",
|
|
],
|
|
"privilege": [
|
|
"AdjustTokenPrivileges", "OpenProcessToken", "LookupPrivilegeValue",
|
|
"LogonUser", "ImpersonateLoggedOnUser",
|
|
],
|
|
}
|
|
|
|
|
|
def entropy(data: bytes) -> float:
|
|
if not data:
|
|
return 0.0
|
|
counts = [0] * 256
|
|
for b in data:
|
|
counts[b] += 1
|
|
n = len(data)
|
|
ent = 0.0
|
|
for c in counts:
|
|
if c:
|
|
p = c / n
|
|
ent -= p * math.log2(p)
|
|
return round(ent, 3)
|
|
|
|
|
|
def hashes(path: str) -> dict:
|
|
h_md5, h_sha1, h_sha256 = hashlib.md5(), hashlib.sha1(), hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
h_md5.update(chunk)
|
|
h_sha1.update(chunk)
|
|
h_sha256.update(chunk)
|
|
return {"md5": h_md5.hexdigest(), "sha1": h_sha1.hexdigest(), "sha256": h_sha256.hexdigest()}
|
|
|
|
|
|
def ascii_strings(data: bytes, minlen: int):
|
|
return re.findall(rb"[\x20-\x7e]{%d,}" % minlen, data)
|
|
|
|
|
|
def unicode_strings(data: bytes, minlen: int):
|
|
return re.findall(rb"(?:[\x20-\x7e]\x00){%d,}" % minlen, data)
|
|
|
|
|
|
INTERESTING = re.compile(
|
|
rb"(https?://|www\.|\.onion|[A-Za-z]:\\|/etc/|HKEY_|SOFTWARE\\|"
|
|
rb"\.(exe|dll|dat|bin|sys|ps1|bat|vbs)\b|token|password|api[_-]?key|"
|
|
rb"bearer|-----BEGIN|[0-9]{1,3}(?:\.[0-9]{1,3}){3})",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("path")
|
|
ap.add_argument("--strings-min", type=int, default=6)
|
|
ap.add_argument("--max-strings", type=int, default=60)
|
|
args = ap.parse_args()
|
|
|
|
out: dict = {"path": args.path}
|
|
out["hashes"] = hashes(args.path)
|
|
|
|
try:
|
|
pe = pefile.PE(args.path, fast_load=False)
|
|
except pefile.PEFormatError as e:
|
|
print(f"ERR: not a valid PE: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
fh = pe.FILE_HEADER
|
|
oh = pe.OPTIONAL_HEADER
|
|
is64 = hex(oh.Magic) == "0x20b"
|
|
out["identity"] = {
|
|
"machine": hex(fh.Machine),
|
|
"bits": 64 if is64 else 32,
|
|
"subsystem": pefile.SUBSYSTEM_TYPE.get(oh.Subsystem, str(oh.Subsystem)),
|
|
"dll": bool(fh.Characteristics & 0x2000),
|
|
"entry_point": hex(oh.AddressOfEntryPoint),
|
|
"image_base": hex(oh.ImageBase),
|
|
"timestamp": fh.TimeDateStamp,
|
|
"num_sections": fh.NumberOfSections,
|
|
}
|
|
|
|
out["sections"] = []
|
|
for s in pe.sections:
|
|
raw = s.get_data()
|
|
name = s.Name.rstrip(b"\x00").decode("latin1", "replace")
|
|
out["sections"].append({
|
|
"name": name,
|
|
"vsize": s.Misc_VirtualSize,
|
|
"rawsize": s.SizeOfRawData,
|
|
"entropy": entropy(raw),
|
|
"high_entropy": entropy(raw) > 7.0,
|
|
})
|
|
|
|
# Imports (categorized)
|
|
buckets = {k: [] for k in API_BUCKETS}
|
|
all_imports = []
|
|
dlls = []
|
|
if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"):
|
|
for entry in pe.DIRECTORY_ENTRY_IMPORT:
|
|
dll = entry.dll.decode("latin1", "replace") if entry.dll else "?"
|
|
dlls.append(dll)
|
|
for imp in entry.imports:
|
|
nm = imp.name.decode("latin1", "replace") if imp.name else f"ord_{imp.ordinal}"
|
|
all_imports.append(nm)
|
|
for bucket, needles in API_BUCKETS.items():
|
|
if any(nm.startswith(n) for n in needles):
|
|
buckets[bucket].append(nm)
|
|
out["import_dlls"] = sorted(set(dlls))
|
|
out["import_count"] = len(all_imports)
|
|
out["import_categories"] = {k: sorted(set(v)) for k, v in buckets.items() if v}
|
|
|
|
# Exports
|
|
exports = []
|
|
if hasattr(pe, "DIRECTORY_ENTRY_EXPORT"):
|
|
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
|
|
if exp.name:
|
|
exports.append(exp.name.decode("latin1", "replace"))
|
|
out["exports"] = exports[:50]
|
|
out["has_tls"] = hasattr(pe, "DIRECTORY_ENTRY_TLS")
|
|
|
|
# Authenticode signature presence (directory entry 4)
|
|
try:
|
|
sec = oh.DATA_DIRECTORY[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_SECURITY"]]
|
|
out["signed"] = sec.VirtualAddress != 0 and sec.Size != 0
|
|
except Exception:
|
|
out["signed"] = None
|
|
|
|
# Packer hint: few imports + high-entropy exec section
|
|
highent = [s for s in out["sections"] if s["high_entropy"]]
|
|
out["packer_hint"] = bool(highent) and out["import_count"] < 20
|
|
|
|
# Interesting strings (sampled from whole file)
|
|
with open(args.path, "rb") as f:
|
|
blob = f.read()
|
|
found = []
|
|
seen = set()
|
|
for s in ascii_strings(blob, args.strings_min) + [
|
|
u.replace(b"\x00", b"") for u in unicode_strings(blob, args.strings_min)
|
|
]:
|
|
if INTERESTING.search(s):
|
|
txt = s.decode("latin1", "replace")
|
|
if txt not in seen:
|
|
seen.add(txt)
|
|
found.append(txt)
|
|
if len(found) >= args.max_strings:
|
|
break
|
|
out["interesting_strings"] = found
|
|
|
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|