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 ADDED
File without changes
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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