anais-apk-forensic 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/anais.sh +669 -0
- package/analysis_tools/__pycache__/apk_basic_info.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/apk_basic_info.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/check_zip_encryption.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/check_zip_encryption.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/detect_obfuscation.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/detect_obfuscation.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/dex_payload_hunter.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/entropy_analyzer.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/error_logger.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/error_logger.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/find_encrypted_payload.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/fix_apk_headers.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/fix_apk_headers.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/manifest_analyzer.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/manifest_analyzer.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/network_analyzer.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/network_analyzer.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/report_generator.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/report_generator.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/report_generator_modular.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/sast_scanner.cpython-313.pyc +0 -0
- package/analysis_tools/__pycache__/sast_scanner.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/so_string_analyzer.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/yara_enhanced_analyzer.cpython-314.pyc +0 -0
- package/analysis_tools/__pycache__/yara_results_processor.cpython-314.pyc +0 -0
- package/analysis_tools/apk_basic_info.py +85 -0
- package/analysis_tools/check_zip_encryption.py +142 -0
- package/analysis_tools/detect_obfuscation.py +650 -0
- package/analysis_tools/dex_payload_hunter.py +734 -0
- package/analysis_tools/entropy_analyzer.py +335 -0
- package/analysis_tools/error_logger.py +75 -0
- package/analysis_tools/find_encrypted_payload.py +485 -0
- package/analysis_tools/fix_apk_headers.py +154 -0
- package/analysis_tools/manifest_analyzer.py +214 -0
- package/analysis_tools/network_analyzer.py +287 -0
- package/analysis_tools/report_generator.py +506 -0
- package/analysis_tools/report_generator_modular.py +885 -0
- package/analysis_tools/sast_scanner.py +412 -0
- package/analysis_tools/so_string_analyzer.py +406 -0
- package/analysis_tools/yara_enhanced_analyzer.py +330 -0
- package/analysis_tools/yara_results_processor.py +368 -0
- package/analyzer_config.json +113 -0
- package/apkid/__init__.py +32 -0
- package/apkid/__pycache__/__init__.cpython-313.pyc +0 -0
- package/apkid/__pycache__/__init__.cpython-314.pyc +0 -0
- package/apkid/__pycache__/apkid.cpython-313.pyc +0 -0
- package/apkid/__pycache__/apkid.cpython-314.pyc +0 -0
- package/apkid/__pycache__/main.cpython-313.pyc +0 -0
- package/apkid/__pycache__/main.cpython-314.pyc +0 -0
- package/apkid/__pycache__/output.cpython-313.pyc +0 -0
- package/apkid/__pycache__/rules.cpython-313.pyc +0 -0
- package/apkid/apkid.py +266 -0
- package/apkid/main.py +98 -0
- package/apkid/output.py +177 -0
- package/apkid/rules/apk/common.yara +68 -0
- package/apkid/rules/apk/obfuscators.yara +118 -0
- package/apkid/rules/apk/packers.yara +1197 -0
- package/apkid/rules/apk/protectors.yara +301 -0
- package/apkid/rules/dex/abnormal.yara +104 -0
- package/apkid/rules/dex/anti-vm.yara +568 -0
- package/apkid/rules/dex/common.yara +60 -0
- package/apkid/rules/dex/compilers.yara +434 -0
- package/apkid/rules/dex/obfuscators.yara +602 -0
- package/apkid/rules/dex/packers.yara +761 -0
- package/apkid/rules/dex/protectors.yara +520 -0
- package/apkid/rules/dll/common.yara +38 -0
- package/apkid/rules/dll/obfuscators.yara +43 -0
- package/apkid/rules/elf/anti-vm.yara +43 -0
- package/apkid/rules/elf/common.yara +54 -0
- package/apkid/rules/elf/obfuscators.yara +991 -0
- package/apkid/rules/elf/packers.yara +1128 -0
- package/apkid/rules/elf/protectors.yara +794 -0
- package/apkid/rules/res/common.yara +43 -0
- package/apkid/rules/res/obfuscators.yara +46 -0
- package/apkid/rules/res/protectors.yara +46 -0
- package/apkid/rules.py +77 -0
- package/bin/anais +3 -0
- package/dist/cli.js +82 -0
- package/dist/index.js +123 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/index.js +21 -0
- package/dist/utils/output.js +44 -0
- package/dist/utils/paths.js +107 -0
- package/docs/ARCHITECTURE.txt +353 -0
- package/docs/Workflow and Reference.md +445 -0
- package/package.json +70 -0
- package/rules/yara_general_rules.yar +323 -0
- package/scripts/dynamic_analysis_helper.sh +334 -0
- package/scripts/frida/dpt_dex_dumper.js +145 -0
- package/scripts/frida/frida_dex_dump.js +145 -0
- package/scripts/frida/frida_hooks.js +437 -0
- package/scripts/frida/frida_websocket_extractor.js +154 -0
- package/scripts/setup.sh +206 -0
- package/scripts/validate_framework.sh +224 -0
- package/src/cli.ts +91 -0
- package/src/index.ts +123 -0
- package/src/types/index.ts +44 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/output.ts +50 -0
- package/src/utils/paths.ts +72 -0
- package/tsconfig.json +14 -0
package/apkid/apkid.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (C) 2024 RedNaga. https://rednaga.io
|
|
3
|
+
All rights reserved. Contact: rednaga@protonmail.com
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
This file is part of APKiD
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Commercial License Usage
|
|
10
|
+
------------------------
|
|
11
|
+
Licensees holding valid commercial APKiD licenses may use this file
|
|
12
|
+
in accordance with the commercial license agreement provided with the
|
|
13
|
+
Software or, alternatively, in accordance with the terms contained in
|
|
14
|
+
a written agreement between you and RedNaga.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
GNU General Public License Usage
|
|
18
|
+
--------------------------------
|
|
19
|
+
Alternatively, this file may be used under the terms of the GNU General
|
|
20
|
+
Public License version 3.0 as published by the Free Software Foundation
|
|
21
|
+
and appearing in the file LICENSE.GPL included in the packaging of this
|
|
22
|
+
file. Please visit http://www.gnu.org/copyleft/gpl.html and review the
|
|
23
|
+
information to ensure the GNU General Public License version 3.0
|
|
24
|
+
requirements will be met.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import io
|
|
28
|
+
import lzma
|
|
29
|
+
import os
|
|
30
|
+
import struct
|
|
31
|
+
import sys
|
|
32
|
+
import traceback
|
|
33
|
+
import zipfile
|
|
34
|
+
from typing import Union, IO, List, Dict, Set
|
|
35
|
+
|
|
36
|
+
import yara
|
|
37
|
+
|
|
38
|
+
from .output import OutputFormatter
|
|
39
|
+
from .rules import RulesManager
|
|
40
|
+
|
|
41
|
+
SCANNABLE_FILE_MAGICS: Dict[str, Set[bytes]] = {
|
|
42
|
+
'zip': {b'PK\x03\x04', b'PK\x05\x06', b'PK\x07\x08'},
|
|
43
|
+
'dex': {b'dex\n', b'dey\n'},
|
|
44
|
+
'elf': {b'\x7fELF'},
|
|
45
|
+
'res': {b'\x02\x00\x0c\x00'},
|
|
46
|
+
'dll': {b'MZ\x90\x00'},
|
|
47
|
+
# TODO: implement axml yara module
|
|
48
|
+
# 'axml': set(),
|
|
49
|
+
}
|
|
50
|
+
XZ_COMPRESSION_TYPE = 95
|
|
51
|
+
ZIP_LFH_SIG_SIZE = 4
|
|
52
|
+
ZIP_LFH_FIELDS_SIZE = 26
|
|
53
|
+
ZIP_LFH_HEADER_SIZE = ZIP_LFH_SIG_SIZE + ZIP_LFH_FIELDS_SIZE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Options(object):
|
|
57
|
+
|
|
58
|
+
def __init__(self, timeout: int = 10, verbose: bool = False, json: bool = False, output_dir: Union[str, None] = None,
|
|
59
|
+
typing: Union[str, None] = 'magic', entry_max_scan_size: int = 0, scan_depth=2, recursive: bool = False,
|
|
60
|
+
include_types: bool = False):
|
|
61
|
+
"""Scan options.
|
|
62
|
+
Holds user-supplied options governing how APKiD behaves.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
timeout : integer, optional (default=10)
|
|
67
|
+
The number of seconds before Yara match should time out.
|
|
68
|
+
json : boolean, optional (default=False)
|
|
69
|
+
If the output should be JSON format.
|
|
70
|
+
output_dir : string or None, optional (default=None)
|
|
71
|
+
Directory to write individual scan results to. If this is true, it implies `json_output=True`.
|
|
72
|
+
Note: This is useful for feature extraction of a bunch of APKs.
|
|
73
|
+
verbose : boolean, optional (default=False)
|
|
74
|
+
When set to `True`, log warnings and other debug information.
|
|
75
|
+
typing : string or None, optional (default="magic")
|
|
76
|
+
Determines how the scanner decides if a file should be scanned.
|
|
77
|
+
If "magic", then require the file match a built-in list of supported file magics
|
|
78
|
+
If "filename", then scan files which have names known to be supported (e.g. ".dex")
|
|
79
|
+
If None, pass every file to Yara for matching.
|
|
80
|
+
Note: This option defines a trade-off between performance and accuracy. For example, if you're scanning a large APK file, it is expensive
|
|
81
|
+
to uncompress every ZIP entry for Yara matching. It's much faster to only decompress files which have a known extension such as ".dex".
|
|
82
|
+
But in many cases files may not have the correct extension, e.g. a DEX file may be named "notmalware.gif". On the other extreme, one could
|
|
83
|
+
simply decompress every file and scan it, but this is wasteful because most files in an APK are typically not interesting, e.g. images.
|
|
84
|
+
The default behavior of "magic" only needs to read a few bytes to decide if a file should be completely uncompressed.
|
|
85
|
+
entry_max_scan_size : integer, optional (default=0)
|
|
86
|
+
If > 0, only scan APK entries if their uncompressed size is less than this value.
|
|
87
|
+
scan_depth : integer, optional (default=2)
|
|
88
|
+
Determines how many times scanner should recurse into nested archives.
|
|
89
|
+
If 0, don't recurse into nested archives.
|
|
90
|
+
Note: It's possible to construct a malicious ZIP which can be infinitely nested. It's therefore necessary to limit the scan depth.
|
|
91
|
+
Don't get cheeky and think you can set this value to 1000 and scan random malware without blowing up your memory.
|
|
92
|
+
recursive : boolean, optional (default=True)
|
|
93
|
+
If true, when scanning a directory, will recurse into subdirectories.
|
|
94
|
+
"""
|
|
95
|
+
self.timeout = timeout
|
|
96
|
+
self.verbose = verbose
|
|
97
|
+
self.typing = typing
|
|
98
|
+
self.entry_max_scan_size = entry_max_scan_size
|
|
99
|
+
self.scan_depth = scan_depth
|
|
100
|
+
self.recursive = recursive
|
|
101
|
+
self.rules_manager = RulesManager()
|
|
102
|
+
self.output = OutputFormatter(
|
|
103
|
+
json_output=json,
|
|
104
|
+
output_dir=output_dir,
|
|
105
|
+
rules_manager=self.rules_manager,
|
|
106
|
+
include_types=include_types
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Scanner(object):
|
|
111
|
+
|
|
112
|
+
def __init__(self, rules: yara.Rules, options: Options):
|
|
113
|
+
self.rules = rules
|
|
114
|
+
self.options = options
|
|
115
|
+
|
|
116
|
+
def scan(self, path: str) -> None:
|
|
117
|
+
if os.path.isfile(path):
|
|
118
|
+
results = self.scan_file(path)
|
|
119
|
+
if len(results) > 0:
|
|
120
|
+
self.options.output.write(results)
|
|
121
|
+
elif os.path.isdir(path):
|
|
122
|
+
self.scan_directory(path)
|
|
123
|
+
|
|
124
|
+
def scan_directory(self, dir_path: str) -> None:
|
|
125
|
+
for file_path in self._yield_file_paths(dir_path):
|
|
126
|
+
self.scan(file_path)
|
|
127
|
+
|
|
128
|
+
def scan_file(self, file_path: str) -> Dict[str, List[yara.Match]]:
|
|
129
|
+
results = []
|
|
130
|
+
with open(file_path, 'rb') as f:
|
|
131
|
+
try:
|
|
132
|
+
results: Dict[str, List[yara.Match]] = self.scan_file_obj(f, file_path)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
stack = traceback.format_exc()
|
|
135
|
+
print(f"Exception scanning {file_path}: {stack}")
|
|
136
|
+
return results
|
|
137
|
+
|
|
138
|
+
def scan_file_obj(self, file: IO, file_path: str = '$FILE$'):
|
|
139
|
+
if file_path == '$FILE$':
|
|
140
|
+
file_name = file_path
|
|
141
|
+
else:
|
|
142
|
+
file_name = os.path.basename(file_path)
|
|
143
|
+
|
|
144
|
+
results: Dict[str, List[yara.Match]] = {}
|
|
145
|
+
if not self._should_scan(file, file_name):
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
matches: List[yara.Matches] = self.rules.match(data=file.read(), timeout=self.options.timeout)
|
|
149
|
+
if len(matches) > 0:
|
|
150
|
+
results[file_path] = matches
|
|
151
|
+
if self._is_zipfile(file, file_name):
|
|
152
|
+
with zipfile.ZipFile(file) as zf:
|
|
153
|
+
zip_results = self._scan_zip(zf)
|
|
154
|
+
for entry_name, entry_matches in zip_results.items():
|
|
155
|
+
results[f'{file_path}!{entry_name}'] = entry_matches
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
def _scan_zip(self, zf: zipfile.ZipFile, depth=0) -> Dict[str, List[yara.Match]]:
|
|
159
|
+
results: Dict[str, List[yara.Match]] = {}
|
|
160
|
+
for info in zf.infolist():
|
|
161
|
+
if info.is_dir():
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
self._scan_zip_entry(zf, info, results, depth)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
stack = traceback.format_exc()
|
|
167
|
+
print(f"Exception scanning {info.filename} in {zf.filename}, depth={depth}: {stack}",
|
|
168
|
+
file=sys.stderr)
|
|
169
|
+
return results
|
|
170
|
+
|
|
171
|
+
def _scan_zip_entry(self, zf, info, results, depth) -> None:
|
|
172
|
+
try:
|
|
173
|
+
with zf.open(info) as entry:
|
|
174
|
+
# Python 3.6 zip entries are not seek'able :(
|
|
175
|
+
entry_buffer: IO = io.BytesIO(entry.read(4))
|
|
176
|
+
entry_buffer.seek(0)
|
|
177
|
+
if not self._should_scan(entry_buffer, info.filename):
|
|
178
|
+
return
|
|
179
|
+
entry_buffer.seek(4)
|
|
180
|
+
entry_buffer.write(entry.read())
|
|
181
|
+
except zipfile.BadZipFile as e:
|
|
182
|
+
if "Overlapped entries" in str(e) or "possible zip bomb" in str(e):
|
|
183
|
+
if self.options.verbose:
|
|
184
|
+
print(f"[W] Skipping overlapped entry {info.filename} (possible zip bomb)")
|
|
185
|
+
return
|
|
186
|
+
else:
|
|
187
|
+
raise
|
|
188
|
+
except NotImplementedError:
|
|
189
|
+
# XZ-compression
|
|
190
|
+
if info.compress_type == XZ_COMPRESSION_TYPE:
|
|
191
|
+
with open(zf.filename, 'rb') as raw_zip:
|
|
192
|
+
raw_zip.seek(info.header_offset + ZIP_LFH_FIELDS_SIZE)
|
|
193
|
+
filename_len = struct.unpack('<H', raw_zip.read(2))[0]
|
|
194
|
+
extra_len = struct.unpack('<H', raw_zip.read(2))[0]
|
|
195
|
+
data_offset = info.header_offset + ZIP_LFH_HEADER_SIZE + filename_len + extra_len
|
|
196
|
+
raw_zip.seek(data_offset)
|
|
197
|
+
compressed_data = raw_zip.read(info.compress_size)
|
|
198
|
+
try:
|
|
199
|
+
decompressed_data = lzma.decompress(compressed_data)
|
|
200
|
+
entry_buffer = io.BytesIO(decompressed_data)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
print(f"[E] Failed to decompress {info.filename}: {e}")
|
|
203
|
+
return
|
|
204
|
+
else:
|
|
205
|
+
print(f"[E] Unsupported compression method {info.compress_type} for {info.filename}")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
entry_buffer.seek(0)
|
|
210
|
+
matches = self.rules.match(data=entry_buffer.read(), timeout=self.options.timeout)
|
|
211
|
+
|
|
212
|
+
if len(matches) > 0:
|
|
213
|
+
results[info.filename] = matches
|
|
214
|
+
|
|
215
|
+
if depth < self.options.scan_depth and self._is_zipfile(entry_buffer, info.filename):
|
|
216
|
+
with zipfile.ZipFile(entry_buffer) as zip_entry:
|
|
217
|
+
nested_results = self._scan_zip(zip_entry, depth=depth + 1)
|
|
218
|
+
for nested_name, nested_matches in nested_results.items():
|
|
219
|
+
results[f'{info.filename}!{nested_name}'] = nested_matches
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _type_file(file: IO) -> Union[None, str]:
|
|
223
|
+
magic = file.read(4)
|
|
224
|
+
file.seek(0)
|
|
225
|
+
for file_type, magics in SCANNABLE_FILE_MAGICS.items():
|
|
226
|
+
if magic in magics:
|
|
227
|
+
return file_type
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def _is_zipfile(self, file: IO, name: str) -> bool:
|
|
231
|
+
if self.options.typing == 'filename':
|
|
232
|
+
name = name.lower()
|
|
233
|
+
return name.endswith('.apk') or name.endswith('.zip') or name.endswith('.jar')
|
|
234
|
+
else:
|
|
235
|
+
# Look at file magic since zipfile.is_zipfile isn't perfect
|
|
236
|
+
# Some elfs may be considered zips and this causes apkid to barf errors
|
|
237
|
+
file.seek(0)
|
|
238
|
+
return Scanner._type_file(file) == 'zip' and zipfile.is_zipfile(file)
|
|
239
|
+
|
|
240
|
+
def _should_scan(self, file: IO, name: str) -> bool:
|
|
241
|
+
if self.options.typing == 'magic':
|
|
242
|
+
file_type = Scanner._type_file(file)
|
|
243
|
+
return file_type is not None
|
|
244
|
+
elif self.options.typing == 'filename':
|
|
245
|
+
name = name.lower()
|
|
246
|
+
return name.startswith('classes') \
|
|
247
|
+
or name.startswith('AndroidManifest.xml') \
|
|
248
|
+
or name.startswith('lib/') \
|
|
249
|
+
or name.endswith('.so') \
|
|
250
|
+
or name.endswith('.dex') \
|
|
251
|
+
or name.endswith('.apk') \
|
|
252
|
+
or name.endswith('.arsc') \
|
|
253
|
+
or name.endswith('.dll')
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
def _yield_file_paths(self, dir_path: str):
|
|
257
|
+
if self.options.recursive:
|
|
258
|
+
for root, _, filenames in os.walk(dir_path):
|
|
259
|
+
for path in filenames:
|
|
260
|
+
yield os.path.join(root, path)
|
|
261
|
+
else:
|
|
262
|
+
for path in os.listdir(dir_path):
|
|
263
|
+
full_path = os.path.join(dir_path, path)
|
|
264
|
+
if os.path.isdir(full_path):
|
|
265
|
+
continue
|
|
266
|
+
yield full_path
|
package/apkid/main.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (C) 2023 RedNaga. https://rednaga.io
|
|
3
|
+
All rights reserved. Contact: rednaga@protonmail.com
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
This file is part of APKiD
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Commercial License Usage
|
|
10
|
+
------------------------
|
|
11
|
+
Licensees holding valid commercial APKiD licenses may use this file
|
|
12
|
+
in accordance with the commercial license agreement provided with the
|
|
13
|
+
Software or, alternatively, in accordance with the terms contained in
|
|
14
|
+
a written agreement between you and RedNaga.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
GNU General Public License Usage
|
|
18
|
+
--------------------------------
|
|
19
|
+
Alternatively, this file may be used under the terms of the GNU General
|
|
20
|
+
Public License version 3.0 as published by the Free Software Foundation
|
|
21
|
+
and appearing in the file LICENSE.GPL included in the packaging of this
|
|
22
|
+
file. Please visit http://www.gnu.org/copyleft/gpl.html and review the
|
|
23
|
+
information to ensure the GNU General Public License version 3.0
|
|
24
|
+
requirements will be met.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
|
|
29
|
+
from apkid.apkid import Scanner, Options
|
|
30
|
+
from . import __version__
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_parser():
|
|
34
|
+
formatter = lambda prog: argparse.ArgumentDefaultsHelpFormatter(prog, max_help_position=50, width=100)
|
|
35
|
+
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
description=f"APKiD - Android Application Identifier v{__version__}",
|
|
38
|
+
formatter_class=formatter
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument('input', metavar='FILE', type=str, nargs='*',
|
|
41
|
+
help="apk, dex, or directory")
|
|
42
|
+
parser.add_argument('-v', '--verbose', action='store_true',
|
|
43
|
+
help="log debug messages")
|
|
44
|
+
|
|
45
|
+
scanning = parser.add_argument_group('scanning')
|
|
46
|
+
scanning.add_argument('-t', '--timeout', type=int, default=30,
|
|
47
|
+
help="Yara scan timeout (in seconds)")
|
|
48
|
+
scanning.add_argument('-r', '--recursive', action='store_true', default=False,
|
|
49
|
+
help="recurse into subdirectories")
|
|
50
|
+
scanning.add_argument('--scan-depth', type=int, default=2,
|
|
51
|
+
help="how deep to go when scanning nested zips")
|
|
52
|
+
scanning.add_argument('--entry-max-scan-size', type=int, default=100 * 1024 * 1024,
|
|
53
|
+
help="max zip entry size to scan in bytes, 0 = no limit")
|
|
54
|
+
scanning.add_argument('--typing', choices=('magic', 'filename', 'none'), default='magic',
|
|
55
|
+
help="method to decide which files to scan")
|
|
56
|
+
|
|
57
|
+
output = parser.add_argument_group('output')
|
|
58
|
+
output.add_argument('-j', '--json', action='store_true',
|
|
59
|
+
help="output scan results in JSON format", )
|
|
60
|
+
output.add_argument('-o', '--output-dir', metavar='DIR', default=None,
|
|
61
|
+
help="write individual results here (implies --json)")
|
|
62
|
+
output.add_argument('--include-types', action='store_true', default=False,
|
|
63
|
+
help="include file type info for matched files")
|
|
64
|
+
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_options(args) -> Options:
|
|
69
|
+
return Options(
|
|
70
|
+
timeout=args.timeout,
|
|
71
|
+
verbose=args.verbose,
|
|
72
|
+
json=args.json,
|
|
73
|
+
output_dir=args.output_dir,
|
|
74
|
+
typing=args.typing,
|
|
75
|
+
entry_max_scan_size=args.entry_max_scan_size,
|
|
76
|
+
scan_depth=args.scan_depth,
|
|
77
|
+
recursive=args.recursive,
|
|
78
|
+
include_types=args.include_types,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main():
|
|
83
|
+
parser = get_parser()
|
|
84
|
+
args = parser.parse_args()
|
|
85
|
+
options = build_options(args)
|
|
86
|
+
|
|
87
|
+
if not options.output.json:
|
|
88
|
+
print(f"[+] APKiD {__version__} :: from RedNaga :: rednaga.io")
|
|
89
|
+
|
|
90
|
+
rules = options.rules_manager.load()
|
|
91
|
+
scanner = Scanner(rules, options)
|
|
92
|
+
|
|
93
|
+
for input in args.input:
|
|
94
|
+
scanner.scan(input)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == '__main__':
|
|
98
|
+
main()
|
package/apkid/output.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (C) 2023 RedNaga. https://rednaga.io
|
|
3
|
+
All rights reserved. Contact: rednaga@protonmail.com
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
This file is part of APKiD
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Commercial License Usage
|
|
10
|
+
------------------------
|
|
11
|
+
Licensees holding valid commercial APKiD licenses may use this file
|
|
12
|
+
in accordance with the commercial license agreement provided with the
|
|
13
|
+
Software or, alternatively, in accordance with the terms contained in
|
|
14
|
+
a written agreement between you and RedNaga.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
GNU General Public License Usage
|
|
18
|
+
--------------------------------
|
|
19
|
+
Alternatively, this file may be used under the terms of the GNU General
|
|
20
|
+
Public License version 3.0 as published by the Free Software Foundation
|
|
21
|
+
and appearing in the file LICENSE.GPL included in the packaging of this
|
|
22
|
+
file. Please visit http://www.gnu.org/copyleft/gpl.html and review the
|
|
23
|
+
information to ensure the GNU General Public License version 3.0
|
|
24
|
+
requirements will be met.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from typing import Dict, List, Union
|
|
31
|
+
|
|
32
|
+
import yara
|
|
33
|
+
|
|
34
|
+
from .rules import RulesManager
|
|
35
|
+
|
|
36
|
+
prt_red = lambda s: f"\033[91m{s}\033[00m"
|
|
37
|
+
prt_green = lambda s: f"\033[92m{s}\033[00m"
|
|
38
|
+
prt_yellow = lambda s: f"\033[93m{s}\033[00m"
|
|
39
|
+
prt_light_purple = lambda s: f"\033[94m{s}\033[00m"
|
|
40
|
+
prt_purple = lambda s: f"\033[95m{s}\033[00m"
|
|
41
|
+
prt_cyan = lambda s: f"\033[36m{s}\033[00m"
|
|
42
|
+
prt_light_cyan = lambda s: f"\033[96m{s}\033[00m"
|
|
43
|
+
prt_light_gray = lambda s: f"\033[97m{s}\033[00m"
|
|
44
|
+
prt_orange = lambda s: f"\033[33m{s}\033[00m"
|
|
45
|
+
prt_pink = lambda s: f"\033[35m{s}\033[00m"
|
|
46
|
+
|
|
47
|
+
def is_windows_cmd():
|
|
48
|
+
"""
|
|
49
|
+
Check if the current environment is a Windows command prompt.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
bool: True if the operating system is Windows and the SESSIONNAME
|
|
53
|
+
environment variable is not set, indicating a command prompt.
|
|
54
|
+
"""
|
|
55
|
+
return os.name == 'nt' and (
|
|
56
|
+
os.getenv('SESSIONNAME') is None
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def colorize_tag(tag) -> str:
|
|
60
|
+
if tag == 'compiler':
|
|
61
|
+
return prt_cyan(tag)
|
|
62
|
+
elif tag == 'manipulator':
|
|
63
|
+
return prt_light_cyan(tag)
|
|
64
|
+
elif tag == 'abnormal':
|
|
65
|
+
return prt_light_gray(tag)
|
|
66
|
+
elif tag in ['anti_vm', 'anti_disassembly', 'anti_debug', 'anti_root']:
|
|
67
|
+
return prt_purple(tag)
|
|
68
|
+
elif tag in ['packer', 'protector', 'anticheat']:
|
|
69
|
+
return prt_red(tag)
|
|
70
|
+
elif tag == 'obfuscator':
|
|
71
|
+
return prt_yellow(tag)
|
|
72
|
+
elif tag == 'dropper':
|
|
73
|
+
return prt_green(tag)
|
|
74
|
+
elif tag == 'embedded':
|
|
75
|
+
return prt_light_purple(tag)
|
|
76
|
+
elif tag == 'file_type':
|
|
77
|
+
return prt_orange(tag)
|
|
78
|
+
elif tag == 'internal':
|
|
79
|
+
return prt_pink(tag)
|
|
80
|
+
else:
|
|
81
|
+
return tag
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OutputFormatter(object):
|
|
85
|
+
def __init__(self, json_output: bool, output_dir: Union[str, None], rules_manager: RulesManager, include_types: bool):
|
|
86
|
+
from apkid import __version__
|
|
87
|
+
self.output_dir = output_dir
|
|
88
|
+
self.json = json_output or output_dir
|
|
89
|
+
self.version = __version__
|
|
90
|
+
self.rules_hash = rules_manager.hash
|
|
91
|
+
self.include_types = include_types
|
|
92
|
+
|
|
93
|
+
def write(self, results: Dict[str, List[yara.Match]]) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Example yara.Match:
|
|
96
|
+
{
|
|
97
|
+
'tags': ['foo', 'bar'],
|
|
98
|
+
'matches': True,
|
|
99
|
+
'namespace': 'default',
|
|
100
|
+
'rule': 'my_rule',
|
|
101
|
+
'meta': {},
|
|
102
|
+
'strings': [(81L, '$a', 'abc'), (141L, '$b', 'def')]
|
|
103
|
+
}
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
if self.output_dir:
|
|
107
|
+
# Result keys are file paths. Shortest key is base file in the case of archives.
|
|
108
|
+
base_file = sorted(results.keys(), key=lambda k: len(k))[0]
|
|
109
|
+
out_file = os.path.join(self.output_dir, *base_file.split(os.path.sep))
|
|
110
|
+
out_path = os.path.dirname(out_file)
|
|
111
|
+
if not os.path.exists(out_path):
|
|
112
|
+
os.makedirs(out_path)
|
|
113
|
+
output = self.build_json_output(results)
|
|
114
|
+
with open(out_file, 'w') as f:
|
|
115
|
+
f.write(json.dumps(output))
|
|
116
|
+
else:
|
|
117
|
+
if self.json:
|
|
118
|
+
self._print_json(results)
|
|
119
|
+
else:
|
|
120
|
+
self._print_console(results)
|
|
121
|
+
|
|
122
|
+
def build_json_output(self, results: Dict[str, List[yara.Match]]):
|
|
123
|
+
output = {
|
|
124
|
+
'apkid_version': self.version,
|
|
125
|
+
'rules_sha256': self.rules_hash,
|
|
126
|
+
'files': [],
|
|
127
|
+
}
|
|
128
|
+
for filename, matches in results.items():
|
|
129
|
+
match_results = self._build_match_results(matches)
|
|
130
|
+
if len(match_results) == 0:
|
|
131
|
+
continue
|
|
132
|
+
result = {
|
|
133
|
+
'filename': filename,
|
|
134
|
+
'matches': match_results,
|
|
135
|
+
}
|
|
136
|
+
output['files'].append(result)
|
|
137
|
+
return output
|
|
138
|
+
|
|
139
|
+
def _print_json(self, results: Dict[str, List[yara.Match]]) -> None:
|
|
140
|
+
output = self.build_json_output(results)
|
|
141
|
+
print(json.dumps(output, sort_keys=True))
|
|
142
|
+
|
|
143
|
+
def _print_console(self, results: Dict[str, List[yara.Match]]) -> None:
|
|
144
|
+
for key, raw_matches in results.items():
|
|
145
|
+
match_results = self._build_match_results(raw_matches)
|
|
146
|
+
if len(match_results) == 0:
|
|
147
|
+
continue
|
|
148
|
+
print(f"[*] {key}")
|
|
149
|
+
for tags in sorted(match_results):
|
|
150
|
+
descriptions = ', '.join(sorted(match_results[tags]))
|
|
151
|
+
if sys.stdout.isatty() and not is_windows_cmd():
|
|
152
|
+
tags_str = OutputFormatter._colorize_tags(tags)
|
|
153
|
+
else:
|
|
154
|
+
tags_str = tags
|
|
155
|
+
print(f" |-> {tags_str} : {descriptions}")
|
|
156
|
+
|
|
157
|
+
def _build_match_results(self, matches) -> Dict[str, List[str]]:
|
|
158
|
+
results: Dict[str, List[str]] = {}
|
|
159
|
+
for m in matches:
|
|
160
|
+
if 'file_type' in m.tags and not self.include_types:
|
|
161
|
+
continue
|
|
162
|
+
tags = ', '.join(sorted(m.tags))
|
|
163
|
+
description = m.meta.get('description', m)
|
|
164
|
+
if tags in results:
|
|
165
|
+
if description not in results[tags]:
|
|
166
|
+
results[tags].append(description)
|
|
167
|
+
else:
|
|
168
|
+
results[tags] = [description]
|
|
169
|
+
return results
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _colorize_tags(tags) -> str:
|
|
173
|
+
colored_tags = []
|
|
174
|
+
for tag in tags.split(', '):
|
|
175
|
+
colored_tag = colorize_tag(tag)
|
|
176
|
+
colored_tags.append(colored_tag)
|
|
177
|
+
return ', '.join(colored_tags)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2023 RedNaga. https://rednaga.io
|
|
3
|
+
* All rights reserved. Contact: rednaga@protonmail.com
|
|
4
|
+
*
|
|
5
|
+
*
|
|
6
|
+
* This file is part of APKiD
|
|
7
|
+
*
|
|
8
|
+
*
|
|
9
|
+
* Commercial License Usage
|
|
10
|
+
* ------------------------
|
|
11
|
+
* Licensees holding valid commercial APKiD licenses may use this file
|
|
12
|
+
* in accordance with the commercial license agreement provided with the
|
|
13
|
+
* Software or, alternatively, in accordance with the terms contained in
|
|
14
|
+
* a written agreement between you and RedNaga.
|
|
15
|
+
*
|
|
16
|
+
*
|
|
17
|
+
* GNU General Public License Usage
|
|
18
|
+
* --------------------------------
|
|
19
|
+
* Alternatively, this file may be used under the terms of the GNU General
|
|
20
|
+
* Public License version 3.0 as published by the Free Software Foundation
|
|
21
|
+
* and appearing in the file LICENSE.GPL included in the packaging of this
|
|
22
|
+
* file. Please visit http://www.gnu.org/copyleft/gpl.html and review the
|
|
23
|
+
* information to ensure the GNU General Public License version 3.0
|
|
24
|
+
* requirements will be met.
|
|
25
|
+
*
|
|
26
|
+
**/
|
|
27
|
+
|
|
28
|
+
rule is_apk : file_type
|
|
29
|
+
{
|
|
30
|
+
meta:
|
|
31
|
+
description = "APK"
|
|
32
|
+
|
|
33
|
+
strings:
|
|
34
|
+
$zip_head = "PK"
|
|
35
|
+
$manifest = "AndroidManifest.xml"
|
|
36
|
+
|
|
37
|
+
condition:
|
|
38
|
+
$zip_head at 0 and $manifest and #manifest >= 2
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private rule is_signed_apk : internal
|
|
42
|
+
{
|
|
43
|
+
meta:
|
|
44
|
+
description = "Resembles a signed APK that is likely not corrupt"
|
|
45
|
+
|
|
46
|
+
strings:
|
|
47
|
+
$meta_inf = "META-INF/"
|
|
48
|
+
$ext_rsa = ".RSA"
|
|
49
|
+
$ext_dsa = ".DSA"
|
|
50
|
+
$ext_ec = ".EC"
|
|
51
|
+
$apk_sig_block_footer = { 41 50 4B 20 53 69 67 20 42 6C 6F 63 6B 20 34 32 50 4B 01 02 }
|
|
52
|
+
|
|
53
|
+
condition:
|
|
54
|
+
is_apk and
|
|
55
|
+
(
|
|
56
|
+
for all of ($meta_inf*) : ($ext_rsa or $ext_dsa or $ext_ec in (@ + 9..@ + 9 + 100)) or
|
|
57
|
+
$apk_sig_block_footer
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private rule is_unsigned_apk : internal
|
|
62
|
+
{
|
|
63
|
+
meta:
|
|
64
|
+
description = "Resembles an unsigned APK that is likely not corrupt"
|
|
65
|
+
|
|
66
|
+
condition:
|
|
67
|
+
is_apk and not is_signed_apk
|
|
68
|
+
}
|