Skip to content
45 changes: 32 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`로 억제 가능

이 도구는 권한이 있는 분석 대상에서 리버스 엔지니어링, 보안 검증, 펜테스트, 동적 계측 자동화를 돕기 위한 용도입니다.

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions sample/tests/test_android_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
183 changes: 183 additions & 0 deletions sample/tests/test_cli_android_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,5 +694,188 @@ 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.

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"])

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()
Loading
Loading