picblocks 2.1.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.
- picblocks/__init__.py +0 -0
- picblocks/blockhasher.py +274 -0
- picblocks/blockhashmatcher.py +284 -0
- picblocks-2.1.0.dist-info/METADATA +125 -0
- picblocks-2.1.0.dist-info/RECORD +8 -0
- picblocks-2.1.0.dist-info/WHEEL +5 -0
- picblocks-2.1.0.dist-info/licenses/LICENSE +9 -0
- picblocks-2.1.0.dist-info/top_level.txt +1 -0
picblocks/__init__.py
ADDED
|
File without changes
|
picblocks/blockhasher.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import struct
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from smda.common.SmdaReport import SmdaFunction
|
|
10
|
+
from smda.Disassembler import Disassembler
|
|
11
|
+
from smda.intel.IntelInstructionEscaper import IntelInstructionEscaper
|
|
12
|
+
|
|
13
|
+
# Only do basicConfig if no handlers have been configured
|
|
14
|
+
if not logging.root.handlers:
|
|
15
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)-15s %(message)s")
|
|
16
|
+
LOG = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Memory dumps named like Malpedia's dump/dump7_0x<base> files, never a path
|
|
19
|
+
# segment such as /data/dumps/malware.exe.
|
|
20
|
+
_DUMP_FILENAME_RE = re.compile(r"(?:^|[^A-Za-z0-9])dump(?:7)?_0x[0-9a-fA-F]{4,16}", re.I)
|
|
21
|
+
_HEX_BASE_RE = re.compile(r"0x(?P<base_addr>[0-9a-fA-F]{1,16})", re.I)
|
|
22
|
+
_ARCH_RE = re.compile(r"(?P<bitness>(x86_64|x86-64|x86_32|x86|x64|x32|amd64|i386|i686|win32|win64|32bit|64bit))", re.I)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BlockHasher:
|
|
26
|
+
def parseBitnessFromFilename(self, filepath):
|
|
27
|
+
# try to infer bitness from filename, in case we process a mapped image / memory dump
|
|
28
|
+
# an explicit architecture tag is the only strong signal a file name carries, so it wins
|
|
29
|
+
name = os.path.basename(filepath)
|
|
30
|
+
# a base address such as 0x64000000 contains the literal "x64", so the address tokens
|
|
31
|
+
# have to go before the architecture tag is looked for, or every dump mapped at 0x64......
|
|
32
|
+
# reads as 64bit and every one at 0x32...... as 32bit
|
|
33
|
+
without_addresses = _HEX_BASE_RE.sub("", name)
|
|
34
|
+
architecture_match = _ARCH_RE.search(without_addresses)
|
|
35
|
+
if architecture_match:
|
|
36
|
+
tag = architecture_match.group("bitness").lower()
|
|
37
|
+
parsed_bitness = 64 if "64" in tag else 32
|
|
38
|
+
LOG.info("Parsed bitness from file name: %d", parsed_bitness)
|
|
39
|
+
return parsed_bitness
|
|
40
|
+
# a base address needing more than 8 hex digits cannot be 32bit, so it settles the question.
|
|
41
|
+
# the reverse does not hold - 64bit modules are regularly mapped below 4GB - and guessing
|
|
42
|
+
# 32 from a short address overrides SMDA's estimate over the actual code with a weaker one.
|
|
43
|
+
baddr_match = _HEX_BASE_RE.search(name)
|
|
44
|
+
if baddr_match and len(baddr_match.group("base_addr").lstrip("0")) > 8:
|
|
45
|
+
LOG.info("Parsed bitness from file name: %d", 64)
|
|
46
|
+
return 64
|
|
47
|
+
LOG.warning("No bitness recognized from file name, leaving detection to SMDA.")
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def parseBaseAddrFromFilename(self, filepath):
|
|
51
|
+
# try to infer base addr from filename, in case we process a mapped image / memory dump
|
|
52
|
+
name = os.path.basename(filepath)
|
|
53
|
+
baddr_match = _HEX_BASE_RE.search(name)
|
|
54
|
+
if baddr_match:
|
|
55
|
+
parsed_base_addr = int(baddr_match.group("base_addr"), 16)
|
|
56
|
+
LOG.info("Parsed base address from file name: 0x%08x %d", parsed_base_addr, parsed_base_addr)
|
|
57
|
+
return parsed_base_addr
|
|
58
|
+
LOG.warning("No base address recognized, using 0.")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
def readFileContent(self, file_path):
|
|
62
|
+
file_content = b""
|
|
63
|
+
with open(file_path, "rb") as fin:
|
|
64
|
+
file_content = fin.read()
|
|
65
|
+
return file_content
|
|
66
|
+
|
|
67
|
+
def _isMappedDumpFilename(self, filename):
|
|
68
|
+
name = os.path.basename(filename)
|
|
69
|
+
return bool(_DUMP_FILENAME_RE.search(name))
|
|
70
|
+
|
|
71
|
+
def _isUsableSmdaReport(self, smda_report):
|
|
72
|
+
if smda_report is None:
|
|
73
|
+
return False
|
|
74
|
+
if getattr(smda_report, "status", None) == "error":
|
|
75
|
+
return False
|
|
76
|
+
if getattr(smda_report, "xcfg", None) is None:
|
|
77
|
+
return False
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
def _logSmdaReport(self, smda_report, source):
|
|
81
|
+
if self._isUsableSmdaReport(smda_report):
|
|
82
|
+
LOG.info(smda_report)
|
|
83
|
+
return
|
|
84
|
+
LOG.warning(
|
|
85
|
+
"SMDA analysis failed for %s: %s",
|
|
86
|
+
source,
|
|
87
|
+
getattr(smda_report, "message", "error"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _getInstructionEscaper(self, block):
|
|
91
|
+
"""Prefer the architecture-specific SMDA escaper; fall back to Intel on older SMDA."""
|
|
92
|
+
smda_function = getattr(block, "smda_function", None)
|
|
93
|
+
if smda_function is not None:
|
|
94
|
+
escaper = getattr(smda_function, "_escaper", None)
|
|
95
|
+
if escaper is not None:
|
|
96
|
+
return escaper
|
|
97
|
+
report = getattr(smda_function, "smda_report", None)
|
|
98
|
+
if report is not None:
|
|
99
|
+
getter = getattr(report, "getInstructionEscaper", None)
|
|
100
|
+
if callable(getter):
|
|
101
|
+
resolved = getter()
|
|
102
|
+
if resolved is not None:
|
|
103
|
+
return resolved
|
|
104
|
+
getter = getattr(type(smda_function), "getInstructionEscaper", None)
|
|
105
|
+
if callable(getter):
|
|
106
|
+
architecture = getattr(smda_function, "architecture", None)
|
|
107
|
+
if architecture is None and report is not None:
|
|
108
|
+
architecture = getattr(report, "architecture", None)
|
|
109
|
+
try:
|
|
110
|
+
resolved = getter(architecture)
|
|
111
|
+
except TypeError:
|
|
112
|
+
resolved = getter()
|
|
113
|
+
if resolved is not None:
|
|
114
|
+
return resolved
|
|
115
|
+
return IntelInstructionEscaper
|
|
116
|
+
|
|
117
|
+
def processBuffer(self, buffer, filename, bitness=None, baseaddress=None):
|
|
118
|
+
LOG.info(f"now analyzing {filename}")
|
|
119
|
+
DISASSEMBLER = Disassembler()
|
|
120
|
+
name = os.path.basename(filename)
|
|
121
|
+
# baseaddress=0 is a valid mapped base and must not be treated as "unset"
|
|
122
|
+
if self._isMappedDumpFilename(name) or "_0x" in name or baseaddress is not None:
|
|
123
|
+
BASE_ADDR = baseaddress if baseaddress is not None else self.parseBaseAddrFromFilename(filename)
|
|
124
|
+
BITNESS = bitness if bitness is not None else self.parseBitnessFromFilename(filename)
|
|
125
|
+
SMDA_REPORT = DISASSEMBLER.disassembleBuffer(buffer, BASE_ADDR, BITNESS)
|
|
126
|
+
else:
|
|
127
|
+
SMDA_REPORT = DISASSEMBLER.disassembleUnmappedBuffer(buffer)
|
|
128
|
+
SMDA_REPORT.filename = os.path.basename(filename)
|
|
129
|
+
self._logSmdaReport(SMDA_REPORT, filename)
|
|
130
|
+
blockhash_report = self.extractBlockhashes(SMDA_REPORT)
|
|
131
|
+
LOG.info("hashes extracted.")
|
|
132
|
+
return blockhash_report
|
|
133
|
+
|
|
134
|
+
def processFile(self, filepath):
|
|
135
|
+
LOG.info(f"now analyzing {filepath}")
|
|
136
|
+
INPUT_FILENAME = os.path.basename(filepath)
|
|
137
|
+
DISASSEMBLER = Disassembler()
|
|
138
|
+
if self._isMappedDumpFilename(filepath):
|
|
139
|
+
BUFFER = self.readFileContent(filepath)
|
|
140
|
+
BASE_ADDR = self.parseBaseAddrFromFilename(INPUT_FILENAME)
|
|
141
|
+
BITNESS = self.parseBitnessFromFilename(INPUT_FILENAME)
|
|
142
|
+
SMDA_REPORT = DISASSEMBLER.disassembleBuffer(BUFFER, BASE_ADDR, BITNESS)
|
|
143
|
+
else:
|
|
144
|
+
SMDA_REPORT = DISASSEMBLER.disassembleFile(filepath)
|
|
145
|
+
SMDA_REPORT.filename = os.path.basename(INPUT_FILENAME)
|
|
146
|
+
self._logSmdaReport(SMDA_REPORT, filepath)
|
|
147
|
+
blockhash_report = self.extractBlockhashes(SMDA_REPORT)
|
|
148
|
+
LOG.info("hashes extracted.")
|
|
149
|
+
return blockhash_report
|
|
150
|
+
|
|
151
|
+
def processSmda(self, smda_report):
|
|
152
|
+
blockhash_report = self.extractBlockhashes(smda_report)
|
|
153
|
+
return blockhash_report
|
|
154
|
+
|
|
155
|
+
def calculateBlockhash(self, block, lower_addr, upper_addr, hash_size=4):
|
|
156
|
+
escaper = self._getInstructionEscaper(block)
|
|
157
|
+
escaped_binary_seq = []
|
|
158
|
+
for instruction in block.getInstructions():
|
|
159
|
+
escaped = instruction.getEscapedBinary(
|
|
160
|
+
escaper,
|
|
161
|
+
escape_intraprocedural_jumps=True,
|
|
162
|
+
lower_addr=lower_addr,
|
|
163
|
+
upper_addr=upper_addr,
|
|
164
|
+
)
|
|
165
|
+
if escaped:
|
|
166
|
+
escaped_binary_seq.append(escaped)
|
|
167
|
+
as_bytes = "".join(escaped_binary_seq).encode("ascii")
|
|
168
|
+
digest = hashlib.sha256(as_bytes).digest()
|
|
169
|
+
if hash_size == 8:
|
|
170
|
+
return struct.unpack("<Q", digest[:8])[0]
|
|
171
|
+
return struct.unpack("<I", digest[:4])[0]
|
|
172
|
+
|
|
173
|
+
def getBlockhashesForFunction(
|
|
174
|
+
self, smda_function: "SmdaFunction", image_lower: int, image_upper: int, min_block_size=4, hash_size=4
|
|
175
|
+
):
|
|
176
|
+
blockhashes: dict = {}
|
|
177
|
+
for block in smda_function.getBlocks():
|
|
178
|
+
block_len = getattr(block, "length", 0) or 0
|
|
179
|
+
if block_len >= min_block_size:
|
|
180
|
+
block_size = sum(len(ins.bytes) // 2 for ins in block.getInstructions() if ins.bytes)
|
|
181
|
+
block_hash = self.calculateBlockhash(
|
|
182
|
+
block, lower_addr=image_lower, upper_addr=image_upper, hash_size=hash_size
|
|
183
|
+
)
|
|
184
|
+
offset_tuple = {
|
|
185
|
+
"offset": block.offset,
|
|
186
|
+
"length": block.length,
|
|
187
|
+
"size": block_size,
|
|
188
|
+
}
|
|
189
|
+
if block_hash not in blockhashes:
|
|
190
|
+
blockhashes[block_hash] = {
|
|
191
|
+
"hash": block_hash,
|
|
192
|
+
"count": 1,
|
|
193
|
+
"offset_tuples": [offset_tuple],
|
|
194
|
+
"size": block_size,
|
|
195
|
+
}
|
|
196
|
+
else:
|
|
197
|
+
blockhashes[block_hash]["offset_tuples"].append(offset_tuple)
|
|
198
|
+
blockhashes[block_hash]["count"] += 1
|
|
199
|
+
return list(blockhashes.values())
|
|
200
|
+
|
|
201
|
+
def extractBlockhashes(self, smda_report, min_block_size=4):
|
|
202
|
+
family = getattr(smda_report, "family", None)
|
|
203
|
+
if family is None:
|
|
204
|
+
family = ""
|
|
205
|
+
output = {
|
|
206
|
+
"family": family,
|
|
207
|
+
"version": smda_report.version,
|
|
208
|
+
"bitness": smda_report.bitness,
|
|
209
|
+
"sha256": smda_report.sha256,
|
|
210
|
+
"filename": smda_report.filename,
|
|
211
|
+
"filesize": smda_report.binary_size,
|
|
212
|
+
"is_library": smda_report.is_library,
|
|
213
|
+
"min_block_size": min_block_size,
|
|
214
|
+
"num_hashes": 0,
|
|
215
|
+
"num_functions": 0,
|
|
216
|
+
"num_functions_hashed": 0,
|
|
217
|
+
"num_blocks": 0,
|
|
218
|
+
"num_all_blocks": 0,
|
|
219
|
+
"block_bytes": 0,
|
|
220
|
+
"blockhashes": {},
|
|
221
|
+
}
|
|
222
|
+
if not self._isUsableSmdaReport(smda_report):
|
|
223
|
+
return output
|
|
224
|
+
blockhashes = {}
|
|
225
|
+
image_lower = smda_report.base_addr or 0
|
|
226
|
+
image_upper = image_lower + (smda_report.binary_size or 0)
|
|
227
|
+
function_id = 0
|
|
228
|
+
num_all_blocks = 0
|
|
229
|
+
num_blocks = 0
|
|
230
|
+
num_functions = 0
|
|
231
|
+
num_functions_hashed = 0
|
|
232
|
+
for function in smda_report.getFunctions():
|
|
233
|
+
num_functions += 1
|
|
234
|
+
function_hashed = False
|
|
235
|
+
for block in function.getBlocks():
|
|
236
|
+
num_all_blocks += 1
|
|
237
|
+
if block.length >= min_block_size:
|
|
238
|
+
num_blocks += 1
|
|
239
|
+
function_hashed = True
|
|
240
|
+
block_size = sum([len(ins.bytes) // 2 for ins in block.getInstructions()])
|
|
241
|
+
block_hash = self.calculateBlockhash(block, lower_addr=image_lower, upper_addr=image_upper)
|
|
242
|
+
if block_hash not in blockhashes:
|
|
243
|
+
blockhashes[block_hash] = {}
|
|
244
|
+
if block_size not in blockhashes[block_hash]:
|
|
245
|
+
blockhashes[block_hash][block_size] = set()
|
|
246
|
+
blockhashes[block_hash][block_size].add(function_id)
|
|
247
|
+
output["block_bytes"] += block_size
|
|
248
|
+
if function_hashed:
|
|
249
|
+
num_functions_hashed += 1
|
|
250
|
+
function_id += 1
|
|
251
|
+
num_hashes = 0
|
|
252
|
+
for blockhash, by_size in blockhashes.items():
|
|
253
|
+
for size, offsets in by_size.items():
|
|
254
|
+
num_hashes += 1
|
|
255
|
+
by_size[size] = sorted(list(offsets))
|
|
256
|
+
output["num_functions"] = num_functions
|
|
257
|
+
output["num_functions_hashed"] = num_functions_hashed
|
|
258
|
+
output["num_blocks"] = num_blocks
|
|
259
|
+
output["num_all_blocks"] = num_all_blocks
|
|
260
|
+
output["num_hashes"] = num_hashes
|
|
261
|
+
output["blockhashes"] = blockhashes
|
|
262
|
+
return output
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
if len(sys.argv) < 2:
|
|
267
|
+
print(f"usage: {sys.argv[0]} <target_binary_path>")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
if os.path.isfile(sys.argv[1]):
|
|
270
|
+
INPUT_FILENAME = os.path.basename(sys.argv[1])
|
|
271
|
+
hasher = BlockHasher()
|
|
272
|
+
blockhash_report = hasher.processFile(sys.argv[1])
|
|
273
|
+
with open(INPUT_FILENAME + ".blocks", "w") as fout:
|
|
274
|
+
json.dump(blockhash_report, fout, indent=1, sort_keys=True)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
# optionally use tqdm to render progress (should not be a package requirement)
|
|
11
|
+
import tqdm
|
|
12
|
+
except Exception:
|
|
13
|
+
tqdm = None
|
|
14
|
+
|
|
15
|
+
from .blockhasher import BlockHasher
|
|
16
|
+
|
|
17
|
+
# Only do basicConfig if no handlers have been configured
|
|
18
|
+
if not logging.root.handlers:
|
|
19
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)-15s %(message)s")
|
|
20
|
+
LOG = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _utc_timestamp():
|
|
24
|
+
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _percent(part, total):
|
|
28
|
+
if not total:
|
|
29
|
+
return 0.0
|
|
30
|
+
return 100.0 * part / total
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BlockHashMatcher:
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.db_timestamp = _utc_timestamp()
|
|
36
|
+
self.blockhashes = {}
|
|
37
|
+
self.family_to_id = {}
|
|
38
|
+
self.family_id_to_family = {}
|
|
39
|
+
self.sample_id_to_sample = {}
|
|
40
|
+
|
|
41
|
+
def load(self, filepath):
|
|
42
|
+
"""load a single blockhash report"""
|
|
43
|
+
with open(filepath) as fin:
|
|
44
|
+
blockhash_report = json.load(fin)
|
|
45
|
+
family = blockhash_report.get("family")
|
|
46
|
+
if family is None:
|
|
47
|
+
family = ""
|
|
48
|
+
if family not in self.family_to_id:
|
|
49
|
+
family_id = len(self.family_to_id)
|
|
50
|
+
self.family_to_id[family] = family_id
|
|
51
|
+
self.family_id_to_family[family_id] = family
|
|
52
|
+
family_id = self.family_to_id[family]
|
|
53
|
+
sample_id = len(self.sample_id_to_sample)
|
|
54
|
+
filename = blockhash_report.get("filename") or os.path.basename(filepath)
|
|
55
|
+
self.sample_id_to_sample[sample_id] = filename
|
|
56
|
+
for blockhash, data in (blockhash_report.get("blockhashes") or {}).items():
|
|
57
|
+
int_hash = int(blockhash)
|
|
58
|
+
if int_hash not in self.blockhashes:
|
|
59
|
+
self.blockhashes[int_hash] = {}
|
|
60
|
+
for size, fids in data.items():
|
|
61
|
+
int_size = int(size)
|
|
62
|
+
if int_size not in self.blockhashes[int_hash]:
|
|
63
|
+
self.blockhashes[int_hash][int_size] = []
|
|
64
|
+
for fid in fids:
|
|
65
|
+
is_library = False if "is_library" not in blockhash_report else blockhash_report["is_library"]
|
|
66
|
+
self.blockhashes[int_hash][int_size].append((family_id, sample_id, fid, is_library))
|
|
67
|
+
|
|
68
|
+
def loadDb(self, filepath):
|
|
69
|
+
"""load a previously processed database of blockhashes"""
|
|
70
|
+
with open(filepath) as fin:
|
|
71
|
+
blockhash_db = json.load(fin)
|
|
72
|
+
self.db_timestamp = blockhash_db["timestamp"]
|
|
73
|
+
self.family_to_id = blockhash_db["family_to_id"]
|
|
74
|
+
self.family_id_to_family = {int(k): v for k, v in blockhash_db["family_id_to_family"].items()}
|
|
75
|
+
self.sample_id_to_sample = {int(k): v for k, v in blockhash_db["sample_id_to_sample"].items()}
|
|
76
|
+
self.blockhashes = {
|
|
77
|
+
int(k): {int(ki): vi for ki, vi in v.items()} for k, v in blockhash_db["blockhashes"].items()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def saveDb(self, filepath):
|
|
81
|
+
"""save the current database of blockhashes"""
|
|
82
|
+
parent_dir = os.path.dirname(filepath)
|
|
83
|
+
if parent_dir:
|
|
84
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
85
|
+
with open(filepath, "w") as fout:
|
|
86
|
+
json_db = {
|
|
87
|
+
"timestamp": _utc_timestamp(),
|
|
88
|
+
"family_to_id": self.family_to_id,
|
|
89
|
+
"family_id_to_family": self.family_id_to_family,
|
|
90
|
+
"sample_id_to_sample": self.sample_id_to_sample,
|
|
91
|
+
"blockhashes": self.blockhashes,
|
|
92
|
+
}
|
|
93
|
+
json.dump(json_db, fout)
|
|
94
|
+
|
|
95
|
+
def getDbStats(self):
|
|
96
|
+
"""return statistics for currently loaded DB"""
|
|
97
|
+
family_ids = set()
|
|
98
|
+
library_ids = set()
|
|
99
|
+
function_ids = set()
|
|
100
|
+
num_hashes = 0
|
|
101
|
+
num_hash_and_sizes = 0
|
|
102
|
+
num_bytes = 0
|
|
103
|
+
num_bytes_unique = 0
|
|
104
|
+
hash_size_counts = Counter()
|
|
105
|
+
for block_hash, sizes in self.blockhashes.items():
|
|
106
|
+
num_hashes += 1
|
|
107
|
+
hash_size_counts[len(sizes)] += 1
|
|
108
|
+
for size, entries in sizes.items():
|
|
109
|
+
num_hash_and_sizes += 1
|
|
110
|
+
num_bytes_unique += size
|
|
111
|
+
for entry in entries:
|
|
112
|
+
family_id, sample_id, fid, is_library = entry
|
|
113
|
+
function_ids.add(f"{sample_id}.{fid}")
|
|
114
|
+
num_bytes += size
|
|
115
|
+
if is_library:
|
|
116
|
+
library_ids.add(family_id)
|
|
117
|
+
else:
|
|
118
|
+
family_ids.add(family_id)
|
|
119
|
+
return {
|
|
120
|
+
"num_families": len(family_ids),
|
|
121
|
+
"num_libraries": len(library_ids),
|
|
122
|
+
"num_files": len(self.sample_id_to_sample),
|
|
123
|
+
"num_functions": len(function_ids),
|
|
124
|
+
"num_hashes": num_hashes,
|
|
125
|
+
"num_hash_and_sizes": num_hash_and_sizes,
|
|
126
|
+
"num_bytes": num_bytes,
|
|
127
|
+
"num_bytes_unique": num_bytes_unique,
|
|
128
|
+
"hash_size_counts": dict(hash_size_counts),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def match(self, blockhash_report):
|
|
132
|
+
"""match a blockhash report against the database"""
|
|
133
|
+
block_bytes = blockhash_report.get("block_bytes") or 0
|
|
134
|
+
match_report = {
|
|
135
|
+
"num_families": len(self.family_to_id),
|
|
136
|
+
"num_samples": len(self.sample_id_to_sample),
|
|
137
|
+
"num_blockhashes": len(self.blockhashes),
|
|
138
|
+
"bitness": blockhash_report.get("bitness"),
|
|
139
|
+
"sha256": blockhash_report.get("sha256"),
|
|
140
|
+
"input_filename": blockhash_report.get("filename"),
|
|
141
|
+
"input_block_bytes": block_bytes,
|
|
142
|
+
"input_block_hashes": len(blockhash_report.get("blockhashes") or {}),
|
|
143
|
+
"unmatched_score": 0,
|
|
144
|
+
"unmatched_hashes": 0,
|
|
145
|
+
"unmatched_blocks": 0,
|
|
146
|
+
"family_matches": [],
|
|
147
|
+
}
|
|
148
|
+
LOG.debug(
|
|
149
|
+
f"Using {len(self.family_to_id)} families, {len(self.sample_id_to_sample)} samples with {len(self.blockhashes)} hashes for matching."
|
|
150
|
+
)
|
|
151
|
+
sample_matches = defaultdict(int)
|
|
152
|
+
# bytes
|
|
153
|
+
family_bytes = defaultdict(int)
|
|
154
|
+
non_library_bytes = defaultdict(int)
|
|
155
|
+
adj_family_bytes = defaultdict(float)
|
|
156
|
+
unique_family_bytes = defaultdict(int)
|
|
157
|
+
# blocks
|
|
158
|
+
family_blocks = defaultdict(int)
|
|
159
|
+
non_library_blocks = defaultdict(int)
|
|
160
|
+
adj_family_blocks = defaultdict(float)
|
|
161
|
+
unique_family_blocks = defaultdict(int)
|
|
162
|
+
unmatched_score = 0
|
|
163
|
+
unmatched_blocks = 0
|
|
164
|
+
unmatched_hashes = 0
|
|
165
|
+
for blockhash, data in (blockhash_report.get("blockhashes") or {}).items():
|
|
166
|
+
int_hash = int(blockhash)
|
|
167
|
+
for size, fids in data.items():
|
|
168
|
+
int_size = int(size)
|
|
169
|
+
n_instances = len(fids)
|
|
170
|
+
db_entries = None
|
|
171
|
+
if int_hash in self.blockhashes and int_size in self.blockhashes[int_hash]:
|
|
172
|
+
db_entries = self.blockhashes[int_hash][int_size]
|
|
173
|
+
if not db_entries:
|
|
174
|
+
unmatched_hashes += 1
|
|
175
|
+
unmatched_score += int_size * n_instances
|
|
176
|
+
unmatched_blocks += n_instances
|
|
177
|
+
continue
|
|
178
|
+
families = set(entry[0] for entry in db_entries)
|
|
179
|
+
has_library = any(entry[3] for entry in db_entries)
|
|
180
|
+
family_adjustment_value = 1 if len(families) < 3 else 1 + int(math.log(len(families), 2))
|
|
181
|
+
# once per query function id, matching how reports store fids
|
|
182
|
+
for _fid in fids:
|
|
183
|
+
credited_families = set()
|
|
184
|
+
credited_samples = set()
|
|
185
|
+
for entry in db_entries:
|
|
186
|
+
family_id, sample_id, fid, is_library = entry
|
|
187
|
+
if family_id not in credited_families:
|
|
188
|
+
credited_families.add(family_id)
|
|
189
|
+
family_bytes[family_id] += int_size
|
|
190
|
+
family_blocks[family_id] += 1
|
|
191
|
+
if not has_library:
|
|
192
|
+
non_library_bytes[family_id] += int_size
|
|
193
|
+
non_library_blocks[family_id] += 1
|
|
194
|
+
adj_family_bytes[family_id] += int_size / family_adjustment_value
|
|
195
|
+
adj_family_blocks[family_id] += 1 / family_adjustment_value
|
|
196
|
+
if len(families) == 1:
|
|
197
|
+
unique_family_bytes[family_id] += int_size
|
|
198
|
+
unique_family_blocks[family_id] += 1
|
|
199
|
+
else:
|
|
200
|
+
# TODO we could collect the function names of functions we potentially recognize here.
|
|
201
|
+
pass
|
|
202
|
+
if sample_id not in credited_samples:
|
|
203
|
+
credited_samples.add(sample_id)
|
|
204
|
+
sample_matches[sample_id] += int_size
|
|
205
|
+
match_report["unmatched_score"] = unmatched_score
|
|
206
|
+
match_report["unmatched_blocks"] = unmatched_blocks
|
|
207
|
+
match_report["unmatched_hashes"] = unmatched_hashes
|
|
208
|
+
LOG.debug(
|
|
209
|
+
f"Input: {blockhash_report.get('filename')} "
|
|
210
|
+
f"({blockhash_report.get('family')}/{blockhash_report.get('version')}) - {block_bytes:,d} bytes."
|
|
211
|
+
)
|
|
212
|
+
LOG.debug(f"Unmatched blocks: {unmatched_blocks:,d}, {unmatched_score:,d} bytes.")
|
|
213
|
+
LOG.debug("Family matches: ")
|
|
214
|
+
index = 1
|
|
215
|
+
LOG.debug("*" * 93)
|
|
216
|
+
LOG.debug(
|
|
217
|
+
f"{'#':>2}: {'id':>5} | {'family':>30} | {'bytescore':>9} | {'%':>6} | {'nolib%':>6} | {'adj%':>6} | {'uniq%':>6}"
|
|
218
|
+
)
|
|
219
|
+
for family_id, direct_bytes in sorted(family_bytes.items(), key=lambda x: x[1], reverse=True):
|
|
220
|
+
nonlib_bytes = non_library_bytes[family_id]
|
|
221
|
+
adj_bytes = adj_family_bytes[family_id]
|
|
222
|
+
unique_bytes = unique_family_bytes[family_id]
|
|
223
|
+
family_result = {
|
|
224
|
+
"index": index,
|
|
225
|
+
"family": self.family_id_to_family[family_id],
|
|
226
|
+
"direct_bytes": direct_bytes,
|
|
227
|
+
"direct_blocks": family_blocks[family_id],
|
|
228
|
+
"direct_perc": _percent(direct_bytes, block_bytes),
|
|
229
|
+
"nonlib_bytes": int(nonlib_bytes),
|
|
230
|
+
"nonlib_blocks": non_library_blocks[family_id],
|
|
231
|
+
"nonlib_perc": _percent(nonlib_bytes, block_bytes),
|
|
232
|
+
"freq_bytes": int(adj_bytes),
|
|
233
|
+
"freq_blocks": adj_family_blocks[family_id],
|
|
234
|
+
"freq_perc": _percent(adj_bytes, block_bytes),
|
|
235
|
+
"uniq_bytes": int(unique_bytes),
|
|
236
|
+
"uniq_blocks": unique_family_blocks[family_id],
|
|
237
|
+
"uniq_perc": _percent(unique_bytes, block_bytes),
|
|
238
|
+
}
|
|
239
|
+
match_report["family_matches"].append(family_result)
|
|
240
|
+
if index < 20 or unique_bytes > 0:
|
|
241
|
+
LOG.debug(
|
|
242
|
+
f"{index:>5,d}: {family_id:>5,d} | {self.family_id_to_family[family_id]:>30} | "
|
|
243
|
+
f"{direct_bytes:>9,d} | {_percent(direct_bytes, block_bytes):>6.2f} | "
|
|
244
|
+
f"{_percent(nonlib_bytes, block_bytes):>6.2f} | {_percent(adj_bytes, block_bytes):>6.2f} | "
|
|
245
|
+
f"{_percent(unique_bytes, block_bytes):>6.2f}"
|
|
246
|
+
)
|
|
247
|
+
index += 1
|
|
248
|
+
LOG.debug("*" * 93)
|
|
249
|
+
return match_report
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
if len(sys.argv) < 2:
|
|
254
|
+
print(f"usage: {sys.argv[0]} <block_files_path> <optional:target_binary_path>")
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
blocks_path = sys.argv[1]
|
|
257
|
+
target = sys.argv[2] if len(sys.argv) > 2 else None
|
|
258
|
+
hasher = BlockHasher()
|
|
259
|
+
matcher = BlockHashMatcher()
|
|
260
|
+
if target is not None and os.path.isfile(target):
|
|
261
|
+
if os.path.exists("db/picblocksdb.json"):
|
|
262
|
+
print("Loading cached DB: db/picblocksdb.json")
|
|
263
|
+
matcher.loadDb("db/picblocksdb.json")
|
|
264
|
+
else:
|
|
265
|
+
print("No cached DB found, aggregating blockhash reports...")
|
|
266
|
+
dir_iter = tqdm.tqdm(os.listdir(blocks_path)) if tqdm is not None else os.listdir(blocks_path)
|
|
267
|
+
for filename in dir_iter:
|
|
268
|
+
if filename.endswith(".blocks"):
|
|
269
|
+
matcher.load(blocks_path + os.sep + filename)
|
|
270
|
+
print("saving DB...")
|
|
271
|
+
matcher.saveDb("db/picblocksdb.json")
|
|
272
|
+
blockhash_report = hasher.processFile(target)
|
|
273
|
+
print(
|
|
274
|
+
f"#> hashed input file: {blockhash_report['num_hashes']} hashes covering {blockhash_report['block_bytes']} bytes."
|
|
275
|
+
)
|
|
276
|
+
matcher.match(blockhash_report)
|
|
277
|
+
else:
|
|
278
|
+
print("Aggregating blockhash reports to create a new DB...")
|
|
279
|
+
dir_iter = tqdm.tqdm(os.listdir(blocks_path)) if tqdm is not None else os.listdir(blocks_path)
|
|
280
|
+
for filename in dir_iter:
|
|
281
|
+
if filename.endswith(".blocks"):
|
|
282
|
+
matcher.load(blocks_path + os.sep + filename)
|
|
283
|
+
print("saving DB...")
|
|
284
|
+
matcher.saveDb("db/picblocksdb.json")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: picblocks
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: A library for code similarity estimation using PIC hashing over basic blocks.
|
|
5
|
+
Author-email: Daniel Plohmann <daniel.plohmann@mailbox.org>
|
|
6
|
+
License-Expression: BSD-2-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/danielplohmann/picblocks
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Software Development :: Disassemblers
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: smda>=4.2.13
|
|
20
|
+
Provides-Extra: web
|
|
21
|
+
Requires-Dist: flask; extra == "web"
|
|
22
|
+
Requires-Dist: werkzeug; extra == "web"
|
|
23
|
+
Requires-Dist: waitress; extra == "web"
|
|
24
|
+
Requires-Dist: pymongo; extra == "web"
|
|
25
|
+
Requires-Dist: tqdm; extra == "web"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest; extra == "dev"
|
|
29
|
+
Requires-Dist: requests; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff; extra == "dev"
|
|
31
|
+
Requires-Dist: twine; extra == "dev"
|
|
32
|
+
Requires-Dist: ty; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# PicBlocks
|
|
36
|
+
|
|
37
|
+
An experimental project using position-independent code hashing over basic blocks for code similarity estimation.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Both module files in `./picblocks` and in `./utils` are runnable and contain examples of their usage:
|
|
42
|
+
|
|
43
|
+
* `$ python -m picblocks.blockhasher <target_binary_path>` - produces a `block-report` for a single binary.
|
|
44
|
+
* `$ python -m picblocks.blockhashmatcher <block_reports_path>` - creates a new `./db/picblocksdb.json` from the `block-reports` located in `<block_reports_path>`
|
|
45
|
+
* `$ python -m picblocks.blockhashmatcher <block_reports_path> <target_binary_path>` - matches a binary against data stored in `./db/picblocksdb.json` if it exists, or otherwise creates `./db/picblocksdb.json` from the `block-reports` located in `<block_reports_path>`
|
|
46
|
+
* `$ python -m utils.import_picblocksdb_to_mongo` assumes some mongodb configurations (please check inside the file to adapt to yours) it merely takes the json generated DB into a most easy to manage (and query) mongodb.
|
|
47
|
+
* `$ python -m utils.make_stats` it assumes a mongodb connection (please check inside the file to adapt to yours), the generated json db into `db/picblocksdb.json` (you can change it directly in the relative varible) and the generated blocks report into `./block-reports/` folder. It builds up some statistics about detections and DB composition. The results would be available in a dedicated (and very simple) stats web ui. Without MongoDB it writes `db/stats.json` instead.
|
|
48
|
+
|
|
49
|
+
## Creating a Database
|
|
50
|
+
|
|
51
|
+
The script `hash_malpedia.py` is an example of how to process a collection of binaries into `./block-reports`, which will then be aggreated into a `./db/picblocksdb.json`.
|
|
52
|
+
|
|
53
|
+
## Database Evaulation
|
|
54
|
+
|
|
55
|
+
In oder to quantify and to measure the quality of your detection rate you should check some basic informations about tests run against your db.
|
|
56
|
+
The simple (and preliminary) script named `make_stats.py` would build up some initial stats for you about detection rates.
|
|
57
|
+
It assumes to have a mongodb connection, the generated json db into `db/picblocksdb.json` (you can change it directly in the relative varible) and the generated blocks reports into `./block-reports/` folder (you can change it directly on the specified variable).
|
|
58
|
+
Once you run it, it takes every single block report and check it against the json database.
|
|
59
|
+
It build some stats and saves all the matching results into db.
|
|
60
|
+
A dedicated web page (and a relative API) is built to show the detection rates and some more interesting statistics on your database.
|
|
61
|
+
|
|
62
|
+
## Running as a Service
|
|
63
|
+
|
|
64
|
+
If a `./db/picblocksdb.json` exists, you can run
|
|
65
|
+
|
|
66
|
+
`$ python app.py`
|
|
67
|
+
|
|
68
|
+
to spawn a local demo server (`http://127.0.0.1:9001`) to query against.
|
|
69
|
+
|
|
70
|
+
### Screenshots
|
|
71
|
+
|
|
72
|
+
Just few screenshots about the initial stage of web user interface
|
|
73
|
+
The submit form. Once you have a given database (`./db/picblocksdb.json`) you can check matching from samples by submitting your samples from
|
|
74
|
+
this form.
|
|
75
|
+
|
|
76
|
+
<p align="center">
|
|
77
|
+
<img src="static/img/1.png">
|
|
78
|
+
</p>
|
|
79
|
+
|
|
80
|
+
If the submitted sample gets some matches against the given database you should see a block similarity matrix (still under development for a better visualization)
|
|
81
|
+
|
|
82
|
+
<p align="center">
|
|
83
|
+
<img src="static/img/2.png">
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
Finally the matching database statistics generated by the script into `utils/make_stats.py` which takes all the generated block_reports (`block-reports/`) and check them against the generated databases (`./db/picblocksdb.json`) in order to estimate the detection rate on a given database.
|
|
87
|
+
|
|
88
|
+
<p align="center">
|
|
89
|
+
<img src="static/img/3.png">
|
|
90
|
+
</p>
|
|
91
|
+
|
|
92
|
+
## Contributors
|
|
93
|
+
|
|
94
|
+
* [Daniel Plohmann](https://github.com/danielplohmann)
|
|
95
|
+
* [Marco Ramilli](https://github.com/marcoramilli)
|
|
96
|
+
* [Daniele Bellavista](https://github.com/dbellavista)
|
|
97
|
+
* [Rony](https://github.com/r0ny123)
|
|
98
|
+
|
|
99
|
+
## Scores changed in v2.1.0
|
|
100
|
+
|
|
101
|
+
Up to v2.0.1 the matcher credited a family **once per distinct block hash**, while
|
|
102
|
+
`block_bytes` - the denominator every percentage is divided by - counts **every block
|
|
103
|
+
occurrence**. A block shared by ten functions therefore contributed its size ten times to
|
|
104
|
+
the denominator and once to the numerator, so all four percentages read too low. They now
|
|
105
|
+
credit once per matching function, and reported percentages go up accordingly. Measured
|
|
106
|
+
over a database built from the Malpedia block reports, matching a sample that is itself in
|
|
107
|
+
the database moved from 87.6-99.0% to 98.2-99.4%.
|
|
108
|
+
|
|
109
|
+
Match reports produced by older versions are not comparable with new ones.
|
|
110
|
+
|
|
111
|
+
A known residual gap keeps that self-match just under 100%: a report stores the function
|
|
112
|
+
ids a block was seen in as a set, so the same hash occurring twice inside one function
|
|
113
|
+
counts twice in `block_bytes` but once when scoring. Over the Malpedia block reports this
|
|
114
|
+
is ~2.4% of `block_bytes`. Closing it would change the block report format.
|
|
115
|
+
|
|
116
|
+
## Version History
|
|
117
|
+
* 2026-09-13: v2.1.0 - architecture-aware PIC escaping (SMDA >= 4.2.13, AArch64/CIL/Dalvik as well as Intel), corrected matcher scoring (see above), dump/baseaddress routing, report/UI fixes, and a test suite
|
|
118
|
+
* 2023-11-24: v2.0.1 - SMDA pinned to 1.12.7 before our bigger fix for PIC calculation
|
|
119
|
+
* 2022-09-08: v2.0.0 - (BREAKING CHANGE) now intraprocedural control flow transfers are wildcarded by default, which should improve matching
|
|
120
|
+
* 2022-08-04: v1.1.3 - extended format for blockhash representation of functions
|
|
121
|
+
* 2021-10-01: v1.1.1 - added script to check detection rates and relative web interface page
|
|
122
|
+
* 2021-09-28: v1.1.0 - added simple web user interface and a db connection
|
|
123
|
+
* 2021-09-12: v1.0.6 - added submission form fields for bitness and base address to force overrides for those values.
|
|
124
|
+
* 2021-08-24: v1.0.5 - improved parsing of bitness from submission filenames.
|
|
125
|
+
* 2021-08-20: v1.0.4 - Tweaked result visualization, now showing all unique matches beyond the first 20.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
picblocks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
picblocks/blockhasher.py,sha256=lg6VOVbllLtCdthHWUnKMbZBJR48QGLDcm356kWdRzg,12293
|
|
3
|
+
picblocks/blockhashmatcher.py,sha256=RPIhr6Xx7akYqWwhik5aRh8CwLBj0lEWOuR5icBExXM,13000
|
|
4
|
+
picblocks-2.1.0.dist-info/licenses/LICENSE,sha256=qVIB0Uzno8yiGRqtV-K1ngYBIcF8DqYaL4Lnnx_6pDU,1287
|
|
5
|
+
picblocks-2.1.0.dist-info/METADATA,sha256=Vi30mv_T0voL99IAZwwb7LYDuQC5fQlbJUwOeyC8IeQ,7079
|
|
6
|
+
picblocks-2.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
picblocks-2.1.0.dist-info/top_level.txt,sha256=BRfdREZiXbo_Xfc4YhgZyfnMcRVymnF_1tIVE5KdrKA,10
|
|
8
|
+
picblocks-2.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Copyright (c) 2021, Daniel Plohmann
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
8
|
+
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
9
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
picblocks
|