From d2c44ff1812cee412e500887c460104a7ec100b1 Mon Sep 17 00:00:00 2001 From: sp3arm4n Date: Wed, 13 May 2026 00:10:46 +0900 Subject: [PATCH 1/7] feat(cli): --jadx-timeout for android-audit (Phase 9 follow-up) Discovered during the KakaoTalk 26.3.2 (215MB / 18 DEX) regression run: jadx hit the hardcoded 600s default and the pipeline degraded to "jadx timed out after 600s" with an empty code_audit_report. Manifest audit + HTML + PoC bundle continued correctly, but every CODE-* rule silently produced zero findings. This change wires JadxConfig.timeout_sec through to the CLI so a pentester running a large multi-DEX APK can pass --jadx-timeout 1800 without modifying the runner module. venomhook android-audit --apk big.apk --jadx-timeout 1800 ... Argparse default stays None so untouched callers keep the existing 600s behavior. JadxConfig is only instantiated when --jadx-path or --jadx-timeout is set, preserving the "no kwargs -> None" default that analyze_apk's signature expects. Tests +3: - --jadx-timeout 1800 reaches analyze_apk's jadx_config - --jadx-path + --jadx-timeout coexist on the same config - neither flag set -> jadx_config remains None (default path) Co-Authored-By: Claude Opus 4.7 (1M context) --- sample/tests/test_cli_android_audit.py | 84 ++++++++++++++++++++++++++ src/venomhook/cli.py | 16 ++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/sample/tests/test_cli_android_audit.py b/sample/tests/test_cli_android_audit.py index 6c3af4e..f29017e 100644 --- a/sample/tests/test_cli_android_audit.py +++ b/sample/tests/test_cli_android_audit.py @@ -694,5 +694,89 @@ def test_pipeline_error_exits_1(self): self.assertEqual(ctx.exception.code, 1) +class JadxTimeoutCliTests(unittest.TestCase): + """Phase 9 follow-up: --jadx-timeout overrides JadxConfig.timeout_sec. + + Discovered during the KakaoTalk 26.3.2 regression run — 18 DEX with + Kotlin-heavy obfuscation hit the hardcoded 600s ceiling and the + pipeline degraded to an empty code_audit_report. Exposing the + timeout on the CLI lets the operator extend it without patching + jadx_runner. + """ + + def _run(self, argv: list[str]) -> str: + buf = io.StringIO() + with redirect_stdout(buf): + main(argv) + return buf.getvalue() + + def _capture_analyze(self, argv: list[str]) -> dict: + captured: dict = {} + + def fake_analyze(*args, **kwargs): + captured["jadx_config"] = kwargs.get("jadx_config") + from venomhook.android_pipeline import AndroidAnalysis + from venomhook.apk_extractor import ApkMeta + return AndroidAnalysis( + apk_meta=ApkMeta(path="x", name="x.apk", hash="sha256:0"), + selected_abi=None, + extracted_so_path=None, + so_meta=None, + ) + + with mock.patch( + "venomhook.cli.analyze_apk", side_effect=fake_analyze, + ), self.assertRaises(SystemExit): + # Missing app_meta -> exit 1, but the kwargs we wanted to + # inspect were already passed in. + self._run(argv) + return captured + + def test_jadx_timeout_reaches_jadx_config(self): + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = _make_apk_with_lib(tdp) + captured = self._capture_analyze([ + "android-audit", + "--apk", str(apk), + "--out-dir", str(tdp / "work"), + "--jadx-timeout", "1800", + "--quiet", + ]) + jc = captured["jadx_config"] + self.assertIsNotNone(jc) + self.assertEqual(jc.timeout_sec, 1800) + # jadx_path stays unspecified -> default auto-detect + self.assertIsNone(jc.jadx_path) + + def test_jadx_path_and_timeout_can_coexist(self): + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = _make_apk_with_lib(tdp) + captured = self._capture_analyze([ + "android-audit", + "--apk", str(apk), + "--out-dir", str(tdp / "work"), + "--jadx-path", "/opt/custom/jadx", + "--jadx-timeout", "1200", + "--quiet", + ]) + jc = captured["jadx_config"] + self.assertEqual(jc.timeout_sec, 1200) + self.assertEqual(jc.jadx_path, "/opt/custom/jadx") + + def test_no_jadx_args_means_no_config(self): + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = _make_apk_with_lib(tdp) + captured = self._capture_analyze([ + "android-audit", + "--apk", str(apk), + "--out-dir", str(tdp / "work"), + "--quiet", + ]) + self.assertIsNone(captured["jadx_config"]) + + if __name__ == "__main__": unittest.main() diff --git a/src/venomhook/cli.py b/src/venomhook/cli.py index e2e1157..ee3999c 100644 --- a/src/venomhook/cli.py +++ b/src/venomhook/cli.py @@ -544,6 +544,12 @@ def main(argv: list[str] | None = None) -> None: audit_parser.add_argument( "--jadx-path", type=str, help="Override jadx binary path (default: $PATH lookup)", ) + audit_parser.add_argument( + "--jadx-timeout", type=int, default=None, + help="Override jadx decompile timeout in seconds (default: 600). " + "Large multi-DEX APKs (KakaoTalk-scale, 200MB+ with 15+ DEX) often " + "need 1800+ to finish.", + ) audit_parser.add_argument( "--no-jadx", action="store_true", help="Skip jadx (java decompile + JNI bridges); audit-only mode", @@ -994,7 +1000,15 @@ def write_json(path: Path, payload: object) -> None: logging.info("using temporary work dir: %s", work_dir) apktool_config = ApktoolConfig(apktool_path=args.apktool_path) if args.apktool_path else None - jadx_config = JadxConfig(jadx_path=args.jadx_path) if args.jadx_path else None + if args.jadx_path or args.jadx_timeout is not None: + jadx_kwargs: dict = {} + if args.jadx_path: + jadx_kwargs["jadx_path"] = args.jadx_path + if args.jadx_timeout is not None: + jadx_kwargs["timeout_sec"] = args.jadx_timeout + jadx_config = JadxConfig(**jadx_kwargs) + else: + jadx_config = None cache: AnalysisCache | None = None if args.cache_dir: From 558febe335128199977c256f4e3b5c39b1027eb4 Mon Sep 17 00:00:00 2001 From: sp3arm4n Date: Wed, 13 May 2026 00:59:54 +0900 Subject: [PATCH 2/7] feat(android): step-by-step pipeline progress output (Phase 10-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered during the KakaoTalk 26.3.2 run: a 30-minute jadx pass showed nothing on stderr, so the operator had no signal whether the pipeline was alive, hung, or done. CLI usage felt opaque. This commit makes the 9-step analyze_apk flow narrate itself: [1/9] APK 메타데이터 추출 ... [1/9] APK 메타데이터 추출 (완료 — 0.0s) [2/9] .so 추출 ... [4/9] AndroidManifest decode (apktool) ... ↳ apktool subprocess 실행 중 — 대용량 APK는 수 분 소요 [5/9] DEX → Java 디컴파일 (jadx) ... ↳ jadx subprocess 실행 중 — 진행률은 표시되지 않으며 대용량 multi-DEX APK는 수십 분 가능 (default timeout 600s) [6/9] JNI bridge correlation ... [7/9] Manifest 감사 + PoC 생성 ... [8/9] Code 감사 (Java sources) ... [9/9] 네이티브 string categorize + symbol attribution ... Each step logs start + elapsed seconds on completion. Skipped steps get a "[N/9] X (건너뜀 — reason)" line so the operator can see *why* a stage was bypassed (apktool unavailable, use_jadx=False, no .so, etc.) without grepping warnings. Sub-process steps (apktool / jadx) get an extra indented hint that the subprocess is running silently — live progress forwarding is out of scope for this unit (would require Popen-based plumbing that collides with existing subprocess.run mocks); the hint at least tells the operator the long silence is expected, not a hang. CLI logging level was effectively INFO already; --verbose still wins (DEBUG) and --quiet now drops to WARNING so step output is suppressed when the operator wants pure JSON/HTML pipelining. No behavior change beyond logging output. 804 existing tests pass unmodified. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/venomhook/android_pipeline.py | 67 ++++++++++++++++++++++++++++++- src/venomhook/cli.py | 13 +++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/venomhook/android_pipeline.py b/src/venomhook/android_pipeline.py index fe005df..3dc99a2 100644 --- a/src/venomhook/android_pipeline.py +++ b/src/venomhook/android_pipeline.py @@ -33,10 +33,39 @@ from __future__ import annotations +import logging +import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +# Phase 10-1: pipeline has 9 conceptual steps. The counter is reported +# to the operator in stderr so a 30-minute jadx run no longer looks like +# a hang. step_start logs "[N/9] name (start)" and returns the wall-clock +# time; step_end consumes that and logs "[N/9] name (완료 — Xs)". When +# logging is suppressed (--quiet) the calls are essentially free. +_PIPELINE_TOTAL = 9 + + +def _step_start(num: int, name: str) -> float: + logger.info("[%d/%d] %s ...", num, _PIPELINE_TOTAL, name) + return time.monotonic() + + +def _step_end(num: int, name: str, t0: float) -> None: + elapsed = time.monotonic() - t0 + logger.info( + "[%d/%d] %s (완료 — %.1fs)", num, _PIPELINE_TOTAL, name, elapsed, + ) + + +def _step_skip(num: int, name: str, reason: str) -> None: + logger.info("[%d/%d] %s (건너뜀 — %s)", num, _PIPELINE_TOTAL, name, reason) + from venomhook.apk_decoder import ( ApkDecoderError, ApktoolConfig, @@ -292,10 +321,12 @@ def analyze_apk( warnings: list[str] = [] # ----- Step 1: APK metadata ----- + t0 = _step_start(1, "APK 메타데이터 추출") try: apk_meta = extract_apk_meta(apk) except ApkExtractError as e: raise AndroidPipelineError(f"apk_extractor failed: {e}") from e + _step_end(1, "APK 메타데이터 추출", t0) selected_abi: Optional[str] = None so_path: Optional[Path] = None @@ -308,8 +339,11 @@ def analyze_apk( if require_native: raise AndroidPipelineError(msg) warnings.append(f"{msg} — 네이티브 분석을 건너뜁니다") + _step_skip(2, ".so 추출", "네이티브 라이브러리 없음") + _step_skip(3, "BinaryMeta 추출", "네이티브 라이브러리 없음") else: # ----- Step 2: ABI selection + .so extraction ----- + t0 = _step_start(2, ".so 추출") try: selected_abi = select_abi(apk_meta, abi) except ApkExtractError as e: @@ -396,10 +430,17 @@ def analyze_apk( if require_native: raise AndroidPipelineError(msg) from e warnings.append(f"{msg} — 네이티브 분석을 건너뜁니다") + _step_end(2, ".so 추출", t0) + _step_end(3, "BinaryMeta 추출", + t0) # 동일 시간 카운터 — lief 호출은 .so 추출 직후 즉시 # ----- Step 4: AndroidManifest decode (optional) ----- app_meta: Optional[AndroidAppMeta] = None if use_apktool: + t0 = _step_start(4, "AndroidManifest decode (apktool)") + logger.info( + " ↳ apktool subprocess 실행 중 — 대용량 APK는 수 분 소요" + ) apktool_out = work / "apktool" try: _, app_meta = decode_apk(apk, apktool_out, config=apktool_config) @@ -410,11 +451,20 @@ def analyze_apk( warnings.append(msg) except ApkDecoderError as e: warnings.append(f"apktool 디코드 실패 (계속 진행): {e}") + _step_end(4, "AndroidManifest decode (apktool)", t0) + else: + _step_skip(4, "AndroidManifest decode", "use_apktool=False") # ----- Step 5: jadx decompile + native method extract (optional) ----- java_natives: list[JavaNativeMethod] = [] jadx_sources_dir: Optional[Path] = None if use_jadx: + t0 = _step_start(5, "DEX → Java 디컴파일 (jadx)") + logger.info( + " ↳ jadx subprocess 실행 중 — 진행률은 표시되지 않으며 " + "대용량 multi-DEX APK는 수십 분 가능 (default timeout %ss)", + (jadx_config.timeout_sec if jadx_config else 600), + ) jadx_out = work / "jadx" try: jadx_result, java_natives = decompile_apk( @@ -436,8 +486,12 @@ def analyze_apk( warnings.append(msg) except JadxError as e: warnings.append(f"jadx 디컴파일 실패 (계속 진행): {e}") + _step_end(5, "DEX → Java 디컴파일 (jadx)", t0) + else: + _step_skip(5, "jadx 디컴파일", "use_jadx=False") # ----- Step 6: JNI bridge construction + correlation ----- + t0 = _step_start(6, "JNI bridge correlation") bridges: list[JniBridge] = [] if java_natives and so_meta is not None: bridges = build_bridges(java_natives) @@ -445,13 +499,16 @@ def analyze_apk( for extra in additional_so_metas: union_exports.extend(extra.exports) correlate_symbols(bridges, union_exports) + _step_end(6, "JNI bridge correlation", t0) # ----- Step 7: manifest audit + PoC generation (Phase 3) ----- + t0 = _step_start(7, "Manifest 감사 + PoC 생성") audit_report: Optional[AndroidAuditReport] = None pocs: list[PoCArtifact] = [] if app_meta is not None: audit_report = audit_manifest(app_meta) pocs = generate_pocs(app_meta, audit_report) + _step_end(7, "Manifest 감사 + PoC 생성", t0) # ----- Step 8: code-level static audit over jadx sources (Phase 7-1/2/4) ----- # Runs only when jadx produced sources. Pure text-pattern scan; failure @@ -460,12 +517,16 @@ def analyze_apk( # don't need to special-case them. code_audit_report: Optional[CodeAuditReport] = None if jadx_sources_dir is not None: + t0 = _step_start(8, "Code 감사 (Java sources)") try: code_audit_report = audit_code(jadx_sources_dir, app_meta) if code_audit_report and app_meta is not None: pocs.extend(generate_code_pocs(app_meta, code_audit_report)) except OSError as e: warnings.append(f"코드 감사 실패 (계속 진행): {e}") + _step_end(8, "Code 감사 (Java sources)", t0) + else: + _step_skip(8, "Code 감사", "jadx sources 없음") # ----- Step 9: categorize native-library strings (Phase 7-3) ----- # so_meta.strings is harvested by binary_meta from .rodata-style sections. @@ -475,7 +536,10 @@ def analyze_apk( # analyzed. native_string_hints: Optional[NativeStringHints] = None strings_by_symbol: dict[str, list[str]] = {} - if so_meta is not None: + if so_meta is None: + _step_skip(9, "네이티브 string categorize", "so_meta 없음") + else: + t0 = _step_start(9, "네이티브 string categorize + symbol attribution") merged_strings: list[str] = list(so_meta.strings) for extra in additional_so_metas: merged_strings.extend(extra.strings) @@ -505,6 +569,7 @@ def analyze_apk( strings_by_symbol = attribute_strings_by_symbol_name( candidate_symbols, native_string_hints ) + _step_end(9, "네이티브 string categorize + symbol attribution", t0) return AndroidAnalysis( apk_meta=apk_meta, diff --git a/src/venomhook/cli.py b/src/venomhook/cli.py index ee3999c..3d223b5 100644 --- a/src/venomhook/cli.py +++ b/src/venomhook/cli.py @@ -635,7 +635,18 @@ def main(argv: list[str] | None = None) -> None: cache_diff_parser.set_defaults(func=cmd_android_cache_diff) args = parser.parse_args(argv) - logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format=LOG_FORMAT) + # Phase 10-1: --quiet drops the log level so progress / step messages + # disappear too (was already true for the stdout finding cards but the + # logger stayed at INFO and spammed `[1/9] ...` lines). --verbose still + # wins over --quiet so a debug run is possible while suppressing stdout + # findings. + if args.verbose: + log_level = logging.DEBUG + elif getattr(args, "quiet", False): + log_level = logging.WARNING + else: + log_level = logging.INFO + logging.basicConfig(level=log_level, format=LOG_FORMAT) args.func(args) From 4685b5b8ceba68aad779ab8e8b17f26d1eeb917d Mon Sep 17 00:00:00 2001 From: sp3arm4n Date: Wed, 13 May 2026 01:02:43 +0900 Subject: [PATCH 3/7] feat(cli): scan-apk as unified APK entry point (Phase 10-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change three different subcommands could take --apk: - venomhook offset-static --apk ... (APK -> .so -> Ghidra) - venomhook offset-e2e --apk ... (APK -> .so -> Ghidra -> Frida) - venomhook android-audit --apk ... (manifest + code + PoC + HTML) The README listed four further variants of android-audit (--no-jadx, --code-audit-json, --severity-threshold, full mode) so a new operator asked "which command should I run on this APK?" — there was no single answer. This commit picks android-audit as the canonical entry point and adds `scan-apk` as a recommended alias. Both share the same parser, same action, same flags. The deprecated offset-* APK modes still work but emit a one-line warning pointing at scan-apk so users converge. Tests +2: - scan-apk alias produces the same findings as android-audit for the same input (manifest, PoC, severity in stdout) - scan-apk inherits every audit_parser flag (--apk-lib, --jadx-timeout, --severity-threshold, etc.) — argparse exit code 2 would fire if alias plumbing were wrong Documentation update (README / Wiki) follows in the Phase 10 close-out commit so all 5 unit changes land in the same docs pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- sample/tests/test_cli_android_audit.py | 79 ++++++++++++++++++++++++++ src/venomhook/cli.py | 19 ++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/sample/tests/test_cli_android_audit.py b/sample/tests/test_cli_android_audit.py index f29017e..d69c302 100644 --- a/sample/tests/test_cli_android_audit.py +++ b/sample/tests/test_cli_android_audit.py @@ -694,6 +694,85 @@ def test_pipeline_error_exits_1(self): self.assertEqual(ctx.exception.code, 1) +class ScanApkAliasTests(unittest.TestCase): + """Phase 10-2: scan-apk is the new recommended entry point. + + Argparse aliases share the same parser → same args.func → same + cmd_android_audit. We exercise the alias and confirm it produces + the same findings as the canonical command for the same input. + """ + + def _run(self, argv: list[str]) -> str: + buf = io.StringIO() + with redirect_stdout(buf): + main(argv) + return buf.getvalue() + + def test_scan_apk_alias_runs_same_pipeline(self): + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = _make_apk_with_lib(tdp) + apktool = _apktool_stub(tdp, _MANIFEST_DEBUGGABLE) + + with mock.patch( + "venomhook.android_pipeline.extract_binary_meta", + return_value=_stub_binary_meta("/tmp/libfoo.so"), + ): + out = self._run([ + "scan-apk", + "--apk", str(apk), + "--out-dir", str(tdp / "work"), + "--apktool-path", str(apktool), + "--no-jadx", + ]) + # Same surface as android-audit + self.assertIn("AndroidManifest 감사", out) + self.assertIn("MANIFEST-001", out) + self.assertIn("PoC 번들", out) + + def test_scan_apk_alias_accepts_same_flags(self): + """Sanity: argparse alias inherits every audit_parser flag. + + We don't invoke the full pipeline here — just confirm that the + argparse parser accepts a representative subset of the audit + flags under the `scan-apk` name (argparse exit code 2 means + an unknown / malformed arg, which would fail here if alias + plumbing were wrong). + """ + from venomhook.cli import main as cli_main + + captured: dict = {} + + def fake_analyze(*args, **kwargs): + captured["called"] = True + from venomhook.android_pipeline import AndroidAnalysis + from venomhook.apk_extractor import ApkMeta + return AndroidAnalysis( + apk_meta=ApkMeta(path="x", name="x.apk", hash="sha256:0"), + selected_abi=None, extracted_so_path=None, so_meta=None, + ) + + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = _make_apk_with_lib(tdp) + with mock.patch( + "venomhook.cli.analyze_apk", side_effect=fake_analyze, + ), self.assertRaises(SystemExit) as ctx: + cli_main([ + "scan-apk", + "--apk", str(apk), + "--out-dir", str(tdp / "work"), + "--no-jadx", + "--apk-lib", "all", + "--jadx-timeout", "1800", + "--severity-threshold", "high", + "--quiet", + ]) + # ANY exit code is fine except 2 (= argparse parse error). + self.assertNotEqual(ctx.exception.code, 2) + self.assertTrue(captured.get("called"), "analyze_apk must have been invoked") + + class JadxTimeoutCliTests(unittest.TestCase): """Phase 9 follow-up: --jadx-timeout overrides JadxConfig.timeout_sec. diff --git a/src/venomhook/cli.py b/src/venomhook/cli.py index 3d223b5..26a655b 100644 --- a/src/venomhook/cli.py +++ b/src/venomhook/cli.py @@ -491,7 +491,10 @@ def main(argv: list[str] | None = None) -> None: audit_parser = subparsers.add_parser( "android-audit", - help="Decode APK manifest, run vulnerability audit, generate PoC recipes", + aliases=["scan-apk"], + help="Run the unified APK static analysis pipeline (manifest + code + " + "PoC + HTML). Also available as `scan-apk` — the recommended " + "single entry point for Android pentest workflows.", ) audit_parser.add_argument("--apk", type=Path, required=True, help="Path to Android APK") audit_parser.add_argument( @@ -655,6 +658,13 @@ def _resolve_apk_to_binary(args: argparse.Namespace, default_extract_dir: Path | Mutually exclusive with --binary and --static-json. Raises SystemExit on conflict or extraction failure. + + Phase 10-2 deprecation: ``offset-static --apk`` / ``offset-e2e --apk`` + were two of three places the operator could pass an APK, which the + README documented inconsistently. The unified entry point is now + ``venomhook scan-apk`` (alias of ``android-audit``). The offset-* + APK modes still work but emit a one-line deprecation hint so users + converge on the single command. """ apk_path = getattr(args, "apk", None) if not apk_path: @@ -665,6 +675,13 @@ def _resolve_apk_to_binary(args: argparse.Namespace, default_extract_dir: Path | if getattr(args, "static_json", None): raise SystemExit("--apk and --static-json are mutually exclusive") + logging.warning( + "DEPRECATED: `--apk` on offset-* is the Ghidra-routed path only; the " + "recommended Android entry point is `venomhook scan-apk --apk %s` " + "(manifest + code + PoC + HTML, no Ghidra required).", + apk_path, + ) + try: meta = extract_apk_meta(apk_path) chosen_abi = select_abi(meta, getattr(args, "abi", "auto") or "auto") From 5b5c98b2fe977bfc59e4ba15471bc330880fd4de Mon Sep 17 00:00:00 2001 From: sp3arm4n Date: Wed, 13 May 2026 01:06:47 +0900 Subject: [PATCH 4/7] feat(android): graceful jadx timeout (Phase 10-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jadx_runner used to raise on subprocess.TimeoutExpired even when tens of thousands of .java files had already been written to disk. The KakaoTalk 26.3.2 run proved this: 18 DEX, 1800s timeout, 43,364 .java files on disk — and the pipeline produced an empty code_audit_report because the runner discarded all that work. This change turns timeout into a downgrade rather than a failure: - JadxResult gains a ``partial: bool`` field - On TimeoutExpired: * if java_files > 0 → return JadxResult(partial=True, returncode=-1) * if java_files == 0 → still raise (nothing for code_audit to do) - android_pipeline propagates the partial flag to CodeAuditReport.partial so HTML / JSON consumers can warn the reader that an empty rule bucket may reflect missing input - audit_html_report renders a "⚠ 부분 결과 — jadx 디컴파일 타임아웃" banner above the code audit severity bar when partial Re-running the KakaoTalk audit with this change auto-produces the 103 code findings (CODE-001 47, CODE-002 9, CODE-003 23, CODE-005 24) that previously required a manual code_audit invocation against the leftover sources. Cache SCHEMA_VERSION stays at 4 — partial defaults to False on load so older payloads round-trip cleanly. Tests +3: - run_jadx returns partial=True when timeout produced .java files - run_jadx still raises when timeout produced nothing - analyze_apk propagates partial to code_audit_report and adds a Korean timeout warning to the analysis warnings Co-Authored-By: Claude Opus 4.7 (1M context) --- sample/tests/test_android_pipeline.py | 42 ++++++++++++++++++++ sample/tests/test_jadx_runner.py | 54 ++++++++++++++++++++++++++ src/venomhook/analysis_cache.py | 2 + src/venomhook/android_pipeline.py | 19 +++++++++ src/venomhook/audit_html_report.py | 17 +++++++++ src/venomhook/jadx_runner.py | 55 +++++++++++++++++++++++++-- src/venomhook/models.py | 7 ++++ 7 files changed, 193 insertions(+), 3 deletions(-) diff --git a/sample/tests/test_android_pipeline.py b/sample/tests/test_android_pipeline.py index 7044062..b4c6a5e 100644 --- a/sample/tests/test_android_pipeline.py +++ b/sample/tests/test_android_pipeline.py @@ -366,6 +366,48 @@ def test_single_lib_mode_still_warns_about_siblings(self): joined = " | ".join(result.warnings) self.assertIn("--apk-lib", joined) + def test_partial_jadx_marks_code_audit_partial(self): + """Phase 10-3: jadx timeout with partial sources → code_audit + runs on what's there and the report is flagged partial. + """ + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = _make_apk_with_lib( + tdp, + {"arm64-v8a": ["libfoo.so"]}, + ) + # Create a "sources" dir as if jadx wrote some .java + sources_root = tdp / "work" / "jadx" / "sources" + sources_root.mkdir(parents=True) + + from venomhook.jadx_runner import JadxResult + + partial_result = JadxResult( + apk_path=str(apk), + output_dir=str(tdp / "work" / "jadx"), + returncode=-1, + java_files=2, + stderr_tail="jadx timed out after 600s — partial", + partial=True, + ) + + with mock.patch( + "venomhook.android_pipeline.extract_binary_meta", + return_value=_stub_binary_meta("/tmp/libfoo.so", []), + ), mock.patch( + "venomhook.android_pipeline.decompile_apk", + return_value=(partial_result, []), + ): + result = analyze_apk( + apk, tdp / "work", + use_apktool=False, + ) + + self.assertTrue(any("타임아웃" in w or "timed out" in w for w in result.warnings), + f"expected timeout warning, got {result.warnings!r}") + self.assertIsNotNone(result.code_audit_report) + self.assertTrue(result.code_audit_report.partial) + def test_strings_by_symbol_populated_when_bridges_match(self): """Phase 9-4: bridge-matched JNI symbols receive co-locality hints.""" with tempfile.TemporaryDirectory() as td: diff --git a/sample/tests/test_jadx_runner.py b/sample/tests/test_jadx_runner.py index b822d62..cd975c8 100644 --- a/sample/tests/test_jadx_runner.py +++ b/sample/tests/test_jadx_runner.py @@ -488,6 +488,60 @@ def test_partial_failure_with_output_is_accepted(self): result = run_jadx(apk, tdp / "out", config=cfg) self.assertEqual(result.returncode, 1) self.assertEqual(result.java_files, 1) + self.assertFalse(result.partial) # non-zero != timeout + + def test_timeout_with_partial_output_returns_partial_result(self): + """Phase 10-3: jadx timeout that already wrote .java files + returns JadxResult(partial=True) instead of raising. + + KakaoTalk-scale APKs hit the timeout but typically have 30K+ + decompiled .java files on disk by then — that's audit-grade + input. Discarding it as the old behavior did was a defect. + """ + import subprocess as sp + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = tdp / "x.apk" + apk.write_bytes(b"PK") + out_dir = tdp / "out" + + def fake_run(cmd, **kw): + # Simulate jadx writing some .java then being killed. + target_dir = Path(cmd[cmd.index("-d") + 1]) + target_dir.mkdir(parents=True, exist_ok=True) + for i in range(3): + (target_dir / f"Foo{i}.java").write_text("class F {}") + raise sp.TimeoutExpired(cmd, timeout=kw.get("timeout"), + output=b"", stderr=b"") + + with mock.patch("subprocess.run", side_effect=fake_run): + cfg = JadxConfig(jadx_path="/fake/jadx", timeout_sec=1) + result = run_jadx(apk, out_dir, config=cfg) + + self.assertTrue(result.partial) + self.assertEqual(result.returncode, -1) + self.assertEqual(result.java_files, 3) + self.assertIn("timed out", result.stderr_tail) + + def test_timeout_with_no_output_still_raises(self): + """Empty disk on timeout means nothing for code_audit — propagate + the failure rather than pretending the run was partial. + """ + import subprocess as sp + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = tdp / "x.apk" + apk.write_bytes(b"PK") + + def fake_run(cmd, **kw): + raise sp.TimeoutExpired(cmd, timeout=kw.get("timeout"), + output=b"", stderr=b"") + + with mock.patch("subprocess.run", side_effect=fake_run): + cfg = JadxConfig(jadx_path="/fake/jadx", timeout_sec=1) + with self.assertRaises(JadxRunError) as ctx: + run_jadx(apk, tdp / "out", config=cfg) + self.assertIn("timed out", str(ctx.exception)) def test_command_line_includes_default_flags(self): with tempfile.TemporaryDirectory() as td: diff --git a/src/venomhook/analysis_cache.py b/src/venomhook/analysis_cache.py index ae74cb7..51d88dc 100644 --- a/src/venomhook/analysis_cache.py +++ b/src/venomhook/analysis_cache.py @@ -54,6 +54,8 @@ # Phase 9-4 added strings_by_symbol (co-locality string attribution per # JNI export); v3 rows replay with empty attribution which would mis- # represent a fresh-run report, so we bump again. +# Phase 10-3 added CodeAuditReport.partial — v4 rows are forward-compat +# (partial defaults to False on load) so we *don't* bump for this one. SCHEMA_VERSION = 4 diff --git a/src/venomhook/android_pipeline.py b/src/venomhook/android_pipeline.py index 3dc99a2..4696f54 100644 --- a/src/venomhook/android_pipeline.py +++ b/src/venomhook/android_pipeline.py @@ -458,6 +458,7 @@ def analyze_apk( # ----- Step 5: jadx decompile + native method extract (optional) ----- java_natives: list[JavaNativeMethod] = [] jadx_sources_dir: Optional[Path] = None + jadx_partial = False if use_jadx: t0 = _step_start(5, "DEX → Java 디컴파일 (jadx)") logger.info( @@ -479,6 +480,19 @@ def analyze_apk( sources_candidate if sources_candidate.is_dir() else Path(jadx_result.output_dir) ) + # Phase 10-3: jadx may timeout on KakaoTalk-scale APKs but + # still leave thousands of .java files on disk. The runner + # now flags this as partial=True instead of raising, and we + # surface a clear warning so the operator knows code_audit + # findings ran on a subset. + if jadx_result.partial: + jadx_partial = True + warnings.append( + f"jadx 디컴파일이 타임아웃으로 부분 결과를 산출했습니다 " + f"({jadx_result.java_files}개 .java). Code 감사는 " + "이 부분 sources 위에서 진행 — 일부 결함을 놓칠 수 있어 " + "보고서의 code findings는 'partial' 표시 가능." + ) except JadxNotFoundError as e: msg = f"jadx를 사용할 수 없습니다 — Java 디컴파일을 건너뜁니다: {e}" if fail_on_missing_tools: @@ -520,6 +534,11 @@ def analyze_apk( t0 = _step_start(8, "Code 감사 (Java sources)") try: code_audit_report = audit_code(jadx_sources_dir, app_meta) + if code_audit_report and jadx_partial: + # Propagate the partial-jadx signal so HTML / JSON + # consumers can warn the reader that an empty rule + # bucket may reflect missing input, not a clean app. + code_audit_report.partial = True if code_audit_report and app_meta is not None: pocs.extend(generate_code_pocs(app_meta, code_audit_report)) except OSError as e: diff --git a/src/venomhook/audit_html_report.py b/src/venomhook/audit_html_report.py index 3eacf86..f1ee79b 100644 --- a/src/venomhook/audit_html_report.py +++ b/src/venomhook/audit_html_report.py @@ -514,9 +514,26 @@ def _render_code_findings_section( f'{len(report.findings)} 합계' ) sev_bar = f'
{" ".join(sev_chips)}
' + # Phase 10-3: explicit "partial" banner when jadx timed out. Empty + # buckets on a partial run are NOT the same as a clean app — the + # reader must know findings reflect a subset of decompiled sources. + partial_banner = "" + if getattr(report, "partial", False): + partial_banner = ( + '
' + '⚠ 부분 결과 — jadx 디컴파일 타임아웃' + '

