BoringSecretHunter 1.2.1__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.
- boring_secret_hunter/__init__.py +40 -0
- boring_secret_hunter/__main__.py +6 -0
- boring_secret_hunter/analyzer.py +287 -0
- boring_secret_hunter/arch_detector.py +118 -0
- boring_secret_hunter/archive_extractor.py +74 -0
- boring_secret_hunter/binary_classifier.py +66 -0
- boring_secret_hunter/cli.py +214 -0
- boring_secret_hunter/config.py +148 -0
- boring_secret_hunter/ghidra_runner.py +162 -0
- boring_secret_hunter/ghidra_scripts/BoringSecretHunter.java +1213 -0
- boring_secret_hunter/ghidra_scripts/MinimalAnalysisOption.java +219 -0
- boring_secret_hunter/ghidra_scripts/custom_log4j.xml +13 -0
- boring_secret_hunter/result.py +57 -0
- boring_secret_hunter/utils.py +89 -0
- boringsecrethunter-1.2.1.dist-info/METADATA +148 -0
- boringsecrethunter-1.2.1.dist-info/RECORD +20 -0
- boringsecrethunter-1.2.1.dist-info/WHEEL +5 -0
- boringsecrethunter-1.2.1.dist-info/entry_points.txt +3 -0
- boringsecrethunter-1.2.1.dist-info/licenses/LICENSE +21 -0
- boringsecrethunter-1.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""BoringSecretHunter - Extract ssl_log_secret() offsets from BoringSSL/RustLS binaries."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("BoringSecretHunter")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.0-dev"
|
|
9
|
+
|
|
10
|
+
from boring_secret_hunter.analyzer import (
|
|
11
|
+
analyze,
|
|
12
|
+
analyze_binary,
|
|
13
|
+
analyze_parallel,
|
|
14
|
+
discover_binaries,
|
|
15
|
+
)
|
|
16
|
+
from boring_secret_hunter.binary_classifier import BinaryType, classify_binary
|
|
17
|
+
from boring_secret_hunter.config import find_ghidra, find_java
|
|
18
|
+
from boring_secret_hunter.ghidra_runner import GhidraError
|
|
19
|
+
from boring_secret_hunter.result import AnalysisResult, BatchResult, FunctionMatch
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
# Analysis
|
|
24
|
+
"analyze",
|
|
25
|
+
"analyze_binary",
|
|
26
|
+
"analyze_parallel",
|
|
27
|
+
"discover_binaries",
|
|
28
|
+
# Data models
|
|
29
|
+
"AnalysisResult",
|
|
30
|
+
"BatchResult",
|
|
31
|
+
"FunctionMatch",
|
|
32
|
+
# Config helpers
|
|
33
|
+
"find_ghidra",
|
|
34
|
+
"find_java",
|
|
35
|
+
# Classification
|
|
36
|
+
"BinaryType",
|
|
37
|
+
"classify_binary",
|
|
38
|
+
# Errors
|
|
39
|
+
"GhidraError",
|
|
40
|
+
]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Main analysis orchestrator — ports ghidra_analysis.sh."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from boring_secret_hunter.archive_extractor import extract_binaries, is_archive
|
|
11
|
+
from boring_secret_hunter.arch_detector import detect_architecture, detect_from_siblings
|
|
12
|
+
from boring_secret_hunter.binary_classifier import (
|
|
13
|
+
BinaryType,
|
|
14
|
+
classify_binary,
|
|
15
|
+
is_archive_extension,
|
|
16
|
+
)
|
|
17
|
+
from boring_secret_hunter.ghidra_runner import GhidraError, parse_output, run_analysis
|
|
18
|
+
from boring_secret_hunter.result import AnalysisResult, BatchResult, FunctionMatch
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Threshold for "large dump" mode (in bytes)
|
|
23
|
+
LARGE_DUMP_THRESHOLD = 100 * 1024 * 1024 # 100 MB
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def discover_binaries(path: Path) -> List[Path]:
|
|
27
|
+
"""Discover all supported binaries at the given path.
|
|
28
|
+
|
|
29
|
+
If path is a file, returns [path] if it's a supported binary or archive.
|
|
30
|
+
If path is a directory, returns all supported binaries in the directory.
|
|
31
|
+
"""
|
|
32
|
+
if path.is_file():
|
|
33
|
+
return [path]
|
|
34
|
+
|
|
35
|
+
if not path.is_dir():
|
|
36
|
+
log.error("Path does not exist: %s", path)
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
binaries = []
|
|
40
|
+
for item in sorted(path.iterdir()):
|
|
41
|
+
if not item.is_file():
|
|
42
|
+
continue
|
|
43
|
+
if is_archive_extension(item):
|
|
44
|
+
continue
|
|
45
|
+
bin_type = classify_binary(item)
|
|
46
|
+
if bin_type != BinaryType.UNKNOWN:
|
|
47
|
+
binaries.append(item)
|
|
48
|
+
return binaries
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_function_matches(parsed_output: str) -> List[FunctionMatch]:
|
|
52
|
+
"""Parse function match info from the BoringSecretHunter output."""
|
|
53
|
+
functions = []
|
|
54
|
+
current = FunctionMatch()
|
|
55
|
+
|
|
56
|
+
for line in parsed_output.splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if not line:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Look for function label patterns
|
|
62
|
+
if "Function Label:" in line or "function label:" in line.lower():
|
|
63
|
+
if current.label:
|
|
64
|
+
functions.append(current)
|
|
65
|
+
current = FunctionMatch()
|
|
66
|
+
current.label = line.split(":", 1)[1].strip()
|
|
67
|
+
elif "Ghidra Offset:" in line or "ghidra offset:" in line.lower():
|
|
68
|
+
current.ghidra_offset = line.split(":", 1)[1].strip()
|
|
69
|
+
elif "IDA Offset:" in line or "ida offset:" in line.lower():
|
|
70
|
+
current.ida_offset = line.split(":", 1)[1].strip()
|
|
71
|
+
elif "Byte Pattern:" in line or "byte pattern:" in line.lower():
|
|
72
|
+
current.byte_pattern = line.split(":", 1)[1].strip()
|
|
73
|
+
|
|
74
|
+
if current.label:
|
|
75
|
+
functions.append(current)
|
|
76
|
+
|
|
77
|
+
return functions
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _detect_tls_library(parsed_output: str) -> str:
|
|
81
|
+
"""Detect TLS library type from output."""
|
|
82
|
+
lower = parsed_output.lower()
|
|
83
|
+
if "boringssl" in lower:
|
|
84
|
+
return "BoringSSL"
|
|
85
|
+
elif "rustls" in lower:
|
|
86
|
+
return "RustLS"
|
|
87
|
+
elif "openssl" in lower:
|
|
88
|
+
return "OpenSSL"
|
|
89
|
+
return "unknown"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def analyze_binary(
|
|
93
|
+
binary_path: Path,
|
|
94
|
+
ghidra_dir: str,
|
|
95
|
+
debug: bool = False,
|
|
96
|
+
large_dump_mode: Optional[str] = None,
|
|
97
|
+
processor_override: Optional[str] = None,
|
|
98
|
+
) -> AnalysisResult:
|
|
99
|
+
"""Analyze a single binary.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
binary_path: Path to the binary
|
|
103
|
+
ghidra_dir: Path to Ghidra installation
|
|
104
|
+
debug: Enable debug output
|
|
105
|
+
large_dump_mode: Mode for large dumps ('normal', 'fast', 'skip')
|
|
106
|
+
processor_override: Override processor detection with this value
|
|
107
|
+
"""
|
|
108
|
+
result = AnalysisResult(
|
|
109
|
+
binary_name=binary_path.name,
|
|
110
|
+
binary_path=str(binary_path),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
bin_type = classify_binary(binary_path)
|
|
114
|
+
result.binary_type = bin_type.value
|
|
115
|
+
|
|
116
|
+
# Detect architecture
|
|
117
|
+
arch = detect_architecture(binary_path)
|
|
118
|
+
if arch:
|
|
119
|
+
result.architecture = arch
|
|
120
|
+
|
|
121
|
+
# Build extra args for raw data files
|
|
122
|
+
extra_args = []
|
|
123
|
+
if bin_type == BinaryType.RAW_DATA:
|
|
124
|
+
proc = processor_override or detect_from_siblings(
|
|
125
|
+
binary_path.parent, processor_override
|
|
126
|
+
)
|
|
127
|
+
result.architecture = proc
|
|
128
|
+
extra_args = ["-processor", proc, "-loader", "BinaryLoader"]
|
|
129
|
+
log.info(" Raw data file detected, using processor: %s", proc)
|
|
130
|
+
|
|
131
|
+
# Check size for large dump handling
|
|
132
|
+
file_size = binary_path.stat().st_size
|
|
133
|
+
if file_size > LARGE_DUMP_THRESHOLD:
|
|
134
|
+
size_mb = file_size // (1024 * 1024)
|
|
135
|
+
mode = large_dump_mode or "normal"
|
|
136
|
+
if mode == "skip":
|
|
137
|
+
log.info(
|
|
138
|
+
" Skipping %s (%d MB) per user choice.",
|
|
139
|
+
binary_path.name,
|
|
140
|
+
size_mb,
|
|
141
|
+
)
|
|
142
|
+
result.error = f"Skipped (large dump: {size_mb} MB)"
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
raw_output = run_analysis(
|
|
147
|
+
binary_path=binary_path,
|
|
148
|
+
ghidra_dir=ghidra_dir,
|
|
149
|
+
extra_args=extra_args if extra_args else None,
|
|
150
|
+
debug=debug,
|
|
151
|
+
large_dump_mode=large_dump_mode,
|
|
152
|
+
)
|
|
153
|
+
result.raw_output = raw_output
|
|
154
|
+
|
|
155
|
+
parsed = parse_output(raw_output)
|
|
156
|
+
if parsed.strip():
|
|
157
|
+
result.success = True
|
|
158
|
+
result.functions = _parse_function_matches(parsed)
|
|
159
|
+
result.tls_library_type = _detect_tls_library(parsed)
|
|
160
|
+
else:
|
|
161
|
+
log.warning(
|
|
162
|
+
" No results found in Ghidra output for %s", binary_path.name
|
|
163
|
+
)
|
|
164
|
+
if debug:
|
|
165
|
+
log.debug("Raw output:\n%s", raw_output)
|
|
166
|
+
|
|
167
|
+
if debug:
|
|
168
|
+
log.debug("Raw Ghidra output:\n%s", raw_output)
|
|
169
|
+
|
|
170
|
+
except GhidraError as e:
|
|
171
|
+
result.error = str(e)
|
|
172
|
+
log.error(" Analysis failed: %s", e)
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def analyze(
|
|
178
|
+
path: Path,
|
|
179
|
+
ghidra_dir: str,
|
|
180
|
+
debug: bool = False,
|
|
181
|
+
large_dump_mode: Optional[str] = None,
|
|
182
|
+
processor_override: Optional[str] = None,
|
|
183
|
+
on_result: Optional[Callable[[AnalysisResult, str], None]] = None,
|
|
184
|
+
) -> BatchResult:
|
|
185
|
+
"""Analyze one or more binaries.
|
|
186
|
+
|
|
187
|
+
Handles archives (APK/IPA/ZIP), single files, and directories.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
path: Path to binary, archive, or directory
|
|
191
|
+
ghidra_dir: Path to Ghidra installation
|
|
192
|
+
debug: Enable debug output
|
|
193
|
+
large_dump_mode: Mode for large dumps
|
|
194
|
+
processor_override: Override processor for raw dumps
|
|
195
|
+
"""
|
|
196
|
+
batch = BatchResult()
|
|
197
|
+
temp_dirs = []
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
# Handle archives
|
|
201
|
+
if path.is_file() and is_archive(path):
|
|
202
|
+
extracted, tmp_dir = extract_binaries(path)
|
|
203
|
+
temp_dirs.append(tmp_dir)
|
|
204
|
+
binaries = extracted
|
|
205
|
+
else:
|
|
206
|
+
binaries = discover_binaries(path)
|
|
207
|
+
|
|
208
|
+
batch.total = len(binaries)
|
|
209
|
+
|
|
210
|
+
if not binaries:
|
|
211
|
+
log.warning(
|
|
212
|
+
"No supported binaries found. Supported types: ELF, Mach-O, PE32, raw data files."
|
|
213
|
+
)
|
|
214
|
+
return batch
|
|
215
|
+
|
|
216
|
+
if len(binaries) == 1:
|
|
217
|
+
log.info("Found 1 binary to analyze:")
|
|
218
|
+
else:
|
|
219
|
+
log.info("Found %d binaries to analyze:", len(binaries))
|
|
220
|
+
for i, b in enumerate(binaries, 1):
|
|
221
|
+
log.info(" %d. %s", i, b.name)
|
|
222
|
+
|
|
223
|
+
for i, binary in enumerate(binaries, 1):
|
|
224
|
+
log.info("[%d/%d] Analyzing %s...", i, batch.total, binary.name)
|
|
225
|
+
|
|
226
|
+
result = analyze_binary(
|
|
227
|
+
binary_path=binary,
|
|
228
|
+
ghidra_dir=ghidra_dir,
|
|
229
|
+
debug=debug,
|
|
230
|
+
large_dump_mode=large_dump_mode,
|
|
231
|
+
processor_override=processor_override,
|
|
232
|
+
)
|
|
233
|
+
batch.results.append(result)
|
|
234
|
+
|
|
235
|
+
if result.success:
|
|
236
|
+
batch.successful += 1
|
|
237
|
+
parsed = parse_output(result.raw_output)
|
|
238
|
+
if parsed.strip():
|
|
239
|
+
log.debug("Parsed output for %s:\n%s", binary.name, parsed)
|
|
240
|
+
if on_result is not None:
|
|
241
|
+
on_result(result, parsed)
|
|
242
|
+
|
|
243
|
+
finally:
|
|
244
|
+
for tmp_dir in temp_dirs:
|
|
245
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
246
|
+
|
|
247
|
+
return batch
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def analyze_parallel(
|
|
251
|
+
binaries: List[Path],
|
|
252
|
+
ghidra_dir: str,
|
|
253
|
+
max_workers: int = 4,
|
|
254
|
+
debug: bool = False,
|
|
255
|
+
large_dump_mode: Optional[str] = None,
|
|
256
|
+
processor_override: Optional[str] = None,
|
|
257
|
+
) -> Dict[Path, AnalysisResult]:
|
|
258
|
+
"""Analyze multiple binaries in parallel using threads.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
binaries: List of binary paths to analyze
|
|
262
|
+
ghidra_dir: Path to Ghidra installation
|
|
263
|
+
max_workers: Maximum number of concurrent analyses
|
|
264
|
+
debug: Enable debug output
|
|
265
|
+
large_dump_mode: Mode for large dumps ('normal', 'fast', 'skip')
|
|
266
|
+
processor_override: Override processor for raw dumps
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Dictionary mapping each binary path to its AnalysisResult.
|
|
270
|
+
"""
|
|
271
|
+
results: Dict[Path, AnalysisResult] = {}
|
|
272
|
+
|
|
273
|
+
def _run(binary_path: Path) -> tuple:
|
|
274
|
+
result = analyze_binary(
|
|
275
|
+
binary_path=binary_path,
|
|
276
|
+
ghidra_dir=ghidra_dir,
|
|
277
|
+
debug=debug,
|
|
278
|
+
large_dump_mode=large_dump_mode,
|
|
279
|
+
processor_override=processor_override,
|
|
280
|
+
)
|
|
281
|
+
return binary_path, result
|
|
282
|
+
|
|
283
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
284
|
+
for path, result in executor.map(_run, binaries):
|
|
285
|
+
results[path] = result
|
|
286
|
+
|
|
287
|
+
return results
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Detect CPU architecture from binary headers."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from boring_secret_hunter.binary_classifier import (
|
|
8
|
+
BinaryType,
|
|
9
|
+
classify_binary,
|
|
10
|
+
_MACHO_MAGIC_64_LE,
|
|
11
|
+
_MACHO_MAGIC_32_LE,
|
|
12
|
+
_MACHO_MAGIC_64_BE,
|
|
13
|
+
_MACHO_MAGIC_32_BE,
|
|
14
|
+
_MACHO_FAT_BE,
|
|
15
|
+
_MACHO_FAT_LE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ELF e_machine values (at offset 18, 2 bytes)
|
|
20
|
+
_ELF_MACHINES = {
|
|
21
|
+
0x03: "x86:LE:32:default", # EM_386
|
|
22
|
+
0x3E: "x86:LE:64:default", # EM_X86_64
|
|
23
|
+
0x28: "ARM:LE:32:v8", # EM_ARM
|
|
24
|
+
0xB7: "AARCH64:LE:64:v8A", # EM_AARCH64
|
|
25
|
+
0x08: "MIPS:BE:32:default", # EM_MIPS
|
|
26
|
+
0xF3: "RISCV:LE:64:default", # EM_RISCV
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Mach-O cputype values (at offset 4, 4 bytes)
|
|
30
|
+
_MACHO_CPUTYPES_LE = {
|
|
31
|
+
0x0C: "ARM:LE:32:v8", # CPU_TYPE_ARM
|
|
32
|
+
0x0100000C: "AARCH64:LE:64:v8A", # CPU_TYPE_ARM64
|
|
33
|
+
0x07: "x86:LE:32:default", # CPU_TYPE_X86
|
|
34
|
+
0x01000007: "x86:LE:64:default", # CPU_TYPE_X86_64
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
DEFAULT_PROCESSOR = "AARCH64:LE:64:v8A"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detect_architecture(path: Path) -> Optional[str]:
|
|
41
|
+
"""Detect Ghidra processor string from binary headers.
|
|
42
|
+
|
|
43
|
+
Returns a Ghidra-compatible processor string like 'AARCH64:LE:64:v8A'
|
|
44
|
+
or None if detection fails.
|
|
45
|
+
"""
|
|
46
|
+
bin_type = classify_binary(path)
|
|
47
|
+
|
|
48
|
+
if bin_type == BinaryType.ELF:
|
|
49
|
+
return _detect_elf_arch(path)
|
|
50
|
+
elif bin_type == BinaryType.MACHO:
|
|
51
|
+
return _detect_macho_arch(path)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detect_elf_arch(path: Path) -> Optional[str]:
|
|
56
|
+
"""Read ELF e_machine field at offset 18."""
|
|
57
|
+
try:
|
|
58
|
+
with open(path, "rb") as f:
|
|
59
|
+
f.seek(5)
|
|
60
|
+
ei_data = struct.unpack("B", f.read(1))[0]
|
|
61
|
+
endian = "<" if ei_data == 1 else ">"
|
|
62
|
+
f.seek(18)
|
|
63
|
+
e_machine = struct.unpack(f"{endian}H", f.read(2))[0]
|
|
64
|
+
return _ELF_MACHINES.get(e_machine)
|
|
65
|
+
except (OSError, struct.error):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _detect_macho_arch(path: Path) -> Optional[str]:
|
|
70
|
+
"""Read Mach-O cputype field at offset 4."""
|
|
71
|
+
try:
|
|
72
|
+
with open(path, "rb") as f:
|
|
73
|
+
magic = f.read(4)
|
|
74
|
+
# Determine endianness from magic
|
|
75
|
+
if magic in (_MACHO_MAGIC_64_LE, _MACHO_MAGIC_32_LE):
|
|
76
|
+
endian = "<"
|
|
77
|
+
elif magic in (_MACHO_MAGIC_64_BE, _MACHO_MAGIC_32_BE):
|
|
78
|
+
endian = ">"
|
|
79
|
+
elif magic == _MACHO_FAT_BE:
|
|
80
|
+
# Fat binary - read first arch
|
|
81
|
+
endian = ">"
|
|
82
|
+
f.seek(8) # skip to first arch cputype
|
|
83
|
+
elif magic == _MACHO_FAT_LE:
|
|
84
|
+
endian = "<"
|
|
85
|
+
f.seek(8)
|
|
86
|
+
else:
|
|
87
|
+
return None
|
|
88
|
+
cputype = struct.unpack(f"{endian}I", f.read(4))[0]
|
|
89
|
+
return _MACHO_CPUTYPES_LE.get(cputype)
|
|
90
|
+
except (OSError, struct.error):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect_from_siblings(
|
|
95
|
+
directory: Path, processor_override: Optional[str] = None
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Detect processor from sibling binaries in the same directory.
|
|
98
|
+
|
|
99
|
+
Used for raw data dumps where the binary itself has no headers.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
directory: Directory to scan for sibling binaries
|
|
103
|
+
processor_override: If set, use this value directly (from CLI --processor flag)
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Ghidra processor string, defaults to AARCH64:LE:64:v8A
|
|
107
|
+
"""
|
|
108
|
+
if processor_override:
|
|
109
|
+
return processor_override
|
|
110
|
+
|
|
111
|
+
for sibling in directory.iterdir():
|
|
112
|
+
if not sibling.is_file():
|
|
113
|
+
continue
|
|
114
|
+
arch = detect_architecture(sibling)
|
|
115
|
+
if arch:
|
|
116
|
+
return arch
|
|
117
|
+
|
|
118
|
+
return DEFAULT_PROCESSOR
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Extract binaries from APK/IPA/ZIP archives."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
|
|
10
|
+
from boring_secret_hunter.binary_classifier import BinaryType, classify_binary
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Archive types recognized by extension
|
|
15
|
+
_ARCHIVE_EXTENSIONS = {".apk", ".ipa", ".zip"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_archive(path: Path) -> bool:
|
|
19
|
+
"""Check if a file is a supported archive type."""
|
|
20
|
+
return path.suffix.lower() in _ARCHIVE_EXTENSIONS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_binaries(archive_path: Path) -> Tuple[List[Path], Path]:
|
|
24
|
+
"""Extract binaries from an archive.
|
|
25
|
+
|
|
26
|
+
For APK: extracts only .so files that are ELF binaries.
|
|
27
|
+
For IPA: extracts all Mach-O binaries.
|
|
28
|
+
For ZIP: extracts all ELF and Mach-O binaries.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (list of extracted binary paths, temp directory to clean up)
|
|
32
|
+
"""
|
|
33
|
+
suffix = archive_path.suffix.lower()
|
|
34
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix="bsh_extract_"))
|
|
35
|
+
|
|
36
|
+
log.info("Extracting archive: %s", archive_path.name)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
40
|
+
zf.extractall(tmp_dir)
|
|
41
|
+
except (zipfile.BadZipFile, Exception) as e:
|
|
42
|
+
log.warning("Failed to extract %s: %s", archive_path.name, e)
|
|
43
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
44
|
+
return [], tmp_dir
|
|
45
|
+
|
|
46
|
+
extracted = []
|
|
47
|
+
for candidate in tmp_dir.rglob("*"):
|
|
48
|
+
if not candidate.is_file() or candidate.stat().st_size < 1024:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
bin_type = classify_binary(candidate)
|
|
52
|
+
should_extract = False
|
|
53
|
+
|
|
54
|
+
if suffix == ".apk":
|
|
55
|
+
if candidate.suffix == ".so" and bin_type == BinaryType.ELF:
|
|
56
|
+
should_extract = True
|
|
57
|
+
elif suffix == ".ipa":
|
|
58
|
+
if bin_type == BinaryType.MACHO:
|
|
59
|
+
should_extract = True
|
|
60
|
+
else:
|
|
61
|
+
if bin_type in (BinaryType.ELF, BinaryType.MACHO):
|
|
62
|
+
should_extract = True
|
|
63
|
+
|
|
64
|
+
if should_extract:
|
|
65
|
+
relative = candidate.relative_to(tmp_dir)
|
|
66
|
+
sanitized = str(relative).replace("/", "__").replace("\\", "__")
|
|
67
|
+
dest_name = f"{archive_path.name}__{sanitized}"
|
|
68
|
+
dest_path = tmp_dir / dest_name
|
|
69
|
+
if candidate != dest_path:
|
|
70
|
+
shutil.copy2(candidate, dest_path)
|
|
71
|
+
extracted.append(dest_path)
|
|
72
|
+
log.info(" Found: %s", relative)
|
|
73
|
+
|
|
74
|
+
return extracted, tmp_dir
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Classify binary files by magic bytes."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BinaryType(enum.Enum):
|
|
8
|
+
ELF = "elf"
|
|
9
|
+
MACHO = "macho"
|
|
10
|
+
PE32 = "pe32"
|
|
11
|
+
RAW_DATA = "data"
|
|
12
|
+
UNKNOWN = "unknown"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Magic byte signatures
|
|
16
|
+
_ELF_MAGIC = b"\x7fELF"
|
|
17
|
+
_MACHO_MAGIC_64_LE = b"\xcf\xfa\xed\xfe" # 64-bit little-endian
|
|
18
|
+
_MACHO_MAGIC_32_LE = b"\xce\xfa\xed\xfe" # 32-bit little-endian
|
|
19
|
+
_MACHO_MAGIC_64_BE = b"\xfe\xed\xfa\xcf" # 64-bit big-endian
|
|
20
|
+
_MACHO_MAGIC_32_BE = b"\xfe\xed\xfa\xce" # 32-bit big-endian
|
|
21
|
+
_MACHO_FAT_BE = b"\xca\xfe\xba\xbe" # Universal/fat binary
|
|
22
|
+
_MACHO_FAT_LE = b"\xbe\xba\xfe\xca" # Universal/fat binary (LE)
|
|
23
|
+
_PE_MAGIC = b"MZ"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify_binary(path: Path) -> BinaryType:
|
|
27
|
+
"""Classify a binary file by reading its magic bytes.
|
|
28
|
+
|
|
29
|
+
Returns BinaryType enum indicating the file type.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
with open(path, "rb") as f:
|
|
33
|
+
header = f.read(4)
|
|
34
|
+
except (OSError, IOError):
|
|
35
|
+
return BinaryType.UNKNOWN
|
|
36
|
+
|
|
37
|
+
if len(header) < 2:
|
|
38
|
+
return BinaryType.UNKNOWN
|
|
39
|
+
|
|
40
|
+
if header[:4] == _ELF_MAGIC:
|
|
41
|
+
return BinaryType.ELF
|
|
42
|
+
|
|
43
|
+
if header[:4] in (
|
|
44
|
+
_MACHO_MAGIC_64_LE,
|
|
45
|
+
_MACHO_MAGIC_32_LE,
|
|
46
|
+
_MACHO_MAGIC_64_BE,
|
|
47
|
+
_MACHO_MAGIC_32_BE,
|
|
48
|
+
_MACHO_FAT_BE,
|
|
49
|
+
_MACHO_FAT_LE,
|
|
50
|
+
):
|
|
51
|
+
return BinaryType.MACHO
|
|
52
|
+
|
|
53
|
+
if header[:2] == _PE_MAGIC:
|
|
54
|
+
return BinaryType.PE32
|
|
55
|
+
|
|
56
|
+
return BinaryType.RAW_DATA
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_supported_binary(path: Path) -> bool:
|
|
60
|
+
"""Check if a file is a supported binary type (not UNKNOWN)."""
|
|
61
|
+
return classify_binary(path) != BinaryType.UNKNOWN
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_archive_extension(path: Path) -> bool:
|
|
65
|
+
"""Check if a file has an archive extension (to skip during classification)."""
|
|
66
|
+
return path.suffix.lower() in {".apk", ".ipa", ".zip", ".jar"}
|