elfpeek 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
elfpeek/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """elfpeek: render an ELF/PE binary as a structural map."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .model import ElfFile, PEFile
6
+ from .parse import parse
7
+ from .pe import parse_pe
8
+
9
+ __version__ = "0.2.0"
10
+ __all__ = ["ElfFile", "PEFile", "__version__", "parse", "parse_pe"]
elfpeek/checks.py ADDED
@@ -0,0 +1,179 @@
1
+ """Security feature detection, entropy and string extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+
8
+ from .model import ElfFile
9
+
10
+ __all__ = [
11
+ "SecurityFeature",
12
+ "StringHit",
13
+ "extract_strings",
14
+ "section_entropy",
15
+ "security_features",
16
+ ]
17
+
18
+ ET_DYN = 3
19
+ PT_GNU_STACK = 0x6474E551
20
+ PT_GNU_RELRO = 0x6474E552
21
+ DF_BIND_NOW = 0x8
22
+ DF_1_NOW = 0x1
23
+ MIN_STRING_LEN = 4
24
+
25
+ CANARY_SYMBOL = "__stack_chk_fail"
26
+ FORTIFY_RE = re.compile(r"__(?:\w+)_chk$")
27
+ URL_RE = re.compile(r"https?://[\w./%\-?=&#:+]+")
28
+ PATH_RE = re.compile(r"(?:/[a-zA-Z0-9._\-]+){2,}")
29
+ IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
30
+
31
+
32
+ class SecurityFeature:
33
+ """One checksec-style property."""
34
+
35
+ __slots__ = ("detail", "name", "status")
36
+
37
+ def __init__(self, name: str, status: str, detail: str = "") -> None:
38
+ # status: "yes" / "no" / "unknown"
39
+ self.name = name
40
+ self.status = status
41
+ self.detail = detail
42
+
43
+
44
+ class StringHit:
45
+ """One extracted printable string with a best-effort category."""
46
+
47
+ __slots__ = ("category", "offset", "value")
48
+
49
+ def __init__(self, value: str, category: str, offset: int) -> None:
50
+ self.value = value
51
+ self.category = category
52
+ self.offset = offset
53
+
54
+
55
+ def security_features(elf: ElfFile) -> list[SecurityFeature]:
56
+ """Return checksec-style hardening properties for an ELF binary."""
57
+ features: list[SecurityFeature] = []
58
+
59
+ is_pie = elf.type == ET_DYN
60
+ features.append(
61
+ SecurityFeature(
62
+ "PIE",
63
+ "yes" if is_pie else "no",
64
+ "ET_DYN,加载基址随机化" if is_pie else "ET_EXEC,固定基址",
65
+ )
66
+ )
67
+
68
+ stack = next((seg for seg in elf.segments if seg.type == PT_GNU_STACK), None)
69
+ if stack is None:
70
+ features.append(SecurityFeature("NX", "unknown", "无 GNU_STACK 段,栈权限未知"))
71
+ elif stack.flags & 0x1:
72
+ features.append(SecurityFeature("NX", "no", "GNU_STACK 含可执行位,栈可执行"))
73
+ else:
74
+ features.append(SecurityFeature("NX", "yes", "栈不可执行"))
75
+
76
+ has_relro = any(seg.type == PT_GNU_RELRO for seg in elf.segments)
77
+ bind_now = bool(elf.dt_flags & DF_BIND_NOW) or bool(elf.dt_flags_1 & DF_1_NOW)
78
+ if has_relro and bind_now:
79
+ features.append(SecurityFeature("RELRO", "full", "GNU_RELRO + BIND_NOW,完全只读重定位"))
80
+ elif has_relro:
81
+ features.append(SecurityFeature("RELRO", "partial", "GNU_RELRO,部分只读重定位"))
82
+ else:
83
+ features.append(SecurityFeature("RELRO", "no", "无 RELRO 段"))
84
+
85
+ names = {sym.name for sym in elf.symbols}
86
+ features.append(
87
+ SecurityFeature(
88
+ "Canary",
89
+ "yes" if CANARY_SYMBOL in names else "no",
90
+ "检测到栈溢出保护符号" if CANARY_SYMBOL in names else "未发现栈溢出保护符号",
91
+ )
92
+ )
93
+ fortified = [name for name in names if FORTIFY_RE.match(name)]
94
+ features.append(
95
+ SecurityFeature(
96
+ "FORTIFY",
97
+ "yes" if fortified else "no",
98
+ f"检测到 {len(fortified)} 个 _chk 加固函数" if fortified else "未发现 FORTIFY 加固符号",
99
+ )
100
+ )
101
+
102
+ rwx = [seg for seg in elf.segments if seg.flags & 0x7 == 0x7]
103
+ if rwx:
104
+ features.append(
105
+ SecurityFeature("RWX段", "warn", f"存在可读可写可执行段:{', '.join(seg.type_name for seg in rwx)}")
106
+ )
107
+ else:
108
+ features.append(SecurityFeature("RWX段", "no", "无 RWX 段"))
109
+ return features
110
+
111
+
112
+ def section_entropy(data: bytes, offset: int, size: int) -> float:
113
+ """Shannon entropy of a section's bytes (0-8 bits)."""
114
+ if size <= 0 or offset < 0 or offset >= len(data):
115
+ return 0.0
116
+ end = min(offset + size, len(data))
117
+ chunk = data[offset:end]
118
+ if not chunk:
119
+ return 0.0
120
+ counts = [0] * 256
121
+ for byte in chunk:
122
+ counts[byte] += 1
123
+ total = len(chunk)
124
+ entropy = 0.0
125
+ for count in counts:
126
+ if count:
127
+ p = count / total
128
+ entropy -= p * math.log2(p)
129
+ return entropy
130
+
131
+
132
+ def fill_entropy(obj, data: bytes) -> None:
133
+ """Compute and store entropy on every section of a parsed binary."""
134
+ for sec in obj.sections:
135
+ offset = getattr(sec, "offset", None)
136
+ if offset is None:
137
+ offset = getattr(sec, "raw_offset", 0)
138
+ size = getattr(sec, "size", None)
139
+ if size is None:
140
+ size = getattr(sec, "raw_size", 0)
141
+ if getattr(sec, "is_nobits", False) or size <= 0:
142
+ sec.entropy = 0.0
143
+ continue
144
+ sec.entropy = section_entropy(data, offset, size)
145
+
146
+
147
+ def extract_strings(data: bytes, *, min_len: int = MIN_STRING_LEN, limit: int = 200) -> list[StringHit]:
148
+ """Extract printable ASCII runs and classify URL/path/IP-like ones."""
149
+ hits: list[StringHit] = []
150
+ start = -1
151
+ n = len(data)
152
+ for i in range(n):
153
+ byte = data[i]
154
+ if 0x20 <= byte < 0x7F:
155
+ if start == -1:
156
+ start = i
157
+ else:
158
+ if start != -1 and i - start >= min_len:
159
+ _emit_string(hits, data[start:i], start)
160
+ if len(hits) >= limit:
161
+ return hits
162
+ start = -1
163
+ if start != -1 and n - start >= min_len:
164
+ _emit_string(hits, data[start:n], start)
165
+ return hits
166
+
167
+
168
+ def _emit_string(hits: list[StringHit], raw: bytes, offset: int) -> None:
169
+ value = raw.decode("ascii", "replace")
170
+ category = "other"
171
+ if URL_RE.search(value):
172
+ category = "URL"
173
+ elif IP_RE.search(value):
174
+ category = "IP"
175
+ elif PATH_RE.search(value):
176
+ category = "path"
177
+ elif value.strip().endswith(".so") or ".so." in value:
178
+ category = "library"
179
+ hits.append(StringHit(value, category, offset))
elfpeek/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """Command line entry point for elfpeek."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .checks import extract_strings, fill_entropy
12
+ from .model import PEFile
13
+ from .parse import ParseError
14
+ from .parse import parse as parse_elf
15
+ from .pe import ParseError as PEParseError
16
+ from .pe import parse_pe
17
+ from .render import render_html, render_terminal
18
+
19
+ __all__ = ["main"]
20
+
21
+
22
+ def _load(path: Path):
23
+ """Dispatch parsing by file magic and inject entropy / strings."""
24
+ data = path.read_bytes()
25
+ if len(data) >= 4 and data[:4] == b"\x7fELF":
26
+ obj = parse_elf(data, str(path))
27
+ elif len(data) >= 2 and data[:2] == b"MZ":
28
+ obj = parse_pe(data, str(path))
29
+ else:
30
+ raise ParseError("不是 ELF 或 PE 文件(魔数不匹配)")
31
+ fill_entropy(obj, data)
32
+ obj.strings = extract_strings(data)
33
+ return obj
34
+
35
+
36
+ def _to_dict(obj) -> dict:
37
+ base = {
38
+ "tool": "elfpeek",
39
+ "path": obj.path,
40
+ "size": obj.size,
41
+ "is_64": obj.is_64,
42
+ "type_name": obj.type_name,
43
+ "machine_name": obj.machine_name,
44
+ "entry": obj.entry,
45
+ "needed": list(obj.needed),
46
+ "strings": [
47
+ {"value": s.value, "category": s.category, "offset": s.offset} for s in obj.strings
48
+ ],
49
+ }
50
+ if isinstance(obj, PEFile):
51
+ base.update(
52
+ {
53
+ "format": "PE",
54
+ "image_base": obj.image_base,
55
+ "subsystem": obj.subsystem_name,
56
+ "exports": list(obj.exports),
57
+ "imports": list(obj.imports),
58
+ "sections": [
59
+ {
60
+ "name": sec.name,
61
+ "vaddr": sec.virtual_address,
62
+ "vsize": sec.virtual_size,
63
+ "raw_offset": sec.raw_offset,
64
+ "raw_size": sec.raw_size,
65
+ "flags_text": sec.flags_text,
66
+ "entropy": round(sec.entropy, 2),
67
+ }
68
+ for sec in obj.sections
69
+ ],
70
+ }
71
+ )
72
+ else:
73
+ base.update(
74
+ {
75
+ "format": "ELF",
76
+ "little_endian": obj.little_endian,
77
+ "soname": obj.soname,
78
+ "segments": [
79
+ {
80
+ "type_name": seg.type_name,
81
+ "offset": seg.offset,
82
+ "vaddr": seg.vaddr,
83
+ "filesz": seg.filesz,
84
+ "memsz": seg.memsz,
85
+ "flags_text": seg.flags_text,
86
+ }
87
+ for seg in obj.segments
88
+ ],
89
+ "sections": [
90
+ {
91
+ "name": sec.name,
92
+ "type_name": sec.type_name,
93
+ "offset": sec.offset,
94
+ "addr": sec.addr,
95
+ "size": sec.size,
96
+ "flags_text": sec.flags_text,
97
+ "entropy": round(sec.entropy, 2),
98
+ }
99
+ for sec in obj.sections
100
+ ],
101
+ "symbols": [
102
+ {"name": sym.name, "bind": sym.bind, "type": sym.type}
103
+ for sym in obj.symbols
104
+ ],
105
+ }
106
+ )
107
+ return base
108
+
109
+
110
+ def main(argv: list[str] | None = None) -> int:
111
+ parser = argparse.ArgumentParser(
112
+ prog="elfpeek",
113
+ description="把 ELF/PE 二进制解析成结构图:节区/段布局、入口、依赖、符号、安全特性与熵。",
114
+ )
115
+ parser.add_argument("file", metavar="FILE", help="待解析的 ELF 或 PE 文件路径")
116
+ parser.add_argument("--html", metavar="PATH", help="将结构报告导出为自包含 HTML")
117
+ parser.add_argument("--json", metavar="PATH", help="将解析结果导出为 JSON")
118
+ parser.add_argument("--version", action="version", version=f"elfpeek {__version__}")
119
+ args = parser.parse_args(argv)
120
+
121
+ path = Path(args.file)
122
+ try:
123
+ obj = _load(path)
124
+ except OSError as exc:
125
+ print(f"错误:无法读取文件 {path}:{exc}", file=sys.stderr)
126
+ return 2
127
+ except (ParseError, PEParseError) as exc:
128
+ print(f"错误:{exc}", file=sys.stderr)
129
+ return 1
130
+
131
+ print(render_terminal(obj))
132
+
133
+ status = 0
134
+ if args.html:
135
+ try:
136
+ Path(args.html).write_text(render_html(obj), encoding="utf-8")
137
+ print(f"HTML 报告已写入 {args.html}")
138
+ except OSError as exc:
139
+ print(f"错误:无法写入 HTML 文件 {args.html}:{exc}", file=sys.stderr)
140
+ status = 2
141
+ if args.json:
142
+ payload = json.dumps(_to_dict(obj), ensure_ascii=False, indent=2)
143
+ try:
144
+ Path(args.json).write_text(payload + "\n", encoding="utf-8")
145
+ print(f"JSON 报告已写入 {args.json}")
146
+ except OSError as exc:
147
+ print(f"错误:无法写入 JSON 文件 {args.json}:{exc}", file=sys.stderr)
148
+ status = 2
149
+ return status
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
elfpeek/model.py ADDED
@@ -0,0 +1,331 @@
1
+ """Data model for a parsed ELF binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ __all__ = [
8
+ "DT_NAMES",
9
+ "MACHINE_NAMES",
10
+ "SECTION_TYPE_NAMES",
11
+ "SEGMENT_TYPE_NAMES",
12
+ "SYMBOL_BIND_NAMES",
13
+ "SYMBOL_TYPE_NAMES",
14
+ "TYPE_NAMES",
15
+ "ElfFile",
16
+ "PEFile",
17
+ "PESection",
18
+ "Section",
19
+ "Segment",
20
+ "Symbol",
21
+ ]
22
+
23
+ TYPE_NAMES: dict[int, str] = {
24
+ 1: "可重定位",
25
+ 2: "可执行",
26
+ 3: "共享库",
27
+ 4: "核心转储",
28
+ }
29
+
30
+ SEGMENT_TYPE_NAMES: dict[int, str] = {
31
+ 0: "NULL",
32
+ 1: "LOAD",
33
+ 2: "DYNAMIC",
34
+ 3: "INTERP",
35
+ 4: "NOTE",
36
+ 5: "SHLIB",
37
+ 6: "PHDR",
38
+ 7: "TLS",
39
+ 0x6474e550: "GNU_EH_FRAME",
40
+ 0x6474e551: "GNU_STACK",
41
+ 0x6474e552: "GNU_RELRO",
42
+ 0x6474e553: "GNU_PROPERTY",
43
+ }
44
+
45
+ SECTION_TYPE_NAMES: dict[int, str] = {
46
+ 0: "NULL",
47
+ 1: "PROGBITS",
48
+ 2: "SYMTAB",
49
+ 3: "STRTAB",
50
+ 4: "RELA",
51
+ 5: "HASH",
52
+ 6: "DYNAMIC",
53
+ 7: "NOTE",
54
+ 8: "NOBITS",
55
+ 9: "REL",
56
+ 10: "SHLIB",
57
+ 11: "DYNSYM",
58
+ 14: "INIT_ARRAY",
59
+ 15: "FINI_ARRAY",
60
+ 16: "PREINIT_ARRAY",
61
+ 17: "GROUP",
62
+ 18: "SYMTAB_SHNDX",
63
+ }
64
+
65
+ DT_NAMES: dict[int, str] = {
66
+ 0: "NULL",
67
+ 1: "NEEDED",
68
+ 2: "PLTRELSZ",
69
+ 3: "PLTGOT",
70
+ 4: "HASH",
71
+ 5: "STRTAB",
72
+ 6: "SYMTAB",
73
+ 7: "RELA",
74
+ 8: "RELASZ",
75
+ 9: "RELAENT",
76
+ 10: "STRSZ",
77
+ 11: "SYMENT",
78
+ 12: "INIT",
79
+ 13: "FINI",
80
+ 14: "SONAME",
81
+ 15: "RPATH",
82
+ 16: "SYMBOLIC",
83
+ 17: "REL",
84
+ 20: "PLTREL",
85
+ 21: "DEBUG",
86
+ 23: "JMPREL",
87
+ 24: "BIND_NOW",
88
+ }
89
+
90
+ SYMBOL_BIND_NAMES: dict[int, str] = {
91
+ 0: "局部",
92
+ 1: "全局",
93
+ 2: "弱",
94
+ }
95
+
96
+ SYMBOL_TYPE_NAMES: dict[int, str] = {
97
+ 0: "未类型",
98
+ 1: "对象",
99
+ 2: "函数",
100
+ 3: "节区",
101
+ 4: "文件",
102
+ 5: "COMMON",
103
+ 6: "TLS",
104
+ }
105
+
106
+ MACHINE_NAMES: dict[int, str] = {
107
+ 0: "未指定",
108
+ 3: "x86",
109
+ 40: "ARM",
110
+ 62: "x86_64",
111
+ 183: "AArch64",
112
+ 243: "RISC-V",
113
+ }
114
+
115
+ PE_MACHINE_NAMES: dict[int, str] = {
116
+ 0x14C: "x86",
117
+ 0x8664: "x86_64",
118
+ 0xAA64: "AArch64",
119
+ 0x1C0: "ARM Thumb",
120
+ }
121
+
122
+ PE_SUBSYSTEM_NAMES: dict[int, str] = {
123
+ 1: "原生",
124
+ 2: "Windows 图形",
125
+ 3: "Windows 控制台",
126
+ 7: "POSIX",
127
+ 9: "Windows CE",
128
+ 10: "EFI 应用",
129
+ }
130
+
131
+ PE_SECTION_FLAG_TEXT = {
132
+ 0x00000020: "C", # CNT_CODE
133
+ 0x00000040: "D", # CNT_INITIALIZED_DATA
134
+ 0x00000080: "U", # CNT_UNINITIALIZED_DATA
135
+ 0x20000000: "X", # MEM_EXECUTE
136
+ 0x40000000: "R", # MEM_READ
137
+ 0x80000000: "W", # MEM_WRITE
138
+ }
139
+
140
+
141
+ @dataclass
142
+ class Section:
143
+ """One ELF section header."""
144
+
145
+ name: str
146
+ type: int
147
+ type_name: str
148
+ flags: int
149
+ addr: int
150
+ offset: int
151
+ size: int
152
+ link: int
153
+ info: int
154
+ addralign: int
155
+ entsize: int
156
+ entropy: float = 0.0
157
+
158
+ @property
159
+ def flags_text(self) -> str:
160
+ return "".join(
161
+ char
162
+ for bit, char in (
163
+ (0x1, "W"),
164
+ (0x2, "A"),
165
+ (0x4, "X"),
166
+ (0x10, "M"),
167
+ (0x20, "S"),
168
+ (0x40, "I"),
169
+ (0x80, "L"),
170
+ )
171
+ if self.flags & bit
172
+ )
173
+
174
+ @property
175
+ def is_nobits(self) -> bool:
176
+ return self.type == 8
177
+
178
+
179
+ @dataclass
180
+ class Segment:
181
+ """One ELF program header."""
182
+
183
+ type: int
184
+ type_name: str
185
+ flags: int
186
+ offset: int
187
+ vaddr: int
188
+ paddr: int
189
+ filesz: int
190
+ memsz: int
191
+ align: int
192
+
193
+ @property
194
+ def flags_text(self) -> str:
195
+ return "".join(
196
+ char
197
+ for bit, char in ((0x4, "R"), (0x2, "W"), (0x1, "X"))
198
+ if self.flags & bit
199
+ )
200
+
201
+
202
+ @dataclass
203
+ class Symbol:
204
+ """One symbol from .symtab or .dynsym."""
205
+
206
+ name: str
207
+ bind: int
208
+ type: int
209
+ shndx: int
210
+ value: int
211
+ size: int
212
+
213
+ @property
214
+ def bind_name(self) -> str:
215
+ return SYMBOL_BIND_NAMES.get(self.bind, str(self.bind))
216
+
217
+ @property
218
+ def type_name(self) -> str:
219
+ return SYMBOL_TYPE_NAMES.get(self.type, str(self.type))
220
+
221
+
222
+ @dataclass
223
+ class ElfFile:
224
+ """Parsed view of an ELF binary."""
225
+
226
+ path: str
227
+ size: int
228
+ is_64: bool
229
+ little_endian: bool
230
+ type: int
231
+ machine: int
232
+ entry: int
233
+ flags: int
234
+ segments: list[Segment] = field(default_factory=list)
235
+ sections: list[Section] = field(default_factory=list)
236
+ needed: list[str] = field(default_factory=list)
237
+ symbols: list[Symbol] = field(default_factory=list)
238
+ soname: str = ""
239
+ dt_flags: int = 0
240
+ dt_flags_1: int = 0
241
+ strings: list = field(default_factory=list)
242
+
243
+ @property
244
+ def type_name(self) -> str:
245
+ return TYPE_NAMES.get(self.type, f"0x{self.type:x}")
246
+
247
+ @property
248
+ def machine_name(self) -> str:
249
+ return MACHINE_NAMES.get(self.machine, f"0x{self.machine:x}")
250
+
251
+ @property
252
+ def dynamic_symbols(self) -> list[Symbol]:
253
+ # Symbols parsed from .dynsym carry non-empty names from .dynstr.
254
+ return [sym for sym in self.symbols if sym.name]
255
+
256
+ @property
257
+ def functions(self) -> list[Symbol]:
258
+ return [sym for sym in self.dynamic_symbols if sym.type == 2]
259
+
260
+ @property
261
+ def imported_functions(self) -> list[Symbol]:
262
+ # Undefined dynamic symbols (shndx == 0 / UNDEF) are imports.
263
+ return [sym for sym in self.functions if sym.shndx == 0]
264
+
265
+ @property
266
+ def exported_functions(self) -> list[Symbol]:
267
+ return [sym for sym in self.functions if sym.shndx != 0]
268
+
269
+
270
+ @dataclass
271
+ class PESection:
272
+ """One PE/COFF section header."""
273
+
274
+ name: str
275
+ virtual_address: int
276
+ virtual_size: int
277
+ raw_offset: int
278
+ raw_size: int
279
+ flags: int
280
+ entropy: float = 0.0
281
+
282
+ @property
283
+ def flags_text(self) -> str:
284
+ return "".join(
285
+ char for bit, char in PE_SECTION_FLAG_TEXT.items() if self.flags & bit
286
+ )
287
+
288
+ @property
289
+ def is_code(self) -> bool:
290
+ return bool(self.flags & 0x00000020)
291
+
292
+
293
+ @dataclass
294
+ class PEFile:
295
+ """Parsed view of a PE/COFF binary."""
296
+
297
+ path: str
298
+ size: int
299
+ is_64: bool
300
+ type: int # 0x2000 DLL, 0x0002 EXEC
301
+ machine: int
302
+ entry: int
303
+ image_base: int
304
+ subsystem: int
305
+ dll_characteristics: int
306
+ characteristics: int
307
+ sections: list[PESection] = field(default_factory=list)
308
+ needed: list[str] = field(default_factory=list)
309
+ exports: list[str] = field(default_factory=list)
310
+ imports: list[str] = field(default_factory=list)
311
+ strings: list = field(default_factory=list)
312
+
313
+ @property
314
+ def type_name(self) -> str:
315
+ if self.type & 0x2000:
316
+ return "动态链接库"
317
+ if self.type & 0x0002:
318
+ return "可执行"
319
+ return "未知"
320
+
321
+ @property
322
+ def machine_name(self) -> str:
323
+ return PE_MACHINE_NAMES.get(self.machine, f"0x{self.machine:x}")
324
+
325
+ @property
326
+ def subsystem_name(self) -> str:
327
+ return PE_SUBSYSTEM_NAMES.get(self.subsystem, str(self.subsystem))
328
+
329
+ @property
330
+ def little_endian(self) -> bool:
331
+ return True