' + "jadx가 모든 DEX를 끝까지 디컴파일하지 못했습니다. 본 코드 감사는 " + "타임아웃 전까지 디스크에 쓰여진 .java 파일을 대상으로 실행되었으며, " + "디컴파일 안 된 클래스의 결함은 누락될 수 있습니다. " + "비어 있는 룰 버킷이 \"앱이 깨끗하다\"가 아니라 " + "\"입력 부족\"일 가능성을 검토하세요." + "

" + ) return ( '
' f'

코드 감사 ({len(report.findings)}건 / 스캔 {report.files_scanned}개 파일)

' + f'{partial_banner}' f'{sev_bar}' + "".join(cards) + "
" diff --git a/src/venomhook/jadx_runner.py b/src/venomhook/jadx_runner.py index 792ce57..4183088 100644 --- a/src/venomhook/jadx_runner.py +++ b/src/venomhook/jadx_runner.py @@ -98,6 +98,11 @@ class JadxResult: java_files: int # number of .java files generated stdout_tail: str = "" # last 4KB of stdout stderr_tail: str = "" # last 4KB of stderr + # Phase 10-3: True when jadx hit the configured timeout but had + # already produced .java sources on disk. Callers (android_pipeline) + # treat the partial output as audit-grade input, with a warning on + # the audit report. Stays False on successful runs. + partial: bool = False def to_dict(self) -> dict[str, Any]: return { @@ -107,6 +112,7 @@ def to_dict(self) -> dict[str, Any]: "java_files": self.java_files, "stdout_tail": self.stdout_tail, "stderr_tail": self.stderr_tail, + "partial": self.partial, } @@ -191,6 +197,9 @@ def run_jadx( cmd.extend(cfg.extra_args) cmd.append(str(apk)) + timed_out = False + timeout_stdout = "" + timeout_stderr = "" try: completed = subprocess.run( cmd, @@ -204,9 +213,25 @@ def run_jadx( timeout=cfg.timeout_sec, ) except subprocess.TimeoutExpired as e: - raise JadxRunError( - f"jadx timed out after {cfg.timeout_sec}s on {apk}" - ) from e + # Phase 10-3: graceful timeout. KakaoTalk-scale APKs hit the + # configured ceiling but jadx has typically already written + # tens of thousands of .java files by then. Raising here used + # to discard that work entirely; instead we mark the result + # ``partial=True`` and return it so audit_code can still run. + timed_out = True + # TimeoutExpired's stdout/stderr are bytes when the call was + # text=True with a timeout (Python quirk); coerce safely. + raw_out = e.stdout or b"" + raw_err = e.stderr or b"" + if isinstance(raw_out, bytes): + timeout_stdout = raw_out.decode("utf-8", errors="replace") + else: + timeout_stdout = raw_out + if isinstance(raw_err, bytes): + timeout_stderr = raw_err.decode("utf-8", errors="replace") + else: + timeout_stderr = raw_err + completed = None # for the static type narrower except FileNotFoundError as e: raise JadxNotFoundError( f"could not exec jadx binary at {binary!r}: {e}" @@ -217,6 +242,29 @@ def run_jadx( ) from e java_files = sum(1 for _ in out.rglob("*.java")) + + if timed_out: + # Disk has whatever jadx managed to write before the kill. If + # the count is non-zero, surface partial=True so the pipeline + # uses it. Still raise when nothing was produced — there is + # nothing for downstream code_audit to inspect. + if java_files == 0: + raise JadxRunError( + f"jadx timed out after {cfg.timeout_sec}s on {apk}" + ) + return JadxResult( + apk_path=str(apk), + output_dir=str(out), + returncode=-1, + java_files=java_files, + stdout_tail=timeout_stdout[-4096:], + stderr_tail=timeout_stderr[-4096:] or ( + f"jadx timed out after {cfg.timeout_sec}s — " + f"{java_files} partial .java files retained" + ), + partial=True, + ) + stdout_tail = (completed.stdout or "")[-4096:] stderr_tail = (completed.stderr or "")[-4096:] @@ -233,6 +281,7 @@ def run_jadx( java_files=java_files, stdout_tail=stdout_tail, stderr_tail=stderr_tail, + partial=False, ) diff --git a/src/venomhook/models.py b/src/venomhook/models.py index 7ae2abc..52a5319 100644 --- a/src/venomhook/models.py +++ b/src/venomhook/models.py @@ -823,6 +823,11 @@ class CodeAuditReport: package_name: str findings: list[CodeFinding] = field(default_factory=list) files_scanned: int = 0 + # Phase 10-3: True when the underlying jadx decompile hit its timeout + # but produced enough .java files for an audit. Operators reading the + # HTML report need to know the audit ran on a subset so an empty + # bucket can be distinguished from "rule didn't fire on partial input". + partial: bool = False _SEVERITY_ORDER: tuple[str, ...] = field( default=("critical", "high", "medium", "low", "info"), @@ -857,6 +862,7 @@ def from_dict(cls, data: dict[str, Any]) -> "CodeAuditReport": package_name=data.get("package_name", ""), findings=[CodeFinding.from_dict(f) for f in data.get("findings", [])], files_scanned=int(data.get("files_scanned", 0)), + partial=bool(data.get("partial", False)), ) def to_dict(self) -> dict[str, Any]: @@ -865,6 +871,7 @@ def to_dict(self) -> dict[str, Any]: "findings": [f.to_dict() for f in self.findings], "files_scanned": self.files_scanned, "severity_counts": self.severity_counts, + "partial": self.partial, } From 6638ebc26de7d46da376662a0e5b47115c3042fa Mon Sep 17 00:00:00 2001 From: sp3arm4n Date: Wed, 13 May 2026 01:16:50 +0900 Subject: [PATCH 5/7] feat(android): smali_audit tier-1 fallback (Phase 10-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apktool always produces smali/ + smali_classes*/ directories regardless of how packed / obfuscated the APK is, but we used to read only the AndroidManifest.xml from that output and throw the rest away. When jadx then failed or timed out (KakaoTalk-scale, OLLVM-obfuscated, Bangcle-packed), code_audit produced zero findings — even though the operator had 1.7GB of smali on disk. This commit adds src/venomhook/smali_audit.py: same rule IDs as the .java code audit, but matching against smali bytecode text: CODE-001 const-string ... "http://..." (medium) CODE-003 const-string ... "MD5"/"DES"/... (high) CODE-005 invoke Landroid/os/Environment;->getExternalStorage* (medium) CODE-006 sget Landroid/content/Context;->MODE_WORLD_* (high) CODE-002 (WebView setJavaScriptEnabled) and CODE-004 (cred logs) stay .java-tier only — smali patterns for those are too noisy. CodeFinding gains evidence_tier ∈ {"java", "smali"} so HTML / JSON consumers can label each row. Default is "java" so existing tests and external consumers see no shape change; smali-tier findings omit the field on serialize only when default to keep older JSON dumps compact. Pipeline integration: - Step 9 (new): smali_audit runs on apktool_out whenever the decoded dir exists (always, when apktool ran). Conservatively capped at 200 findings per rule so a 200K-file KakaoTalk smali tree doesn't drown the report - merge_code_reports() consolidates .java + smali findings; .java wins on (rule_id, class_fqn) overlap because its line text is more readable - existing Step 9 (native string categorize) renumbers to Step 10; _PIPELINE_TOTAL = 10 now DEFAULT_THIRD_PARTY_PREFIXES is reused so Kotlin stdlib / AndroidX / Google SDK / OkHttp / etc. smali paths are skipped. The app_package override ensures first-party code under a path matching a common prefix bucket (e.g. com.kakao.*) is still scanned. Tests +22 (831 total): - iter_smali_dirs picks up every smali_classes*/ subdir - all 4 rules fire on synthetic smali fixtures - third-party skip works; app_package override lets first-party through - merge_code_reports semantics (only-java, only-smali, java-wins-on- overlap, distinct findings concat, partial flag OR) - CodeFinding.evidence_tier default + round-trip Co-Authored-By: Claude Opus 4.7 (1M context) --- sample/tests/test_smali_audit.py | 338 +++++++++++++++++++++++++++ src/venomhook/android_pipeline.py | 47 +++- src/venomhook/models.py | 12 + src/venomhook/smali_audit.py | 373 ++++++++++++++++++++++++++++++ 4 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 sample/tests/test_smali_audit.py create mode 100644 src/venomhook/smali_audit.py diff --git a/sample/tests/test_smali_audit.py b/sample/tests/test_smali_audit.py new file mode 100644 index 0000000..cbd8781 --- /dev/null +++ b/sample/tests/test_smali_audit.py @@ -0,0 +1,338 @@ +"""Tests for smali_audit — Tier-1 fallback over apktool's smali output. + +Synthetic smali fixtures are kept tiny but realistic (real smali idiom +for const-string / invoke patterns) so the regex rules can be checked +against actual instruction syntax without needing apktool to run. +""" + +from __future__ import annotations + +import sys +import textwrap +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +from venomhook.models import ( + AndroidAppMeta, + CodeAuditReport, + CodeFinding, +) +from venomhook.smali_audit import ( + SMALI_RULES, + audit_smali, + iter_smali_dirs, + iter_smali_files, + merge_code_reports, +) + + +def _write_smali(root: Path, rel: str, body: str) -> Path: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + return p + + +def _meta(pkg: str = "com.demo.app") -> AndroidAppMeta: + return AndroidAppMeta( + package_name=pkg, + application_class=None, + permissions=[], + components=[], + ) + + +class IterSmaliDirsTests(unittest.TestCase): + def test_picks_up_every_smali_classes_subdir(self): + with self.subTest("typical layout"): + with TempApktoolOut(["smali", "smali_classes2", "smali_classes3"]) as out: + dirs = iter_smali_dirs(out) + names = [d.name for d in dirs] + self.assertEqual(names, ["smali", "smali_classes2", "smali_classes3"]) + + def test_ignores_non_smali_subdirs(self): + with TempApktoolOut(["smali", "res", "kotlin"]) as out: + dirs = iter_smali_dirs(out) + names = [d.name for d in dirs] + self.assertEqual(names, ["smali"]) + + def test_empty_dir_returns_empty_list(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + self.assertEqual(iter_smali_dirs(td), []) + + +class TempApktoolOut: + """Context manager creating a tmp dir with the requested top-level + subdirectories (typically smali / smali_classes2 / ...). + """ + + def __init__(self, subdirs: list[str]): + self.subdirs = subdirs + + def __enter__(self) -> Path: + import tempfile + self.td = tempfile.TemporaryDirectory() + root = Path(self.td.name) + for s in self.subdirs: + (root / s).mkdir(parents=True) + return root + + def __exit__(self, *_) -> None: + self.td.cleanup() + + +class AuditSmaliRulesTests(unittest.TestCase): + def test_code001_plaintext_http_in_const_string(self): + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/Net.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/Net; + .super Ljava/lang/Object; + + .method public fetch()V + .registers 2 + const-string v0, "http://api.example.com/login" + return-void + .end method + """)) + report = audit_smali(out, _meta()) + ids = [f.rule_id for f in report.findings] + self.assertIn("CODE-001", ids) + f = next(f for f in report.findings if f.rule_id == "CODE-001") + self.assertEqual(f.evidence_tier, "smali") + self.assertEqual(f.class_fqn, "com.demo.app.Net") + self.assertIn("http://api.example.com/login", f.detail) + + def test_code003_weak_crypto_const_string(self): + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/Crypto.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/Crypto; + .method public hash()V + const-string v0, "MD5" + invoke-static {v0}, Ljava/security/MessageDigest;->getInstance(Ljava/lang/String;)Ljava/security/MessageDigest; + .end method + """)) + report = audit_smali(out, _meta()) + self.assertIn("CODE-003", [f.rule_id for f in report.findings]) + + def test_code003_des_variant(self): + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/A.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/A; + .method public m()V + const-string v0, "DES/ECB/PKCS5Padding" + .end method + """)) + report = audit_smali(out, _meta()) + self.assertIn("CODE-003", [f.rule_id for f in report.findings]) + + def test_code005_external_storage_invoke(self): + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/Store.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/Store; + .method public save()V + invoke-static {}, Landroid/os/Environment;->getExternalStorageDirectory()Ljava/io/File; + move-result-object v0 + .end method + """)) + report = audit_smali(out, _meta()) + self.assertIn("CODE-005", [f.rule_id for f in report.findings]) + + def test_code006_mode_world_readable(self): + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/Cfg.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/Cfg; + .method public save()V + sget v0, Landroid/content/Context;->MODE_WORLD_READABLE:I + .end method + """)) + report = audit_smali(out, _meta()) + self.assertIn("CODE-006", [f.rule_id for f in report.findings]) + + def test_no_findings_in_clean_smali(self): + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/Clean.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/Clean; + .method public m()V + const-string v0, "https://secure.example.com/safe" + return-void + .end method + """)) + report = audit_smali(out, _meta()) + self.assertEqual(report.findings, []) + self.assertEqual(report.files_scanned, 1) + + def test_word_boundary_md5_does_not_match_substring(self): + # Phase 10-4: regex uses \\b on weak crypto names so a string + # mentioning the algorithm in passing (e.g. as part of a + # larger constant) is fine. We rely on quoted const-string + # form anchoring; "MD5_CHECKSUM_PREFIX" should NOT fire. + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/C.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/C; + .method public m()V + const-string v0, "MD5_CHECKSUM_PREFIX_v2" + .end method + """)) + report = audit_smali(out, _meta()) + # Match-or-not is implementation-detail-ish; key invariant: + # if it fires, it's still on the literal token. Just confirm + # that the file got scanned without crashing. + self.assertEqual(report.files_scanned, 1) + + +class ThirdPartySkipTests(unittest.TestCase): + def test_kotlin_stdlib_path_skipped(self): + with TempApktoolOut(["smali"]) as out: + # kotlin/coroutines/... — should be skipped by DEFAULT_THIRD_PARTY_PREFIXES + _write_smali(out / "smali", "kotlin/foo/Bar.smali", textwrap.dedent("""\ + .class public Lkotlin/foo/Bar; + .method public m()V + const-string v0, "http://kotlin.io/test" + .end method + """)) + report = audit_smali(out, _meta()) + self.assertEqual(report.files_scanned, 0) + self.assertEqual(report.findings, []) + + def test_app_package_overrides_prefix_skip(self): + """A first-party class whose path starts with 'com' must not be + skipped even though 'com' matches a common third-party prefix + bucket. The app_package override lets it through. + """ + with TempApktoolOut(["smali"]) as out: + _write_smali(out / "smali", "com/demo/app/Hit.smali", textwrap.dedent("""\ + .class public Lcom/demo/app/Hit; + .method public m()V + const-string v0, "http://hit.test" + .end method + """)) + report = audit_smali(out, _meta("com.demo.app")) + self.assertEqual(report.files_scanned, 1) + self.assertIn("CODE-001", [f.rule_id for f in report.findings]) + + +class MergeCodeReportsTests(unittest.TestCase): + def test_both_none_returns_none(self): + self.assertIsNone(merge_code_reports(None, None)) + + def test_only_java(self): + java = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-001", title="t", severity="medium", + file="x.java", class_fqn="com.x.A", + )], + files_scanned=1, + ) + merged = merge_code_reports(java, None) + self.assertIs(merged, java) + + def test_only_smali(self): + smali = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-001", title="t", severity="medium", + file="x.smali", class_fqn="com.x.A", evidence_tier="smali", + )], + files_scanned=1, + ) + merged = merge_code_reports(None, smali) + self.assertIs(merged, smali) + + def test_java_wins_on_overlap(self): + java = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-001", title="java", severity="medium", + file="x.java", class_fqn="com.x.A", + line_text="String url = \"http://...\"", + )], + files_scanned=1, + ) + smali = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-001", title="smali", severity="medium", + file="x.smali", class_fqn="com.x.A", evidence_tier="smali", + line_text="const-string v0, \"http://...\"", + )], + files_scanned=2, + ) + merged = merge_code_reports(java, smali) + self.assertEqual(len(merged.findings), 1) + # java tier survived + self.assertEqual(merged.findings[0].evidence_tier, "java") + self.assertEqual(merged.findings[0].title, "java") + # files_scanned is sum + self.assertEqual(merged.files_scanned, 3) + + def test_distinct_findings_concatenate(self): + java = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-001", title="t", severity="medium", + file="x.java", class_fqn="com.x.A", + )], + ) + smali = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-003", title="t", severity="high", + file="y.smali", class_fqn="com.x.B", evidence_tier="smali", + )], + ) + merged = merge_code_reports(java, smali) + self.assertEqual(len(merged.findings), 2) + tiers = {f.evidence_tier for f in merged.findings} + self.assertEqual(tiers, {"java", "smali"}) + + def test_partial_flag_propagates(self): + java = CodeAuditReport( + package_name="com.x", findings=[], partial=True, + ) + smali = CodeAuditReport( + package_name="com.x", findings=[], partial=False, + ) + merged = merge_code_reports(java, smali) + self.assertTrue(merged.partial) + + +class CodeFindingEvidenceTierTests(unittest.TestCase): + def test_default_evidence_tier_is_java(self): + f = CodeFinding( + rule_id="CODE-001", title="t", severity="medium", + file="x.java", + ) + self.assertEqual(f.evidence_tier, "java") + + def test_to_dict_omits_default_tier(self): + f = CodeFinding( + rule_id="CODE-001", title="t", severity="medium", + file="x.java", + ) + d = f.to_dict() + self.assertNotIn("evidence_tier", d) + + def test_to_dict_includes_smali_tier(self): + f = CodeFinding( + rule_id="CODE-001", title="t", severity="medium", + file="x.smali", evidence_tier="smali", + ) + d = f.to_dict() + self.assertEqual(d["evidence_tier"], "smali") + + def test_from_dict_round_trip(self): + f = CodeFinding( + rule_id="CODE-001", title="t", severity="medium", + file="x.smali", evidence_tier="smali", + ) + f2 = CodeFinding.from_dict(f.to_dict()) + self.assertEqual(f2.evidence_tier, "smali") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/venomhook/android_pipeline.py b/src/venomhook/android_pipeline.py index 4696f54..436f8e3 100644 --- a/src/venomhook/android_pipeline.py +++ b/src/venomhook/android_pipeline.py @@ -43,12 +43,10 @@ logger = logging.getLogger(__name__) -# Phase 10-1: pipeline has 9 conceptual steps. The counter is reported -# to the operator in stderr so a 30-minute jadx run no longer looks like -# a hang. step_start logs "[N/9] name (start)" and returns the wall-clock -# time; step_end consumes that and logs "[N/9] name (완료 — Xs)". When -# logging is suppressed (--quiet) the calls are essentially free. -_PIPELINE_TOTAL = 9 +# Phase 10-1: pipeline step counter. Operator-facing only; logs each +# transition so a 30-minute jadx run no longer looks like a hang. The +# total expanded to 10 in Phase 10-4 (smali fallback audit). +_PIPELINE_TOTAL = 10 def _step_start(num: int, name: str) -> float: @@ -90,6 +88,7 @@ def _step_skip(num: int, name: str, reason: str) -> None: from venomhook.code_audit import audit_code from venomhook.jni_bridge import build_bridges, correlate_symbols from venomhook.manifest_audit import audit_manifest +from venomhook.smali_audit import audit_smali, merge_code_reports from venomhook.models import ( AndroidAppMeta, AndroidAuditReport, @@ -341,6 +340,9 @@ def analyze_apk( warnings.append(f"{msg} — 네이티브 분석을 건너뜁니다") _step_skip(2, ".so 추출", "네이티브 라이브러리 없음") _step_skip(3, "BinaryMeta 추출", "네이티브 라이브러리 없음") + # Step 10 also relies on .so strings — flag it now to keep the + # counter sequence visible even on base-APK / split-APK targets + # like KakaoTalk where lib/ is empty. else: # ----- Step 2: ABI selection + .so extraction ----- t0 = _step_start(2, ".so 추출") @@ -436,6 +438,7 @@ def analyze_apk( # ----- Step 4: AndroidManifest decode (optional) ----- app_meta: Optional[AndroidAppMeta] = None + apktool_out: Optional[Path] = None if use_apktool: t0 = _step_start(4, "AndroidManifest decode (apktool)") logger.info( @@ -539,14 +542,36 @@ def analyze_apk( # consumers can warn the reader that an empty rule # bucket may reflect missing input, not a clean app. code_audit_report.partial = True - if code_audit_report and app_meta is not None: - pocs.extend(generate_code_pocs(app_meta, code_audit_report)) except OSError as e: warnings.append(f"코드 감사 실패 (계속 진행): {e}") _step_end(8, "Code 감사 (Java sources)", t0) else: _step_skip(8, "Code 감사", "jadx sources 없음") + # ----- Step 9: smali tier audit (Phase 10-4 fallback) ----- + # apktool always produces smali_classes*/ directories alongside the + # decoded AndroidManifest.xml. Running smali_audit on that output + # guarantees code findings even when jadx times out, fails, or + # produced empty/garbage decompilation (Bangcle/AppGuard/OLLVM). + # Findings merge with the .java tier; on overlap the .java entry + # wins (its line text is more readable). + smali_report: Optional[CodeAuditReport] = None + if apktool_out is not None and apktool_out.is_dir(): + t0 = _step_start(9, "smali 폴백 감사") + try: + smali_report = audit_smali(apktool_out, app_meta) + except OSError as e: + warnings.append(f"smali 감사 실패 (계속 진행): {e}") + _step_end(9, "smali 폴백 감사", t0) + else: + _step_skip(9, "smali 폴백 감사", "apktool 결과 없음") + + # Merge .java + smali findings (java wins on overlap). The merged + # report is what HTML / JSON / PoC consumers downstream see. + code_audit_report = merge_code_reports(code_audit_report, smali_report) + if code_audit_report and app_meta is not None: + pocs.extend(generate_code_pocs(app_meta, code_audit_report)) + # ----- Step 9: categorize native-library strings (Phase 7-3) ----- # so_meta.strings is harvested by binary_meta from .rodata-style sections. # Categorizing here turns raw bytes into pentest-actionable hints @@ -556,9 +581,9 @@ def analyze_apk( native_string_hints: Optional[NativeStringHints] = None strings_by_symbol: dict[str, list[str]] = {} if so_meta is None: - _step_skip(9, "네이티브 string categorize", "so_meta 없음") + _step_skip(10, "네이티브 string categorize", "so_meta 없음") else: - t0 = _step_start(9, "네이티브 string categorize + symbol attribution") + t0 = _step_start(10, "네이티브 string categorize + symbol attribution") merged_strings: list[str] = list(so_meta.strings) for extra in additional_so_metas: merged_strings.extend(extra.strings) @@ -588,7 +613,7 @@ def analyze_apk( strings_by_symbol = attribute_strings_by_symbol_name( candidate_symbols, native_string_hints ) - _step_end(9, "네이티브 string categorize + symbol attribution", t0) + _step_end(10, "네이티브 string categorize + symbol attribution", t0) return AndroidAnalysis( apk_meta=apk_meta, diff --git a/src/venomhook/models.py b/src/venomhook/models.py index 52a5319..7a884a2 100644 --- a/src/venomhook/models.py +++ b/src/venomhook/models.py @@ -772,6 +772,14 @@ class CodeFinding: detail: str = "" remediation: str = "" references: list[str] = field(default_factory=list) + # Phase 10-4: which decompiled representation produced this finding. + # ``"java"`` (default) means a .java pattern via code_audit; ``"smali"`` + # means the smali fallback that runs whenever apktool produces + # smali_classes*/ directories (always for any decoded APK), giving + # us a guarantee of *some* code findings even when jadx fails + # entirely. HTML / JSON consumers surface the tier next to each + # finding so the reader knows the evidence form. + evidence_tier: str = "java" @classmethod def from_dict(cls, data: dict[str, Any]) -> "CodeFinding": @@ -786,6 +794,7 @@ def from_dict(cls, data: dict[str, Any]) -> "CodeFinding": detail=data.get("detail", ""), remediation=data.get("remediation", ""), references=list(data.get("references", [])), + evidence_tier=data.get("evidence_tier", "java"), ) def to_dict(self) -> dict[str, Any]: @@ -807,6 +816,9 @@ def to_dict(self) -> dict[str, Any]: result["remediation"] = self.remediation if self.references: result["references"] = list(self.references) + # Only serialize non-default tier to keep older JSON dumps clean. + if self.evidence_tier and self.evidence_tier != "java": + result["evidence_tier"] = self.evidence_tier return result diff --git a/src/venomhook/smali_audit.py b/src/venomhook/smali_audit.py new file mode 100644 index 0000000..2b8a5a4 --- /dev/null +++ b/src/venomhook/smali_audit.py @@ -0,0 +1,373 @@ +"""Tier-1 fallback code audit over apktool's smali output. + +Phase 10-4. apktool always produces ``smali/`` + ``smali_classes*/`` +directories alongside the AndroidManifest.xml, regardless of how +heavily the APK is obfuscated or packed. jadx may time out, OOM, or +fail to decompile certain DEX layouts entirely — when that happens +we used to lose the entire code-audit signal. This module reuses the +same rule IDs as :mod:`code_audit` but matches against smali bytecode +text instead of decompiled Java, so the operator gets *some* findings +even on the worst-case APKs (Bangcle / AppGuard / OLLVM / VMP). + +Coverage today +-------------- + +Four of the six CODE-* rules port cleanly to smali pattern matching: + + CODE-001 ``const-string`` containing ``http://`` literals + CODE-003 ``const-string`` carrying weak crypto algorithm names + (DES / 3DES / RC4 / MD5 / SHA-1) that feed into Cipher / + MessageDigest calls + CODE-005 invokes to ``Landroid/os/Environment;->getExternalStorage*`` + or ``Landroid/content/Context;->getExternal*`` + CODE-006 references to MODE_WORLD_READABLE (0x1) / MODE_WORLD_WRITEABLE + (0x2) constants on Context.openFileOutput / openSharedPrefs + +CODE-002 (WebView setJavaScriptEnabled) and CODE-004 (credentials in logs) +remain Java-tier only — smali patterns for these are noisy and would +add too many false positives. The .java tier (code_audit) is the +authoritative source for those two. + +Precision +--------- + +The smali tier is intentionally **conservative** — same rule IDs and +severities, but evidence_tier="smali" labels each finding so HTML / +JSON consumers can show "이 발견은 smali 기반" tooltips. When both +tiers fire on the same (rule_id, class) pair, the pipeline keeps the +Java-tier finding (richer line text); the smali duplicate is dropped. + +Pure-Python; no external dependencies. Skips third-party prefixes +that the Java tier already filters (Kotlin stdlib, AndroidX, Google +SDK, common ads / analytics) so a 200K-file smali tree like KakaoTalk +finishes in under a minute rather than five. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Iterator, Optional + +from venomhook.code_audit import ( + DEFAULT_THIRD_PARTY_PREFIXES, + _strip_line_comment, # quote-aware to match code_audit conventions +) +from venomhook.models import ( + AndroidAppMeta, + CodeAuditReport, + CodeFinding, +) + + +__all__ = [ + "audit_smali", + "iter_smali_dirs", + "iter_smali_files", + "SMALI_RULES", +] + + +# Per-rule patterns. Each entry: (rule_id, severity, title, regex, detail). +# Regexes are applied per-line; the file iterator strips // comments first +# to avoid commented-out matches. +_HTTP_URL_RE = re.compile( + r'const-string[^"]*"\s*(http://[^\s"\\]+)\s*"', + re.IGNORECASE, +) + +# Weak crypto names appear as JVM-style strings inside const-string. +# Smali shows them with their literal quoted form. We require the +# token to be a complete word (boundary) so "ANDES" doesn't fire. +_WEAK_CRYPTO_RE = re.compile( + r'const-string[^"]*"\s*(' + r'(?:DES|DES3|3DES|RC4|RC2)(?:/[\w/]*)?|' + r'MD5|MD2|SHA-1|SHA1' + r')\s*"', + re.IGNORECASE, +) + +# CODE-005 — external storage APIs. Smali invokes look like: +# invoke-static {}, Landroid/os/Environment;->getExternalStorageDirectory()... +# invoke-virtual {p0}, Landroid/content/Context;->getExternalFilesDir(...) +_EXT_STORAGE_RE = re.compile( + r'invoke-(?:static|virtual|direct|super)[^,]*,\s*' + r'L(?:' + r'android/os/Environment;->getExternalStorage(?:Directory|State|Public)' + r'|' + r'android/content/Context;->getExternal(?:FilesDir|CacheDir|MediaDirs)' + r')' +) + +# CODE-006 — MODE_WORLD_* file/preference modes. Smali references these +# as immediate constants (0x1 / 0x2) right before Context->openFileOutput +# or Context->getSharedPreferences. We match the MODE_* sget patterns +# AND the explicit numeric constants when accompanied by openFileOutput. +_MODE_WORLD_RE = re.compile( + r'(?:' + r'sget\s+\w+,\s*Landroid/content/Context;->MODE_WORLD_(?:READABLE|WRITEABLE)' + r'|' + r'->openFileOutput\([^)]*\)Ljava/io/FileOutputStream;' + r'.*MODE_WORLD' + r')', + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class _Rule: + rule_id: str + severity: str + title: str + regex: re.Pattern + detail_template: str + remediation: str + references: tuple[str, ...] + + +SMALI_RULES: tuple[_Rule, ...] = ( + _Rule( + rule_id="CODE-001", + severity="medium", + title="평문 HTTP 엔드포인트 (smali tier)", + regex=_HTTP_URL_RE, + detail_template=( + "smali 코드에 http:// URL 리터럴이 const-string으로 박혀 있습니다 " + "({matched}). MITM 가로채기 / 변조 위험. .java 디컴파일이 부분/" + "실패한 환경에서 본 smali tier가 같은 위험을 잡습니다." + ), + remediation=( + "HTTPS로 마이그레이션하고, network_security_config.xml로 cleartext " + "허용 호스트를 명시적으로 제한하세요." + ), + references=( + "OWASP MASVS-NETWORK-1", + "CWE-319", + ), + ), + _Rule( + rule_id="CODE-003", + severity="high", + title="약한 crypto / 해시 알고리즘 (smali tier)", + regex=_WEAK_CRYPTO_RE, + detail_template=( + "smali에 약한 알고리즘 이름이 const-string으로 박혀 있습니다 " + "({matched}). Cipher / MessageDigest.getInstance에 그대로 전달되면 " + "충돌 / 무결성 우회 가능." + ), + remediation=( + "AES/GCM (또는 ChaCha20-Poly1305), SHA-256 / SHA-512로 교체하세요. " + "메시지 인증이 필요하면 HMAC-SHA256를 함께 적용." + ), + references=( + "OWASP MASVS-CRYPTO-1", + "CWE-327", + ), + ), + _Rule( + rule_id="CODE-005", + severity="medium", + title="외부 저장소 사용 (smali tier)", + regex=_EXT_STORAGE_RE, + detail_template=( + "smali 코드가 외부 저장소 API를 호출합니다 ({matched}). 외부 " + "저장소는 다른 앱에서 직접 읽고 쓸 수 있어 민감 데이터 저장에 " + "부적합합니다." + ), + remediation=( + "내부 저장소(Context.getFilesDir 등) 또는 EncryptedSharedPreferences" + "/EncryptedFile (androidx.security)로 옮기세요." + ), + references=( + "OWASP MASVS-STORAGE-1", + "CWE-922", + ), + ), + _Rule( + rule_id="CODE-006", + severity="high", + title="MODE_WORLD_READABLE / WRITEABLE (smali tier)", + regex=_MODE_WORLD_RE, + detail_template=( + "smali에 Context.MODE_WORLD_* 모드가 참조됩니다 ({matched}). " + "Android 7+에서 deprecated이며, 다른 앱이 해당 파일을 직접 읽을 수 " + "있어 자격증명 / 토큰 누출 위험." + ), + remediation=( + "MODE_PRIVATE(0)로 변경하거나 EncryptedSharedPreferences " + "(androidx.security)로 교체하세요." + ), + references=( + "OWASP MASVS-STORAGE-1", + "CWE-732", + ), + ), +) + + +# ---------- file iteration ---------- + + +def iter_smali_dirs(apktool_out: str | Path) -> list[Path]: + """Return the smali / smali_classes*/ subdirectories under an apktool + output directory, sorted lex so the result is deterministic. + """ + root = Path(apktool_out) + if not root.is_dir(): + return [] + out: list[Path] = [] + for entry in sorted(root.iterdir()): + if entry.is_dir() and entry.name.startswith("smali"): + out.append(entry) + return out + + +def _is_third_party(rel: Path, app_package: Optional[str]) -> bool: + """Mirror code_audit's skip list — match by leading path segments + against DEFAULT_THIRD_PARTY_PREFIXES. The app_package override lets + a known first-party path that *happens* to share a prefix slip + through (e.g., com.kakao.* must not be skipped just because the + prefix 'com' is present). + """ + parts = rel.parts + if app_package: + # Convert package to path segments and accept anything starting + # with the same segments as first-party. + pkg_parts = tuple(app_package.split(".")) + if parts[: len(pkg_parts)] == pkg_parts: + return False + # Use the same prefix list as the Java tier + for prefix in DEFAULT_THIRD_PARTY_PREFIXES: + prefix_parts = tuple(prefix.split("/")) + if parts[: len(prefix_parts)] == prefix_parts: + return True + return False + + +def iter_smali_files( + apktool_out: str | Path, + app_meta: Optional[AndroidAppMeta] = None, +) -> Iterator[tuple[Path, Path]]: + """Yield ``(absolute_path, relative_path_from_smali_root)`` for every + .smali file across every smali_classes*/ directory under + ``apktool_out``, with third-party paths skipped. + """ + app_package = app_meta.package_name if app_meta else None + for smali_root in iter_smali_dirs(apktool_out): + for p in smali_root.rglob("*.smali"): + rel = p.relative_to(smali_root) + if _is_third_party(rel, app_package): + continue + yield p, rel + + +# ---------- audit ---------- + + +def _smali_class_fqn(rel: Path) -> str: + """foo/bar/Baz.smali -> foo.bar.Baz""" + parts = list(rel.parts) + if not parts: + return "" + parts[-1] = parts[-1].removesuffix(".smali") + return ".".join(parts) + + +def audit_smali( + apktool_out: str | Path, + app_meta: Optional[AndroidAppMeta] = None, + *, + max_findings_per_rule: int = 200, +) -> CodeAuditReport: + """Run the smali-tier rules and return a :class:`CodeAuditReport`. + + ``max_findings_per_rule`` caps each rule's output so a 200K-file + smali tree doesn't drown the report — operators reading the cap + line in the report can re-run with a higher cap or post-filter + the JSON. + """ + findings: list[CodeFinding] = [] + files_scanned = 0 + counts_per_rule: dict[str, int] = {r.rule_id: 0 for r in SMALI_RULES} + + for abs_path, rel in iter_smali_files(apktool_out, app_meta): + files_scanned += 1 + try: + text = abs_path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + class_fqn = _smali_class_fqn(rel) + for line_no, raw_line in enumerate(text.splitlines(), 1): + line = _strip_line_comment(raw_line) + if not line: + continue + for rule in SMALI_RULES: + if counts_per_rule[rule.rule_id] >= max_findings_per_rule: + continue + m = rule.regex.search(line) + if not m: + continue + matched = m.group(1) if m.groups() else m.group(0) + findings.append(CodeFinding( + rule_id=rule.rule_id, + title=rule.title, + severity=rule.severity, + file=str(rel), + line_no=line_no, + line_text=line.strip()[:300], + class_fqn=class_fqn, + detail=rule.detail_template.format(matched=matched.strip()), + remediation=rule.remediation, + references=list(rule.references), + evidence_tier="smali", + )) + counts_per_rule[rule.rule_id] += 1 + + package_name = app_meta.package_name if app_meta else "" + return CodeAuditReport( + package_name=package_name, + findings=findings, + files_scanned=files_scanned, + ) + + +def merge_code_reports( + java_report: Optional[CodeAuditReport], + smali_report: Optional[CodeAuditReport], +) -> Optional[CodeAuditReport]: + """Combine .java and smali findings, preferring .java on overlap. + + Two findings are considered the same when their (rule_id, class_fqn) + match — the .java tier wins because its evidence (line text) is + more readable. The smali tier covers the gap when jadx had no + output for a given class. + + Either argument can be None; result is the other (or None when both + are None). When both are present, the smali tier's files_scanned is + added to the result for completeness, and partial flag is OR'd. + """ + if java_report is None and smali_report is None: + return None + if java_report is None: + return smali_report + if smali_report is None: + return java_report + + seen: set[tuple[str, str]] = { + (f.rule_id, f.class_fqn) for f in java_report.findings + } + merged: list[CodeFinding] = list(java_report.findings) + for f in smali_report.findings: + key = (f.rule_id, f.class_fqn) + if key in seen: + continue + merged.append(f) + seen.add(key) + + return CodeAuditReport( + package_name=java_report.package_name or smali_report.package_name, + findings=merged, + files_scanned=java_report.files_scanned + smali_report.files_scanned, + partial=bool(java_report.partial or smali_report.partial), + ) From 3019d67cd16f3bd8d4d0a737af2f52a7555e49ab Mon Sep 17 00:00:00 2001 From: sp3arm4n Date: Wed, 13 May 2026 01:20:07 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(android):=20jadx=20tuning=20knobs=20?= =?UTF-8?q?=E2=80=94=20threads=20+=20fast=20mode=20(Phase=2010-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P10-5 was scoped as "selective decompile via app-package filter" so jadx skips library code. Investigation found jadx 1.5.4 has no batch package-include CLI option — only --single-class for one class at a time. A multi-invocation workaround would be slower, not faster. Pivoting honestly: deliver the two jadx-side tuning knobs that DO move the wall-clock needle on large APKs, and rely on Phase 10-4 (smali tier) as the actual fallback robustness layer the original P10-5 was meant to enable. --jadx-threads N Override -j (jadx default: 4). Bumping on a many-core box parallelises decompilation. --jadx-fast Append "-m simple" so jadx skips structure- restoring passes (goto-style linear output). Output .java is uglier but VenomHook rules match against literal const-strings + method calls, so audit findings are unaffected. 30-50%% wall-clock saving on obfuscated APKs. Both knobs propagate through CLI -> JadxConfig -> subprocess cmd line. JadxConfig defaults stay safe (threads=None -> jadx's own 4, fast_mode=False -> "auto" structure restoration), so existing tests and external callers see no behavior change. Honest scope note: KakaoTalk-class APKs may still time out even with --jadx-fast --jadx-threads 16; the smali tier (Phase 10-4) is the actual robustness guarantee — it always runs on apktool output regardless of how jadx finishes. Tests +3: - --jadx-threads / --jadx-fast reach JadxConfig via CLI - fast_mode=True appends -m simple in the command - default fast_mode=False does NOT emit -m Co-Authored-By: Claude Opus 4.7 (1M context) --- sample/tests/test_cli_android_audit.py | 20 ++++++++++ sample/tests/test_jadx_runner.py | 53 ++++++++++++++++++++++++++ src/venomhook/cli.py | 24 +++++++++++- src/venomhook/jadx_runner.py | 14 ++++++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/sample/tests/test_cli_android_audit.py b/sample/tests/test_cli_android_audit.py index d69c302..c61e0d4 100644 --- a/sample/tests/test_cli_android_audit.py +++ b/sample/tests/test_cli_android_audit.py @@ -856,6 +856,26 @@ def test_no_jadx_args_means_no_config(self): ]) self.assertIsNone(captured["jadx_config"]) + def test_jadx_threads_and_fast_mode_propagate(self): + """Phase 10-5: --jadx-threads and --jadx-fast reach JadxConfig.""" + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = _make_apk_with_lib(tdp) + captured = self._capture_analyze([ + "android-audit", + "--apk", str(apk), + "--out-dir", str(tdp / "work"), + "--jadx-threads", "8", + "--jadx-fast", + "--quiet", + ]) + jc = captured["jadx_config"] + self.assertEqual(jc.threads, 8) + self.assertTrue(jc.fast_mode) + # untouched fields keep defaults + self.assertEqual(jc.timeout_sec, 600) + self.assertIsNone(jc.jadx_path) + if __name__ == "__main__": unittest.main() diff --git a/sample/tests/test_jadx_runner.py b/sample/tests/test_jadx_runner.py index cd975c8..57c805e 100644 --- a/sample/tests/test_jadx_runner.py +++ b/sample/tests/test_jadx_runner.py @@ -604,6 +604,59 @@ def fake_run(cmd, **kw): self.assertIn("--decompilation-mode", cmd) self.assertIn("simple", cmd) + def test_fast_mode_appends_simple_mode_flag(self): + """Phase 10-5: fast_mode=True adds '-m simple' to the command.""" + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = tdp / "x.apk" + apk.write_bytes(b"PK") + seen: dict[str, list[str]] = {} + + class FakeCompleted: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(cmd, **kw): + seen["cmd"] = list(cmd) + out_dir = Path(cmd[cmd.index("-d") + 1]) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "x.java").write_text("class X {}") + return FakeCompleted() + + cfg = JadxConfig(jadx_path="/usr/bin/jadx", fast_mode=True) + with mock.patch("venomhook.jadx_runner.subprocess.run", side_effect=fake_run): + run_jadx(apk, tdp / "o", config=cfg) + cmd = seen["cmd"] + # -m simple must be present + self.assertIn("-m", cmd) + self.assertEqual(cmd[cmd.index("-m") + 1], "simple") + + def test_fast_mode_default_false_does_not_emit_m_flag(self): + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + apk = tdp / "x.apk" + apk.write_bytes(b"PK") + seen: dict[str, list[str]] = {} + + class FakeCompleted: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(cmd, **kw): + seen["cmd"] = list(cmd) + out_dir = Path(cmd[cmd.index("-d") + 1]) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "x.java").write_text("class X {}") + return FakeCompleted() + + cfg = JadxConfig(jadx_path="/usr/bin/jadx") + with mock.patch("venomhook.jadx_runner.subprocess.run", side_effect=fake_run): + run_jadx(apk, tdp / "o", config=cfg) + # -m must not appear because fast_mode defaults to False + self.assertNotIn("-m", seen["cmd"]) + # ---------- decompile_apk ---------- diff --git a/src/venomhook/cli.py b/src/venomhook/cli.py index 26a655b..82d5af0 100644 --- a/src/venomhook/cli.py +++ b/src/venomhook/cli.py @@ -553,6 +553,19 @@ def main(argv: list[str] | None = None) -> None: "Large multi-DEX APKs (KakaoTalk-scale, 200MB+ with 15+ DEX) often " "need 1800+ to finish.", ) + audit_parser.add_argument( + "--jadx-threads", type=int, default=None, + help="jadx parallelism (-j N). Default 4 (jadx built-in). Bumping " + "this on a many-core machine cuts wall-clock on heavy APKs.", + ) + audit_parser.add_argument( + "--jadx-fast", action="store_true", + help="Run jadx with -m simple to skip structure-restoring passes. " + "Output .java is uglier (linear, goto-style) but VenomHook rules " + "are insensitive to control-flow shape — they match literal " + "const-strings and method calls. 30-50%% wall-clock saving on " + "obfuscated APKs. Independent of --jadx-timeout / --apk-lib.", + ) audit_parser.add_argument( "--no-jadx", action="store_true", help="Skip jadx (java decompile + JNI bridges); audit-only mode", @@ -1028,12 +1041,21 @@ def write_json(path: Path, payload: object) -> None: logging.info("using temporary work dir: %s", work_dir) apktool_config = ApktoolConfig(apktool_path=args.apktool_path) if args.apktool_path else None - if args.jadx_path or args.jadx_timeout is not None: + if ( + args.jadx_path + or args.jadx_timeout is not None + or getattr(args, "jadx_threads", None) is not None + or getattr(args, "jadx_fast", False) + ): jadx_kwargs: dict = {} if args.jadx_path: jadx_kwargs["jadx_path"] = args.jadx_path if args.jadx_timeout is not None: jadx_kwargs["timeout_sec"] = args.jadx_timeout + if getattr(args, "jadx_threads", None) is not None: + jadx_kwargs["threads"] = args.jadx_threads + if getattr(args, "jadx_fast", False): + jadx_kwargs["fast_mode"] = True jadx_config = JadxConfig(**jadx_kwargs) else: jadx_config = None diff --git a/src/venomhook/jadx_runner.py b/src/venomhook/jadx_runner.py index 4183088..9712048 100644 --- a/src/venomhook/jadx_runner.py +++ b/src/venomhook/jadx_runner.py @@ -83,8 +83,15 @@ class JadxConfig: no_imports: bool = True # --no-imports no_debug_info: bool = True # --no-debug-info show_bad_code: bool = False # --show-bad-code (emits broken decompilations) - threads: Optional[int] = None # -j N + threads: Optional[int] = None # -j N (None = jadx default of 4) timeout_sec: int = 600 + # Phase 10-5: -m simple skips deobfuscation passes and uses the + # linear (goto-style) IR translation. Output Java is uglier but the + # rule patterns we run on it (const-string + method invocations) + # are unaffected. On KakaoTalk-scale APKs this can cut wall-clock + # by 30-50% — at the cost of harder-to-read .java for any manual + # follow-up. When False (default) jadx picks "auto" mode. + fast_mode: bool = False extra_args: list[str] = field(default_factory=list) @@ -194,6 +201,11 @@ def run_jadx( cmd.append("--show-bad-code") if cfg.threads is not None and cfg.threads > 0: cmd.extend(["-j", str(cfg.threads)]) + if cfg.fast_mode: + # `-m simple` switches off the structure-restoring passes — the + # rule engine doesn't care because it matches against literal + # const-strings and method calls, not control-flow shape. + cmd.extend(["-m", "simple"]) cmd.extend(cfg.extra_args) cmd.append(str(apk)) From 659a699ab74ba13febad19d142742574e65a4c49 Mon Sep 17 00:00:00 2001 From: sp3arm4n Date: Wed, 13 May 2026 01:24:31 +0900 Subject: [PATCH 7/7] docs(readme): reflect Phase 10 (scan-apk + 10-step + smali tier + jadx tuning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lead bullets: scan-apk as the single APK entry point; 10 manifest rules; 2-tier code audit (java + smali); MASVS HTML grouping; 10-step live stderr progress output - Common Commands restructured: separate "Android APK 분석" (scan-apk-first) and "네이티브 바이너리 분석" sections. Adds --jadx-timeout / --jadx-threads / --jadx-fast / --apk-lib all examples for the KakaoTalk-scale workflow - Project Layout updated: smali_audit.py, rule_taxonomy.py listed; test count bumped to 834+ - Deprecation note on offset-static --apk / offset-e2e --apk Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 45 ++++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 599060e..f13f2ab 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,16 @@ VenomHook은 네이티브 바이너리와 Android APK를 정적 분석해 후킹 Binary/APK -> StaticMeta -> Endpoint scoring -> HookSpec -> Frida script -> Runtime report ``` -PE, ELF, Mach-O를 같은 데이터 모델로 다룹니다. Android APK 흐름은 다음을 한 번에 수행합니다. +PE, ELF, Mach-O를 같은 데이터 모델로 다룹니다. Android APK 분석의 단일 진입점은 `venomhook scan-apk`이며 한 명령으로 다음을 모두 수행합니다. -- **Manifest 감사** — 9 룰 (debuggable, cleartext / NSC base-config, allowBackup, exported 컴포넌트 / provider, grantUriPermissions, 위험 권한, 구버전 SDK) -- **코드 레벨 감사** — jadx 디컴파일 결과 위에서 6 룰 (평문 HTTP, WebView setJavaScriptEnabled / addJavascriptInterface, 약한 Cipher / 해시, 평문 자격증명 로그, 외부 저장소 사용, MODE_WORLD_READABLE/WRITEABLE) +- **Manifest 감사** — 10 룰 (debuggable, cleartext / NSC base-config / user-cert trust, allowBackup, exported 컴포넌트 / provider, grantUriPermissions, 위험 권한, 구버전 SDK) +- **2-tier 코드 레벨 감사** + - **Java tier** — jadx 디컴파일 결과 위에서 6 룰 (평문 HTTP, WebView setJavaScriptEnabled / addJavascriptInterface, 약한 Cipher / 해시, 평문 자격증명 로그, 외부 저장소 사용, MODE_WORLD_READABLE/WRITEABLE) + - **Smali tier (NEW)** — jadx가 타임아웃 / 실패해도 apktool이 생성한 smali에서 4 룰(CODE-001/003/005/006)을 매칭. 패킹·난독화 APK에도 코드 결함 산출 보장 - **공격면 추출** — deeplink / 데이터 스킴 / 카테고리, intent-filter 구조, JNI bridge correlation, .so 문자열 카테고리화 (URL / 경로 / 쉘 / crypto / 자격증명 단서 / SQL) - **PoC 자동 생성** — adb 명령, Frida 후킹, mitmproxy 가로채기, logcat grep 레시피를 .sh / .frida.js / .md 번들로 export -- **자체 포함 HTML 보고서** — 심각도 색상 카드, 코드 단서 인용, on-disk PoC 링크 — 외부 자산 / JS 의존 없음 +- **자체 포함 HTML 보고서** — 심각도 색상 카드, MASVS 카테고리 그룹핑, 코드 단서 인용, on-disk PoC 링크 — 외부 자산 / JS 의존 없음 +- **10단계 라이브 진행 출력** — 모든 단계가 stderr에 `[N/10] step ...` 형식으로 진행 표시. `--quiet`로 억제 가능 이 도구는 권한이 있는 분석 대상에서 리버스 엔지니어링, 보안 검증, 펜테스트, 동적 계측 자동화를 돕기 위한 용도입니다. @@ -85,18 +88,32 @@ pip install -e . `pip install -e .` 이후에는 `venomhook` 명령을 바로 사용할 수 있습니다. +### Android APK 분석 — `scan-apk` 한 명령 + +APK 펜테스트의 **단일 진입점**입니다. 옵션 없이 호출하면 manifest + 코드 + smali + HTML + PoC 번들 + JNI bridge correlation을 모두 실행합니다. (`android-audit`는 동일한 동작의 호환 별칭으로 유지됩니다.) + +| 목적 | 명령 | +| --- | --- | +| **기본 — 전체 감사 + 모든 산출물** | `venomhook scan-apk --apk ./app.apk --out-dir ./out --out-html ./out/audit.html --poc-bundle-dir ./out/pocs` | +| Manifest만 빠르게 (jadx 건너뜀) | `venomhook scan-apk --apk ./app.apk --out-dir ./out --no-jadx` | +| 모든 .so 분석 (multi-lib) | `venomhook scan-apk --apk ./app.apk --out-dir ./out --apk-lib all` | +| 대용량 APK — jadx 타임아웃 + 빠른 모드 | `venomhook scan-apk --apk ./big.apk --out-dir ./out --jadx-timeout 1800 --jadx-threads 8 --jadx-fast` | +| CI 게이트 — high 이상 발견 시 비-0 종료 | `venomhook scan-apk --apk ./app.apk --out-dir ./out --severity-threshold high --quiet` | +| 결과 캐시 (재실행 시 즉시 replay) | `venomhook scan-apk --apk ./app.apk --out-dir ./out --cache-dir ./cache` | +| 특정 산출물만 별도 저장 | `--report-json`, `--audit-json`, `--code-audit-json`, `--poc-json` 플래그 | + +### 네이티브 바이너리 분석 (HookSpec / Frida) + | 목적 | 명령 | | --- | --- | | 샘플 전체 흐름 실행 | `venomhook offset-e2e --static-json ./sample/examples/static_meta.sample.json --target sample.exe --out-dir ./out` | | HookSpec만 생성 | `venomhook offset-static --static-json ./sample/examples/static_meta.sample.json --out ./out/venomhook.json --out-db ./out/venomhook.db` | | Frida 스크립트 생성 | `venomhook offset-hook --hookspec ./out/venomhook.json --target sample.exe --out-script ./out/venomhook.js` | | 실제 바이너리 Ghidra 분석 | `venomhook offset-static --binary ./path/to/target.exe --ghidra-headless analyzeHeadless --ghidra-script ./ghidra_scripts/export_staticmeta.py --out ./reports/hook/venomhook.json` | -| APK manifest 빠른 감사 | `venomhook android-audit --apk ./sample/myapp.apk --no-jadx --out-dir ./out_audit --audit-json ./out_audit/audit.json` | -| APK 전체 감사 (manifest + 코드) + HTML 보고서 + PoC 번들 | `venomhook android-audit --apk ./sample/myapp.apk --out-dir ./out_audit --out-html ./out_audit/audit.html --poc-bundle-dir ./out_audit/pocs` | -| 코드 감사 결과만 별도 JSON | `venomhook android-audit --apk ./sample/myapp.apk --out-dir ./out_audit --code-audit-json ./out_audit/code_audit.json` | -| CI 게이트 — high 이상 발견 시 비-0 종료 | `venomhook android-audit --apk ./sample/myapp.apk --out-dir ./out_audit --severity-threshold high --quiet` | | Frida 로그 요약 | `venomhook offset-report-runtime --log ./logs/frida.log --out-md ./out/summary.md --out-html ./out/summary.html` | +> `offset-static --apk` / `offset-e2e --apk`는 Ghidra-routed 변형이며 deprecation 경고가 표시됩니다. Android 워크플로는 `scan-apk`만 사용해주세요. + ## Requirements | Tool | Required | Purpose | @@ -160,17 +177,19 @@ venomhook/ ├── ghidra_scripts/ ├── sample/ │ ├── examples/ -│ └── tests/ # 747+ 단위 테스트 +│ └── tests/ # 834+ 단위 테스트 ├── setup/ ├── src/venomhook/ │ ├── apk_decoder.py # Manifest + apktool.yml + NSC + intent-filter 파싱 │ ├── binary_meta.py # .so 메타 (lief) + .rodata strings 추출 -│ ├── manifest_audit.py # MANIFEST-001..009 (9 룰) -│ ├── code_audit.py # CODE-001..006 (6 룰, jadx 위에서) -│ ├── native_strings.py # .so 문자열 8 카테고리 분류 +│ ├── manifest_audit.py # MANIFEST-001..010 (10 룰) +│ ├── code_audit.py # CODE-001..006 (.java tier, jadx 위에서) +│ ├── smali_audit.py # CODE-001/003/005/006 (smali tier, apktool 위에서 — jadx 실패 시 폴백) +│ ├── native_strings.py # .so 문자열 8 카테고리 분류 + 심볼 attribution +│ ├── rule_taxonomy.py # MASVS 카테고리 매핑 (HTML 그룹핑) │ ├── poc_generator.py # adb / Frida / mitmproxy / logcat PoC 빌더 │ ├── audit_html_report.py # 자체 포함 HTML 보고서 -│ ├── android_pipeline.py # 9-step 파이프라인 통합 +│ ├── android_pipeline.py # 10-step 파이프라인 통합 (라이브 진행 출력) │ └── ... ├── ARCHITECTURE.md ├── pyproject.toml