1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
|
#!/usr/bin/env python3
"""The findings export: gathering and, mostly, RENDERING.
`idatui.findings.render` takes plain data and returns markdown, so all of the
interesting behaviour -- grouping, sorting, what an empty section says, and
whether hostile text can break out of a table cell or a code fence -- is
testable with no IDA, no worker and no binary. `gather` is covered against a
fake Program that answers like the real one, including by raising.
"""
#: pure: stdlib only.
#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
NEEDS_IDA = False
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.domain import Comment, NamedItem, Struct # noqa: E402
from idatui.findings import ( # noqa: E402
Findings, default_path, from_loader, gather, is_dummy, render,
)
PASS = FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" ok {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
def sample() -> Findings:
return Findings(
binary="echo", path="/tmp/echo",
sections=[(0x1000, 0x2000, ".text"), (0x2000, 0x2100, ".data")],
n_functions=128,
comments=[
Comment(addr=0x1100, text="length is attacker controlled",
line="mov edi, [rbp+len]", func="parse", func_addr=0x1000),
Comment(addr=0x1010, text="entry", line="push rbp",
func="parse", func_addr=0x1000),
Comment(addr=0x1000, text="parses the header", whole_func=True,
func="parse", func_addr=0x1000),
Comment(addr=0x2004, text="magic", line="dd 0DEADBEEFh"),
# What the ELF loader writes into every database, through the very
# same set_cmt a person uses.
Comment(addr=0x4, text="File class: 64-bit", line="db 2",
seg="LOAD"),
],
names=[
NamedItem(addr=0x1000, name="parse", is_func=True, size=0x120,
proto="int __fastcall parse(char *)"),
NamedItem(addr=0x1200, name="sub_1200", is_func=True, size=0x30),
NamedItem(addr=0x2004, name="hdr_magic", seg=".data"),
NamedItem(addr=0x1400, name="memcpy", is_func=True, size=0x40),
NamedItem(addr=0x390, name="elf_gnu_hash_nbuckets", seg="LOAD"),
],
types=[(Struct(name="hdr", size=0x10, is_union=False, members=3,
ordinal=42), "struct hdr\n{\n int magic;\n};\n"),
(Struct(name="Elf64_Dyn", size=0x10, is_union=False, members=2,
ordinal=3), "struct Elf64_Dyn\n{\n int d_tag;\n};\n")],
linked={"memcpy"},
stripped=True,
)
def main() -> int:
doc = render(sample())
check("the report names the binary", doc.startswith("# Findings — echo"), doc[:40])
# 1 function, not 3: sub_1200 is IDA's invention and memcpy is the linker's.
check("the summary counts only what a person contributed",
"1 named functions · 1 named data · 4 comments · 2 local types" in doc,
doc.splitlines()[2] if len(doc.splitlines()) > 2 else "")
# A name IDA invented is not a finding, and neither is one the linker gave.
check("dummy names are excluded", "sub_1200" not in doc)
check("imported names are excluded", "`memcpy`" not in doc)
check("real names survive", "`parse`" in doc and "`hdr_magic`" in doc)
# The loader annotates every database it makes; none of it is a finding.
check("the loader's own comments are left out", "File class" not in doc)
check("the loader's own names are left out", "elf_gnu_hash" not in doc)
check("but the report says how many it dropped",
"4 annotations left out as the loader's own" in doc, # 1 comment + 3 names
[l for l in doc.splitlines() if "left out" in l])
check("from_loader knows both shapes",
from_loader("LOAD") and from_loader("", "elf_gnu_hash_x")
and not from_loader(".text", "parse"))
check("is_dummy knows the shapes IDA invents",
all(is_dummy(n) for n in ("sub_1234", "loc_A0", "unk_4000", "j_free"))
and not any(is_dummy(n) for n in ("parse", "sub_parse", "main", "")),
"")
# Comments lead, grouped by function, address-ordered within a group.
check("comments come before the name tables",
doc.index("## Comments") < doc.index("## Named functions"))
body = doc[doc.index("## Comments"):doc.index("## Named functions")]
check("comments are grouped under their function", "### `parse`" in body)
check("a commentless region is grouped separately",
"### outside any function" in body)
check("comments are ordered by address inside a group",
body.index("0x1010") < body.index("0x1100"))
check("a function comment says that is what it is",
"*whole function*: parses the header" in body)
check("an instruction comment carries the line it annotates",
"`mov edi, [rbp+len]`" in body)
# Types: newest ordinal first, because that is the one you just wrote.
types = doc[doc.index("## Local types"):]
check("your newest type is first",
types.index("hdr") < types.index("Elf64_Dyn"))
check("type source is fenced as C", "```c\nstruct hdr" in types)
# Escaping.
hostile = Findings(binary="x", comments=[
Comment(addr=1, text="a | b", line="mov | rax"),
], names=[NamedItem(addr=2, name="a|b")])
hdoc = render(hostile)
check("a pipe cannot break a table row", "a\\|b" in hdoc, hdoc)
check("a pipe in a comment is escaped too", "a \\| b" in hdoc)
# The empty database must still produce a document that says something.
empty = render(Findings(binary="nothing"))
check("an empty report is still a document",
empty.startswith("# Findings — nothing") and "## Comments" in empty)
check("and it says why it is empty", "Comments are the part" in empty)
check("an empty report has no dangling type section",
"## Local types" not in empty)
# Provenance must be stated, not implied. Without a journal the report is a
# scan and says so; with one it is exactly what idatui recorded doing.
scanned = render(Findings(binary="x", stripped=False,
names=[NamedItem(addr=1, name="main", is_func=True)]))
check("a scanned report admits it cannot know who wrote what",
"**source**: a scan of the database" in scanned
and "include its work as well as yours" in scanned)
check("and warns when the binary brought its own symbols",
"include ones it shipped with" in scanned)
j = sample()
j.recorded = {0x1100, 0x1000}
j.n_recorded = 7
jdoc = render(j)
check("a journalled report says so", "idatui's edit journal" in jdoc
and "7 recorded edits" in jdoc, "")
jbody = jdoc[jdoc.index("## Comments"):jdoc.index("## Named functions")]
check("and lists only the comments it recorded",
"length is attacker controlled" in jbody and "0x2004" not in jbody,
jbody)
check("a journalled report drops names it did not record",
"`parse`" in jdoc and "hdr_magic" not in jdoc)
# -- gather ------------------------------------------------------------- #
class FakeProgram:
def sections(self):
return [(0x1000, 0x2000, ".text")]
def annotations(self, limit=4000):
return ([Comment(addr=1, text="hi")],
[NamedItem(addr=1, name="parse", is_func=True)])
def linkage(self):
return ([], [])
def functions(self):
raise RuntimeError("index unavailable")
def list_structs(self):
raise RuntimeError("no types")
def struct_source(self, name):
return ""
f = gather(FakeProgram(), "/tmp/echo")
check("gather reads the annotations", len(f.comments) == 1 and len(f.names) == 1)
check("gather takes the binary name from the path", f.binary == "echo", f.binary)
check("a failing backend degrades the report instead of raising",
f.n_functions == 0 and f.types == [] and "# Findings" in render(f))
check("the default path sits beside the binary",
default_path("/tmp/echo") == "/tmp/echo.findings.md")
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
raise SystemExit(main())
|