-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpSlip.py
More file actions
6091 lines (5364 loc) · 254 KB
/
Copy pathpSlip.py
File metadata and controls
6091 lines (5364 loc) · 254 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
import sys
import json
import subprocess
import os
import textwrap
import re
import zipfile
import multiprocessing
from multiprocessing import Pool
def _bootstrap_dependencies():
"""Ensure pSlip's third-party Python packages are importable; install any
that are missing via pip, bootstrapping pip with ensurepip when needed and
falling back to a --user install. This lets pSlip run from a clean Windows
Python with no manual `pip install` step. It is best-effort and transparent:
it acts only when a package is actually missing, prints what it is doing, and
prints clear manual instructions if it cannot install. Set
PSLIP_NO_AUTOINSTALL=1 to disable. (External tools apktool/jadx are NOT
installed here - they are optional; androguard handles manifest analysis and
only the AES pass needs them.)
"""
import importlib.util
required = {'tqdm': 'tqdm', 'androguard': 'androguard'} # import name -> pip name
def _missing():
out = []
for mod, pip_name in required.items():
try:
present = importlib.util.find_spec(mod) is not None
except Exception:
present = False
if not present:
out.append(pip_name)
return out
missing = _missing()
if not missing:
return
if (os.environ.get('PSLIP_NO_AUTOINSTALL') or '').strip().lower() in ('1', 'true', 'yes'):
sys.stderr.write(
"[pSlip] Missing dependencies: %s (auto-install disabled).\n"
"[pSlip] Install with: %s -m pip install %s\n"
% (' '.join(missing), sys.executable, ' '.join(missing)))
sys.exit(1)
# Only attempt the install once per invocation (guards against a loop if the
# install reports success but the package still will not import).
if os.environ.get('PSLIP_DEP_BOOTSTRAP') == '1':
sys.stderr.write(
"[pSlip] Still missing after install attempt: %s\n"
"[pSlip] Install manually then re-run: %s -m pip install %s\n"
% (' '.join(missing), sys.executable, ' '.join(missing)))
sys.exit(1)
print("[pSlip] Missing Python dependencies: %s. Attempting to install..."
% ', '.join(missing))
# Clean Microsoft Store / embedded Pythons can ship without pip.
try:
if importlib.util.find_spec('pip') is None:
print("[pSlip] pip not found; bootstrapping with ensurepip...")
subprocess.run([sys.executable, '-m', 'ensurepip', '--upgrade'], check=False)
except Exception:
pass
pip_base = [sys.executable, '-m', 'pip', 'install', '--disable-pip-version-check']
try:
rc = subprocess.run(pip_base + missing).returncode
except Exception:
rc = 1
if rc != 0:
# Permission-restricted environments (e.g. Store Python) often need --user.
print("[pSlip] Retrying with a per-user install (--user)...")
try:
subprocess.run(pip_base + ['--user'] + missing, check=False)
except Exception:
pass
# Re-run in a fresh process so a newly created site/user-site is on sys.path
# (importing immediately after a runtime install in the same process is
# unreliable). The child inherits PSLIP_DEP_BOOTSTRAP=1 so it will not loop.
os.environ['PSLIP_DEP_BOOTSTRAP'] = '1'
try:
sys.exit(subprocess.run([sys.executable] + sys.argv).returncode)
except Exception:
importlib.invalidate_caches()
if _missing():
print("[pSlip] Dependencies installed. Please re-run pSlip.")
sys.exit(0)
_bootstrap_dependencies()
from tqdm import tqdm
from datetime import datetime
import xml.etree.ElementTree as ET
import platform
import shutil
import tempfile
import signal
def _env_int(name, default):
"""Read a positive int from the environment, else return default. Read at
import time so it is consistent across fork AND spawn worker processes
(spawn re-imports this module and inherits the parent's environment)."""
try:
v = int((os.environ.get(name) or '').strip())
return v if v > 0 else default
except Exception:
return default
# Hard caps for external decoders so one malformed/adversarial APK cannot hang
# the whole scan (apktool/jadx can spin indefinitely on crafted input). On
# expiry the entire child process tree is killed (see _run_tool/_kill_tree) and
# the call sites treat it as a decode failure for that APK. Override via env:
# PSLIP_APKTOOL_TIMEOUT / PSLIP_JADX_TIMEOUT (seconds).
APKTOOL_TIMEOUT_SECONDS = _env_int('PSLIP_APKTOOL_TIMEOUT', 240)
JADX_TIMEOUT_SECONDS = _env_int('PSLIP_JADX_TIMEOUT', 300)
# ============================================================
# Platform detection + cross-platform shims (Linux / Windows)
# ============================================================
IS_WINDOWS = (os.name == 'nt')
IS_MACOS = (sys.platform == 'darwin')
PLATFORM_NAME = platform.system() or os.name
# Console may use a legacy code page (e.g. cp1252 on Windows) that cannot encode
# the banner's box-drawing glyphs or non-ASCII strings extracted from APKs.
# Force UTF-8 with replacement so a stray character never crashes a print.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
def _enable_windows_vt():
"""Enable ANSI/VT escape processing in modern Windows consoles
(Windows 10 1511+). Returns True on success."""
try:
import ctypes
kernel32 = ctypes.windll.kernel32
for handle_id in (-11, -12): # STDOUT, STDERR
h = kernel32.GetStdHandle(handle_id)
if not h or h == ctypes.c_void_p(-1).value:
continue
mode = ctypes.c_uint32()
if not kernel32.GetConsoleMode(h, ctypes.byref(mode)):
continue
# ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
kernel32.SetConsoleMode(h, mode.value | 0x0004)
return True
except Exception:
return False
def _supports_color():
"""Decide whether ANSI color is safe to emit on this platform/stream."""
if os.environ.get('NO_COLOR'):
return False
if os.environ.get('PSLIP_FORCE_COLOR'):
return True
try:
if not sys.stdout.isatty():
return False # piped/redirected -> emit clean text, no escape codes
except Exception:
return False
if IS_WINDOWS:
return _enable_windows_vt()
return True
if _supports_color():
RESET = "\033[0m"
YELLOW = "\033[93m"
GREEN = "\033[92m"
CYAN = "\033[96m"
RED = "\033[91m"
BOLD = "\033[1m"
else:
RESET = YELLOW = GREEN = CYAN = RED = BOLD = ""
# Optional explicit overrides so pSlip can use a jadx/apktool that is not on
# PATH. Set via env var (PSLIP_JADX / PSLIP_APKTOOL) or the -jadx / -apktool
# flags. The value may be the CLI launcher itself, a directory to search, or
# even the GUI build (we then look for the real CLI next to it).
_TOOL_ENV = {'jadx': 'PSLIP_JADX', 'jadx-cli': 'PSLIP_JADX',
'apktool': 'PSLIP_APKTOOL', 'aapt': 'PSLIP_AAPT', 'aapt2': 'PSLIP_AAPT2'}
def _cli_launcher_candidates(name):
base = 'jadx' if name in ('jadx', 'jadx-cli') else name
return [base + '.bat', base + '.cmd', base + '.exe', base]
def _find_cli_in_dir(directory, name):
"""Search a directory for a tool's command-line launcher, skipping GUI
launchers (e.g. jadx-gui). Looks in the directory, its bin/, and one level
of child dirs (so pointing at an extracted jadx-x.y.z/ folder works too).
Returns a full path or None."""
if not directory or not os.path.isdir(directory):
return None
cands = _cli_launcher_candidates(name)
search = [directory, os.path.join(directory, 'bin')]
try:
for child in sorted(os.listdir(directory)):
cp = os.path.join(directory, child)
if os.path.isdir(cp):
search.append(cp)
search.append(os.path.join(cp, 'bin'))
except Exception:
pass
for base in search:
for fn in cands:
cand = os.path.join(base, fn)
if os.path.isfile(cand) and 'gui' not in os.path.basename(cand).lower():
return cand
return None
def _resolve_tool(name):
"""Resolve an external tool to a full path. A PSLIP_<TOOL> override (env var
or -jadx/-apktool flag) is honored first: it may be the CLI launcher, a
directory to search, or the GUI build (we then look for the CLI next to it).
If no override resolves, fall back to PATH. On Windows, PATHEXT lets
shutil.which find apktool.bat / jadx.cmd / aapt.exe. Returns None if not
found."""
env = _TOOL_ENV.get(name)
override = (os.environ.get(env) or '').strip().strip('"').strip("'") if env else ''
if override:
if os.path.isdir(override):
hit = _find_cli_in_dir(override, name)
if hit:
return hit
elif os.path.isfile(override):
if 'gui' in os.path.basename(override).lower():
hit = _find_cli_in_dir(os.path.dirname(override), name)
if hit:
return hit
else:
return override
# Override set but no usable CLI found - fall through to PATH.
return shutil.which(name)
def _kill_tree(proc):
"""Hard-kill a process and all of its descendants.
subprocess's own timeout only kills the direct child (the apktool/jadx
launcher script); its JVM grandchild would be orphaned and keep running.
On POSIX we start the child in its own session and kill the whole process
group; on Windows we taskkill the tree.
"""
try:
if IS_WINDOWS:
subprocess.run(['taskkill', '/F', '/T', '/PID', str(proc.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
proc.kill()
except Exception:
try:
proc.kill()
except Exception:
pass
def _run_tool(args, timeout=None, **kwargs):
"""Cross-platform external-tool runner.
args[0] is a tool NAME (e.g. 'apktool'); it is resolved via PATH so the
correct platform wrapper is used. On Windows, .bat/.cmd wrappers cannot be
launched by CreateProcess directly, so they are run through 'cmd /c'.
If `timeout` is given, the child runs in its own process group/session so
that on expiry the ENTIRE tree (including the JVM grandchild) is killed,
then TimeoutExpired is re-raised for the caller to handle. Without a
timeout, behaves like a plain subprocess.run.
Returns a CompletedProcess, or None if the tool could not be found.
"""
exe = _resolve_tool(args[0])
if not exe:
return None
real = [exe] + [str(a) for a in args[1:]]
if IS_WINDOWS and exe.lower().endswith(('.bat', '.cmd')):
real = ['cmd', '/c'] + real
if timeout is None:
return subprocess.run(real, **kwargs)
# Timeout path: own process group/session so we can kill the whole tree.
if IS_WINDOWS:
kwargs['creationflags'] = kwargs.get('creationflags', 0) | getattr(
subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
else:
kwargs['start_new_session'] = True
proc = subprocess.Popen(real, **kwargs)
try:
stdout, stderr = proc.communicate(timeout=timeout)
return subprocess.CompletedProcess(real, proc.returncode, stdout, stderr)
except subprocess.TimeoutExpired:
_kill_tree(proc)
try:
proc.communicate(timeout=10) # reap; drain pipes so nothing blocks
except Exception:
pass
raise
def _proc_fail_reason(proc, name):
"""Build a concise failure reason from a finished subprocess.
Many Windows tool launchers (jadx.bat in particular) print their fatal
error (e.g. "no 'java' command could be found") to STDOUT via batch echo,
not STDERR. Surfacing stderr alone then leaves only a bare "exit N". This
combines both streams, collapses whitespace, and truncates so the real
cause (missing Java, bad APK, OOM) is visible in the warning line.
"""
def _dec(b):
if not b:
return ""
try:
return b.decode(errors='ignore')
except Exception:
return ""
err = _dec(getattr(proc, 'stderr', b'')).strip()
out = _dec(getattr(proc, 'stdout', b'')).strip()
msg = err or out
if err and out and out not in err:
msg = err + " | " + out
msg = " ".join(msg.split())
if not msg:
return f"{name} exit {proc.returncode}"
if len(msg) > 300:
msg = msg[:220] + " ... " + msg[-60:]
return f"{name} exit {proc.returncode}: {msg}"
def _rmtree(path):
"""Cross-platform recursive delete (replacement for `rm -rf`).
On Windows, read-only files (common in extracted APK trees) can block
deletion, so retry after clearing the read-only bit."""
if not path or not os.path.exists(path):
return
try:
shutil.rmtree(path, ignore_errors=True)
except Exception:
pass
if os.path.exists(path):
def _on_error(func, p, exc_info):
try:
import stat
os.chmod(p, stat.S_IWRITE)
func(p)
except Exception:
pass
try:
shutil.rmtree(path, onerror=_on_error)
except Exception:
pass
def _sweep_stale_temp(max_age_minutes=30):
"""Reclaim leaked container-extraction dirs from prior crashed runs.
gather_apk_inputs extracts .xapk/.apks/.apkm into a tempfile.mkdtemp under
the system temp dir. If a run crashes before its end-of-run cleanup, that
dir (often gigabytes of extracted APKs) leaks. Across many crashed runs the
temp drive fills, after which new extractions silently fail and containers
drop out of the scan. This best-effort sweep removes pslip_xapk_* dirs that
have not been touched in max_age_minutes, so an actively running concurrent
extraction is left alone while stale leaks are cleared."""
try:
import tempfile as _tf
import glob as _glob
import time as _time
base = _tf.gettempdir()
cutoff = _time.time() - max_age_minutes * 60
for d in _glob.glob(os.path.join(base, "pslip_xapk_*")):
try:
if os.path.isdir(d) and os.path.getmtime(d) < cutoff:
_rmtree(d)
except Exception:
pass
except Exception:
pass
def _tool_status():
"""Detect optional external tools on this platform. androguard (Python) is
the primary engine for manifest + OAuth analysis; apktool/jadx are only
needed for the AES/DES source-decompile pass."""
status = {}
for name in ('apktool', 'jadx', 'jadx-cli', 'aapt', 'aapt2'):
status[name] = _resolve_tool(name)
try:
import androguard # noqa: F401
status['androguard'] = getattr(androguard, '__version__', 'installed')
except Exception:
status['androguard'] = None
return status
def _print_environment():
"""Report detected platform and tool availability so behaviour is
transparent on whichever OS pSlip is launched from."""
import multiprocessing as _mp
st = _tool_status()
try:
cpus = _mp.cpu_count()
except Exception:
cpus = 1
py = sys.version.split()[0]
print(f"{BOLD}Environment:{RESET} {PLATFORM_NAME} | Python {py} | {cpus} CPU(s) | "
f"start method: {_mp.get_start_method(allow_none=True) or 'default'}")
def mark(v):
return (f"{GREEN}found{RESET}" if v else f"{YELLOW}missing{RESET}")
androguard_ok = bool(st.get('androguard'))
jadx_ok = bool(st.get('jadx') or st.get('jadx-cli'))
print(f" androguard (primary manifest + OAuth engine): {mark(androguard_ok)}"
+ (f" v{st['androguard']}" if isinstance(st.get('androguard'), str) else ""))
print(f" apktool (manifest fallback + AES deep pass): {mark(st.get('apktool'))}")
print(f" jadx (optional AES deep pass: -aes-deep): {mark(jadx_ok)}")
print(f" aapt/aapt2 (package-name fallback): {mark(st.get('aapt') or st.get('aapt2'))}")
if not androguard_ok and not st.get('apktool'):
print(f"{RED}Warning: neither androguard nor apktool is available - manifest analysis "
f"will not work. Install androguard (pip) for the primary engine.{RESET}")
elif androguard_ok:
print(f"{GREEN}Manifest analysis will use androguard (no apktool/Java required).{RESET}")
_deep = os.environ.get('PSLIP_AES_DEEP') == '1'
if androguard_ok and not _deep:
print(f"{GREEN}AES/DES/IV detection: androguard DEX bytecode scan "
f"(no Java; add -aes-deep for the jadx source pass).{RESET}")
if _deep and not jadx_ok and not st.get('apktool'):
print(f"{YELLOW}Note: -aes-deep needs jadx or apktool; neither found, the deep AES pass will be skipped.{RESET}")
# Explicit-override diagnostics (PSLIP_JADX / PSLIP_APKTOOL or -jadx/-apktool).
for tool, envk in (('jadx', 'PSLIP_JADX'), ('apktool', 'PSLIP_APKTOOL')):
ov = (os.environ.get(envk) or '').strip().strip('"').strip("'")
if not ov:
continue
resolved = _resolve_tool(tool)
if resolved:
print(f"{GREEN}Using {tool} from {envk}: {resolved}{RESET}")
elif 'gui' in os.path.basename(ov).lower():
print(f"{RED}{envk} points at the jadx GUI build ({os.path.basename(ov)}), which cannot "
f"decompile from the command line. Download the CLI bundle 'jadx-<ver>.zip' "
f"(it contains bin/jadx.bat) and point {envk} at that instead.{RESET}")
else:
print(f"{YELLOW}{envk} is set ({ov}) but no usable {tool} CLI was found there.{RESET}")
print()
BANNER = f"""
{YELLOW}
██████╗ ███████╗██╗ ██╗██████╗
██╔══██╗██╔════╝██║ ██║██╔══██╗
██████╔╝███████╗██║ ██║██████╔╝
██╔═══╝ ╚════██║██║ ██║██╔═══╝
██║ ███████║███████╗██║██║
╚═╝ ╚══════╝╚═╝╚═╝
{RESET}{BOLD}
Version 1.3.5 | https://actuator.sh/
{RESET}
"""
def print_help():
print(BANNER)
print(textwrap.dedent(f"""\
{BOLD}What it does:{RESET}
Static analyzer for Android APKs and split bundles (.xapk/.apks/.apkm).
Flags, each with a PoC:
- exported components / ContentProviders; unsafe CALL, VIEW+javascript:, wildcard deep links
- manifest hardening (cleartext, allowBackup, debuggable)
- OAuth redirect scheme-hijack, incl. client_secret shipped in the APK
- hardcoded AES/DES keys and IVs (DEX bytecode)
androguard-based, no Java; jadx/apktool only for -aes-deep.
{BOLD}Usage:{RESET} python pSlip.py <apk/xapk/apks/apkm or directory> [-all] [-allsafe] [-html <output_file>] [-json <output_file>] [-oauth-poc]
{BOLD}Inputs:{RESET}
Accepts a single .apk, a split-APK container (.xapk / .apks / .apkm), or a
directory (scanned recursively for all of the above). Containers are expanded
automatically: the base APK and any code-bearing feature splits are analyzed;
config / ABI / resource-only splits are skipped.
{BOLD}Scan Modes:{RESET}
-all Run full analysis, including the AES/DES/IV key pass
-allsafe Run full analysis but skip the AES/DES/IV key pass (faster)
-aes-deep Run the key pass via jadx/apktool source decompilation
instead of the default androguard DEX bytecode scan.
Slower and needs jadx or apktool, but can resolve keys
assembled across branches or loaded from static fields.
-aes-timeout <m> Per-APK time limit for the key pass, in minutes (default 5)
{BOLD}OAuth scheme-hijack:{RESET}
(detection is always-on; no flag needed to find OAuth scheme-hijack issues)
-oauth-poc Also generate buildable Android PoC projects for OAuth findings
-oauth-poc-dir <dir> Output directory for OAuth PoC projects (default: pslip_oauth_pocs)
{BOLD}Output Options:{RESET}
-html <file> Save the vulnerability report as an HTML file
-json <file> Save the vulnerability report as a JSON file
{BOLD}External tools (optional; for the AES/DES pass only):{RESET}
androguard (pip) handles manifest + OAuth analysis with no Java. The AES/DES
source-decompile pass needs the jadx or apktool COMMAND-LINE tool. If they are
not on PATH, point pSlip at them:
-jadx <path> Path to the jadx CLI (jadx.bat / jadx), a directory to search,
or the jadx GUI build (the CLI next to it is used). NOTE: the
'jadx-gui-*-with-jre-win' bundle is GUI-only and has no CLI -
use the cross-platform 'jadx-<ver>.zip' bundle (bin/jadx.bat).
-apktool <path> Path to the apktool CLI (apktool.bat / apktool) or its directory
(equivalent env vars: PSLIP_JADX / PSLIP_APKTOOL)
{BOLD}Environment:{RESET}
PSLIP_APKTOOL_TIMEOUT / PSLIP_JADX_TIMEOUT per-APK decoder caps in seconds
(defaults 240 / 300). On expiry the whole decoder process tree is killed and
that APK is skipped, so one hung/crafted APK cannot stall a large batch.
PSLIP_NO_AUTOINSTALL=1 disable auto-install of missing Python dependencies.
"""))
def command_exists(command):
return shutil.which(command) is not None
ANDROID_NS = 'http://schemas.android.com/apk/res/android'
def _has_inline_call_gate(elem):
perm = (elem.get(f'{{{ANDROID_NS}}}permission') or '').strip()
return perm in (
'android.permission.CALL_PHONE',
'android.permission.CALL_PRIVILEGED',
'android.permission.CALL_EMERGENCY',
)
def check_manifest_hardening(root, package_name, target_sdk_version):
"""
perform cheap manifest-level hardening checks.
This runs by default (no CLI flag) because it is effectively free compared
to bytecode/AES scanning and only walks the already-parsed manifest tree.
"""
vulnerabilities = []
if root is None or not package_name:
return vulnerabilities
application = root.find('application')
if application is None:
return vulnerabilities
# --- android:allowBackup ---
allow_backup = application.get(f'{{{ANDROID_NS}}}allowBackup')
if allow_backup is None or allow_backup.strip().lower() != 'false':
details = (
'android:allowBackup is not explicitly set to "false" on the '
'<application> tag. This can allow device/ADB backups to include '
'app data. For production builds, explicitly set '
'android:allowBackup="false" unless backups are strictly '
'required and carefully reviewed.'
)
vulnerabilities.append({
'package_name': package_name,
'Component': f'{package_name}/Application',
'Issue Type': 'Hardening: Insecure Backup (android:allowBackup)',
'Details': details,
'Severity': 'Medium',
'Confidence': 80,
'ADB Command': f'adb backup -f {package_name}.ab {package_name}',
})
# --- android:debuggable ---
debuggable = application.get(f'{{{ANDROID_NS}}}debuggable')
if debuggable is not None and debuggable.strip().lower() == 'true':
details = (
'android:debuggable="true" is set on the <application> tag. '
'Release builds should not be debuggable, as this allows runtime '
'inspection and debugging of the app on production devices.'
)
vulnerabilities.append({
'package_name': package_name,
'Component': f'{package_name}/Application',
'Issue Type': 'Hardening: Debuggable Application',
'Details': details,
'Severity': 'High',
'Confidence': 90,
'ADB Command': 'N/A',
})
# --- android:usesCleartextTraffic ---
uses_cleartext = application.get(f'{{{ANDROID_NS}}}usesCleartextTraffic')
if uses_cleartext is not None and uses_cleartext.strip().lower() == 'true':
details = (
'android:usesCleartextTraffic="true" allows cleartext (HTTP) '
'traffic. Prefer HTTPS for all network calls and consider using '
'a Network Security Config to explicitly limit any required '
'cleartext endpoints.'
)
vulnerabilities.append({
'package_name': package_name,
'Component': f'{package_name}/Application',
'Issue Type': 'Hardening: Cleartext Traffic Allowed',
'Details': details,
'Severity': 'Medium',
'Confidence': 80,
'ADB Command': 'N/A',
})
# --- Exported ContentProvider without permissions ---
providers = application.findall('provider')
for provider in providers:
name = provider.get(f'{{{ANDROID_NS}}}name') or ''
if not name:
continue
exported = is_exported(provider, target_sdk_version)
if not exported:
continue
perm = (provider.get(f'{{{ANDROID_NS}}}permission') or '').strip()
read_perm = (provider.get(f'{{{ANDROID_NS}}}readPermission') or '').strip()
write_perm = (provider.get(f'{{{ANDROID_NS}}}writePermission') or '').strip()
if not perm and not read_perm and not write_perm:
comp_name = f'{package_name}/{name}'
authority = (provider.get('authorities') or '').strip()
details = (
'Exported ContentProvider without any read/write permission. '
'Other applications may be able to query or modify its data.'
)
if authority:
details += f' Authority: "{authority}".'
vulnerabilities.append({
'package_name': package_name,
'Component': comp_name,
'Issue Type': 'Hardening: Exposed ContentProvider',
'Details': details,
'Severity': 'High',
'Confidence': 80,
'ADB Command': (
f'adb shell content query --uri content://{authority}'
if authority else 'N/A'
),
})
return vulnerabilities
def _extract_manifest_androguard(apk_file, base_dir):
"""Write a text AndroidManifest.xml using androguard (pure-Python, no
apktool/Java needed). The android namespace round-trips through
serialization, so pSlip's ElementTree parsers read attributes via
{ANDROID_NS}attr exactly as with apktool output (verified on real APKs).
Returns the manifest path, or None if androguard is unavailable/failed.
"""
try:
from androguard.core.apk import APK
from lxml import etree as _L
except Exception:
return None
try:
root = APK(apk_file).get_android_manifest_xml()
if root is None:
return None
data = _L.tostring(root, encoding='utf-8')
if not data:
return None
os.makedirs(base_dir, exist_ok=True)
manifest_file = os.path.join(base_dir, 'AndroidManifest.xml')
with open(manifest_file, 'wb') as fh:
fh.write(data)
return manifest_file
except Exception:
return None
def extract_manifest(apk_file, base_dir):
if os.path.exists(base_dir):
_rmtree(base_dir)
if os.path.exists(base_dir):
print(f"{RED}Error: Failed to remove existing directory '{base_dir}'.{RESET}")
return None
# Primary engine: androguard (pure-Python; no apktool/Java required). It
# reads only the manifest from the APK, so it is also far faster than a full
# apktool decode. apktool remains the fallback below.
mf = _extract_manifest_androguard(apk_file, base_dir)
if mf is not None:
return mf
# Fallback: apktool (decodes the whole APK to disk).
if os.path.exists(base_dir):
_rmtree(base_dir)
try:
proc = _run_tool(['apktool', 'd', '-f', '-o', base_dir, apk_file],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=APKTOOL_TIMEOUT_SECONDS)
if proc is None:
print(f"{RED}Error: could not extract the manifest for '{apk_file}': androguard "
f"could not parse it and apktool is not on PATH. Install androguard (pip) "
f"or apktool.{RESET}")
return None
if proc.returncode != 0:
err = (proc.stderr or b'').decode(errors='ignore')
print(f"{RED}Error: Failed to extract APK file '{apk_file}': {err}{RESET}")
return None
except Exception as e:
print(f"{RED}Error: Failed to extract APK file '{apk_file}': {e}{RESET}")
return None
manifest_file = os.path.join(base_dir, 'AndroidManifest.xml')
if not os.path.exists(manifest_file):
print(f"{RED}Error: Failed to find the extracted manifest file for '{apk_file}'.{RESET}")
return None
return manifest_file
def get_target_sdk_version(manifest_root):
try:
uses_sdk = manifest_root.find('uses-sdk')
if uses_sdk is not None:
target_sdk = uses_sdk.get('{http://schemas.android.com/apk/res/android}targetSdkVersion')
if target_sdk is not None:
return int(target_sdk)
except Exception:
pass
except Exception as e:
print(f"{RED}Error: Unable to extract targetSdkVersion: {e}{RESET}")
return None
def get_package_name(manifest_root):
try:
package_name = manifest_root.attrib.get('package')
return package_name
except Exception:
pass
except Exception as e:
print(f"{RED}Error: An unexpected error occurred while extracting package name: {e}{RESET}")
return None
def is_exported(component, target_sdk_version):
android_ns = 'http://schemas.android.com/apk/res/android'
exported = component.get(f'{{{android_ns}}}exported')
if exported is not None:
return exported.lower() == 'true'
else:
if target_sdk_version is not None and target_sdk_version < 31:
has_intent_filter = component.find('intent-filter') is not None
return has_intent_filter
else:
return False
def format_component_name(package_name, component_name):
"""
returns a string like:
- 'com.example.app/.MainActivity' if component_name = '.MainActivity'
- 'com.example.app/SomeActivity' if it's not dotted
"""
if component_name.startswith('.'):
return f"{package_name}{component_name}"
return f"{package_name}/{component_name}"
def collect_real_activities_export_status(application, package_name, target_sdk_version):
"""
build a map of real <activity> fully-qualified names -> bool exported.
this helps us verify if the underlying activity of an <activity-alias> is also exported.
"""
android_ns = 'http://schemas.android.com/apk/res/android'
activity_map = {}
for act in application.findall('activity'):
act_name = act.get(f'{{{android_ns}}}name')
if not act_name:
continue
fq_name = format_component_name(package_name, act_name)
activity_map[fq_name] = is_exported(act, target_sdk_version)
return activity_map
def find_dangerous_components(manifest_file, target_sdk_version, check_js, check_call):
dangerous_components = {}
android_ns = "http://schemas.android.com/apk/res/android"
ET.register_namespace('android', android_ns)
try:
tree = ET.parse(manifest_file)
root = tree.getroot()
except Exception:
return dangerous_components
package_name = get_package_name(root)
application = root.find('application')
if application is None:
return dangerous_components
real_activities_map = collect_real_activities_export_status(
application, package_name, target_sdk_version
)
component_types = ["activity", "activity-alias", "service", "receiver"]
for component_type in component_types:
for component in application.findall(component_type):
comp_name = component.get(f"{{{android_ns}}}name")
if not comp_name:
continue
fq_name = format_component_name(package_name, comp_name)
# Determine if exported
if component_type == "activity-alias":
alias_exported = is_exported(component, target_sdk_version)
target_name = component.get(f"{{{android_ns}}}targetActivity")
if not target_name:
continue
fq_target = format_component_name(package_name, target_name)
underlying_exported = real_activities_map.get(fq_target, False)
exported = alias_exported and underlying_exported
else:
exported = is_exported(component, target_sdk_version)
if not exported:
continue
intent_filters = component.findall("intent-filter")
# Exported but no intent-filters → ONLY dangerous pre-API21
if not intent_filters:
if target_sdk_version is not None and target_sdk_version < 21:
dangerous_components[fq_name] = {
"component_type": component_type,
"intent_filters": [],
"is_call_vulnerable": False,
"is_js_vulnerable": False,
"is_http_open_vulnerable": False,
"no_intent_filter": True,
"custom_exported": True,
}
continue
# Exported with intent-filters
if fq_name not in dangerous_components:
dangerous_components[fq_name] = {
"component_type": component_type,
"intent_filters": [],
"is_call_vulnerable": False,
"is_js_vulnerable": False,
"is_http_open_vulnerable": False,
"no_intent_filter": False,
"custom_exported": True,
}
# Check dangerous filters
for intent_filter in intent_filters:
actions = intent_filter.findall("action")
data_tags = intent_filter.findall("data")
is_call_vuln = False
is_js_vuln = False
is_http_vuln = False
# CALL
if check_call:
for action in actions:
action_name = action.get(f"{{{android_ns}}}name")
if action_name in (
"android.intent.action.CALL",
"android.intent.action.CALL_PRIVILEGED",
):
comp_perm = (component.get(
f"{{{android_ns}}}permission") or "").strip()
if comp_perm not in (
"android.permission.CALL_PHONE",
"android.permission.CALL_PRIVILEGED",
"android.permission.CALL_EMERGENCY",
):
is_call_vuln = True
break
# JS
if check_js:
for data in data_tags:
scheme = (data.get(f"{{{android_ns}}}scheme") or "").lower()
mime = (data.get(f"{{{android_ns}}}mimeType") or "").lower()
if scheme == "javascript" or mime == "text/javascript":
is_js_vuln = True
break
# HTTP Redirect
for data in data_tags:
scheme = (data.get(f"{{{android_ns}}}scheme") or "").lower()
host = (data.get(f"{{{android_ns}}}host") or "").strip()
if scheme in ("http", "https") and host in ("", "*"):
is_http_vuln = True
break
dangerous_components[fq_name]["intent_filters"].append(
ET.tostring(intent_filter, encoding="unicode")
)
dangerous_components[fq_name]["is_call_vulnerable"] |= is_call_vuln
dangerous_components[fq_name]["is_js_vulnerable"] |= is_js_vuln
dangerous_components[fq_name]["is_http_open_vulnerable"] |= is_http_vuln
return dangerous_components
def find_permissions(manifest_file, apk_name, collect_vulnerabilities, package_name):
permissions = []
new_vulnerabilities = []
normal_protection_permissions = []
try:
tree = ET.parse(manifest_file)
root = tree.getroot()
android_ns = 'http://schemas.android.com/apk/res/android'
def ns(tag):
return f'{{{android_ns}}}{tag}'
# check all declared "uses-permission" entries
for perm in root.findall('uses-permission'):
name = perm.get(ns('name'))
if name:
permissions.append(name)
# check all declared "permission" entries
for perm in root.findall('permission'):
name = perm.get(ns('name'))
protectionLevel = perm.get(ns('protectionLevel'))
# record name
if name:
permissions.append(name)
# if protectionLevel is normal or not set add to normal_protection_permissions
if protectionLevel is None or protectionLevel == 'normal':
normal_protection_permissions.append(name)
except Exception:
pass
except Exception as e:
print(f"{RED}Error: An unexpected error occurred while reading permissions: {e}{RESET}")
return permissions, [], []
return permissions, new_vulnerabilities, normal_protection_permissions
def find_components_requiring_permissions(manifest_file, target_sdk_version, permissions_list, package_name):
"""
look for exported components that require a permission (with normal or no protection level).
"""
components_requiring_permissions = []
try:
tree = ET.parse(manifest_file)
root = tree.getroot()
android_ns = 'http://schemas.android.com/apk/res/android'
application = root.find('application')
if application is None:
return components_requiring_permissions
component_types = ['activity', 'activity-alias', 'service', 'receiver', 'provider']
for component_type in component_types:
comps = application.findall(component_type)
for component in comps:
component_name = component.get(f'{{{android_ns}}}name')
if component_name is None:
continue
exported = is_exported(component, target_sdk_version)
if not exported:
continue
permission = component.get(f'{{{android_ns}}}permission')
if permission in permissions_list:
formatted_name = format_component_name(package_name, component_name)
components_requiring_permissions.append({
'component_type': component_type,
'component_name': formatted_name,
'required_permission': permission
})
except Exception: