Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ The TUI lets you configure:
- Dump mode: DEX, SO, ALL, or APK-aware (default `apk`).
- Local output directory.

On-device flow: the TUI pushes the **v9 C++ binary** (`NHDD-cpp-modular` build output) to `/data/local/tmp/nhdd` and falls back to the root `nhdd` (v8) if not found, uses `/data/local/tmp/.cache/nhdd` as the per-run device output (wiped via the root command before each run), then pulls with `adb root` when available (fallback: root `chmod`/`chcon`). Note: raw `nhdd` CLI examples above write to `/data/local/tmp/nhdd_dump` by default, while the TUI and GUI manage their own output locations.
On-device flow: the TUI pushes the **v9 C++ binary** (`NHDD-cpp-modular` build output) to `/data/local/tmp/nhdd` and falls back to the root `nhdd` (v8) if not found, uses `/data/local/tmp/nhdd_dump` as the device output directory — the same location the raw CLI and the GUI use (the TUI wipes and recreates it via the root command before each run, so the latest run wins) — then pulls with `adb root` when available (fallback: root `chmod`/`chcon`).

After pulling, the TUI automatically repairs results using `fix_dex_checksum.py` / `fix_so.py` (same classification as the GUI), writes a `log.txt`, and can repack the dump into an APK with `scripts/repack_apk_fast.py` (unsigned, jadx-analysis only).

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ TUI 会提示:
- dump 模式选择 DEX、SO 或 ALL。
- 本地输出目录。

设备端流程:TUI 会优先把 **v9 C++ 版二进制**(`NHDD-cpp-modular` 构建产物)推送到 `/data/local/tmp/nhdd`,找不到才回退根目录的 v8 `nhdd`;每次运行前先用 root 命令清理重建设备端输出目录 `/data/local/tmp/.cache/nhdd`,拉取时优先 `adb root`,不可用则回退 root `chmod`/`chcon`。注意:上文原始 `nhdd` 命令行示例默认写入 `/data/local/tmp/nhdd_dump`,而 TUI/GUI 各自管理自己的输出位置。
设备端流程:TUI 会优先把 **v9 C++ 版二进制**(`NHDD-cpp-modular` 构建产物)推送到 `/data/local/tmp/nhdd`,找不到才回退根目录的 v8 `nhdd`;设备端输出目录统一为 `/data/local/tmp/nhdd_dump`(与命令行默认值和 GUI 相同),TUI 每次运行前用 root 命令清理重建该目录,即“最近一次运行优先”;拉取时优先 `adb root`,不可用则回退 root `chmod`/`chcon`。

拉取后 TUI 会自动用 `fix_dex_checksum.py` / `fix_so.py` 修复结果(分类口径与 GUI 一致),写入 `log.txt`,并可用 `scripts/repack_apk_fast.py` 把 dump 重打包进 APK(不签名,仅供 jadx 静态分析)。

Expand Down
14 changes: 14 additions & 0 deletions fix_so.py
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,20 @@ def repair_elf(data_in: bytes, *, strip_bad_sht: bool = True, rebuild_bad_sht: b
if not ok:
actions.append(f"recoverable PHT issue: {reason}")

# 0) memory-layout 兜底:p_offset 指向 EOF 之外、而 p_vaddr 落在文件范围内时,
# 说明 dump 是"从 0 展开的连续镜像"被错写了 file offset。先按 GUI 基线
# (gui/sofix.go)语义把 p_offset 掰回 p_vaddr,再走后续推断/裁剪。
# 没有这一步,clamp 会把健康段 p_filesz 归零、find_dynamic_offset 找不到
# .dynamic 而误杀 PT_DYNAMIC(实测 so_offset_skew 契约样例,修复反而破坏文件)。
info0 = ElfInfo(bytes(data))
for ph in info0.phdrs:
if ph.p_type not in (PT_LOAD, PT_DYNAMIC) or ph.p_filesz == 0:
continue
if ph.p_offset >= info0.size and ph.p_vaddr < info0.size:
set_ph_offset(data, info0, ph, ph.p_vaddr)
actions.append(f"PHDR[{ph.idx}].p_offset: 0x{ph.p_offset:x} -> 0x{ph.p_vaddr:x} (memory-layout fallback)")
info = ElfInfo(bytes(data))

# 1) 修复 LOAD p_offset:从 memory-layout/p_offset=p_vaddr 还原为更合理的 file-layout。
# 注意:这一步必须允许 LOAD_FILESZ_OOB 继续往下走,否则 liball_in_one 这类
# “offset/size 同时错”的 dump 会在清 SHT 前被直接判死。
Expand Down
2 changes: 1 addition & 1 deletion nhdd_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
# ---------------------------------------------------------------------------
ADB = os.environ.get("NHDD_ADB", "adb")
NHDD_BIN = "/data/local/tmp/nhdd"
DEVICE_DUMP_DIR = "/data/local/tmp/.cache/nhdd"
DEVICE_DUMP_DIR = "/data/local/tmp/nhdd_dump" # 与 GUI(gui/adb.go deviceDumpDir)及设备二进制默认值统一
VERSION = "9.0-cpp-class"
_device_serial = None # set after device selection
console = Console()
Expand Down
41 changes: 22 additions & 19 deletions tests/contract/REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,29 @@ D:\project\NHDD\NHDD-cpp-modular\src/process_memory.hpp:10:10: fatal error: sys/
10 | #include <sys/uio.h>
| ^~~~~~~~~~~
compilation terminated.
- python_cli: ok (7 fixtures)

| fixture | go | python | v9 | go==py bytes |
|---|---|---|---|---|
| dex_valid.dex | OK | OK | SKIP | yes |
| dex_bad_adler32.dex | FIXED | FIXED | SKIP | yes |
| dex_bad_sha1.dex | FIXED | FIXED | SKIP | yes |
| dex_filesize_low.dex | FIXED | FIXED | SKIP | yes |
| dex_header_size_bad.dex | FIXED | FIXED | SKIP | yes |
| dex_endian_bad.dex | FIXED | FIXED | SKIP | yes |
| dex_maplist_off_bad.dex | FIXED | FIXED | SKIP | yes |
| dex_maplist_count_huge.dex | FIXED | FIXED | SKIP | yes |
| dex_too_small.dex | BAD | BAD | SKIP | yes |
| dex_magic_erased.dex | BAD | BAD | SKIP | yes |
| so_valid.so | OK | OK | SKIP | yes |
| so_magic_erased.so | FIXED | FIXED | SKIP | yes |
| so_offset_skew.so | FIXED | FIXED | SKIP | yes |
| so_et_exec.so | BAD | BAD | SKIP | yes |
| so_tiny.so | BAD | BAD | SKIP | yes |
| so_not_elf.so | BAD | BAD | SKIP | yes |
| so_phnum_bogus.so | BAD | BAD | SKIP | yes |
CLI 列 = `python3 fix_so.py` 全量修复路径(IDA 友好取向,独立基线,不与 Go 比字节)。

| fixture | go | python | v9 | python_cli | go==py bytes |
|---|---|---|---|---|---|
| dex_valid.dex | OK | OK | — | — | yes |
| dex_bad_adler32.dex | FIXED | FIXED | — | — | yes |
| dex_bad_sha1.dex | FIXED | FIXED | — | — | yes |
| dex_filesize_low.dex | FIXED | FIXED | — | — | yes |
| dex_header_size_bad.dex | FIXED | FIXED | — | — | yes |
| dex_endian_bad.dex | FIXED | FIXED | — | — | yes |
| dex_maplist_off_bad.dex | FIXED | FIXED | — | — | yes |
| dex_maplist_count_huge.dex | FIXED | FIXED | — | — | yes |
| dex_too_small.dex | BAD | BAD | — | — | yes |
| dex_magic_erased.dex | BAD | BAD | — | — | yes |
| so_valid.so | OK | OK | — | FIXED | yes |
| so_magic_erased.so | FIXED | FIXED | — | FIXED | yes |
| so_offset_skew.so | FIXED | FIXED | — | FIXED | yes |
| so_et_exec.so | BAD | BAD | — | REJECTED | yes |
| so_tiny.so | BAD | BAD | — | REJECTED | yes |
| so_not_elf.so | BAD | BAD | — | REJECTED | yes |
| so_phnum_bogus.so | BAD | BAD | — | REJECTED | yes |

## 差异

Expand Down
72 changes: 60 additions & 12 deletions tests/contract/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ def run_impl(impl: str, out_json: str) -> dict:
if proc.returncode != 0:
return {"impl": impl, "skipped": True,
"reason": "v9 runner failed: " + (proc.stderr or proc.stdout)[-400:]}
elif impl == "python_cli":
proc = subprocess.run(
[sys.executable, os.path.join(HERE, "run_python_cli.py")],
env=env, capture_output=True, text=True, cwd=REPO)
if proc.returncode != 0:
return {"impl": impl, "skipped": True,
"reason": "cli runner failed: " + (proc.stderr or proc.stdout)[-400:]}
with open(out_json, "r", encoding="utf-8") as fh:
return json.load(fh)

Expand All @@ -112,9 +119,11 @@ def build_report(fixtures: list, runs: dict) -> tuple[list, list]:
go = index_results(runs["go"])
py = index_results(runs["python"])
v9 = index_results(runs["v9"])
cli = index_results(runs.get("python_cli", {}))
go_skipped = runs["go"].get("skipped")
py_skipped = runs["python"].get("skipped")
v9_skipped = runs["v9"].get("skipped")
cli_skipped = runs.get("python_cli", {}).get("skipped")

for fx in fixtures:
name = fx["name"]
Expand All @@ -124,7 +133,7 @@ def build_report(fixtures: list, runs: dict) -> tuple[list, list]:
pred = fx.get("predict", {})

def cell(r):
return r["status"] if r else ("SKIP" if True else "")
return r["status"] if r else "—"

# prediction checks (informational)
for impl_name, r in (("go", g), ("python", p), ("v9", v)):
Expand All @@ -151,10 +160,27 @@ def cell(r):
if v and pred.get("v9") not in ("?", "N/A", None, ""):
pass # drift already covered by PREDICTION-MISMATCH loop above

# CLI 全量修复路径:独立基线,不比对 go 字节;只查硬不变量
c = cli.get(name) if not cli_skipped else None
cli_exp = fx.get("cli")
if c and cli_exp:
if cli_exp.get("rejected"):
if c["status"] != "REJECTED":
divergences.append(
f"CLI-INVARIANT {name}: expected REJECTED, got {c['status']}")
elif c["status"] in ("OK", "FIXED"):
if cli_exp.get("deep_valid") and c.get("deep_valid") is False:
divergences.append(
f"CLI-INVARIANT {name}: repaired output fails validate_elf_deep")
if cli_exp.get("dynamic_kept") and c.get("dynamic_kept") is False:
divergences.append(
f"CLI-INVARIANT {name}: repair dropped PT_DYNAMIC")

row = {
"name": name,
"intent": fx["intent"],
"go": cell(g), "python": cell(p), "v9": cell(v),
"cli": cell(c),
"hash_match_go_py": (g["sha256"] == p["sha256"]) if (g and p) else None,
}
rows.append(row)
Expand All @@ -165,17 +191,19 @@ def render_md(fixtures: list, rows: list, divergences: list, runs: dict) -> str:
lines = ["# NHDD 修复管线契约报告 (contract report)", ""]
lines.append("基线口径: Go dev 版 bad/ 隔离。v9 为原语级 harness,不做隔离判定。")
lines.append("")
for impl in ("go", "python", "v9"):
if runs[impl].get("skipped"):
lines.append(f"- **{impl}: SKIPPED** — {runs[impl].get('reason','')}")
for impl, label in (("go", "go"), ("python", "python"), ("v9", "v9"), ("python_cli", "python_cli")):
if runs.get(impl, {}).get("skipped"):
lines.append(f"- **{label}: SKIPPED** — {runs[impl].get('reason','')}")
else:
lines.append(f"- {impl}: ok ({len(runs[impl].get('results', []))} fixtures)")
lines.append(f"- {label}: ok ({len(runs[impl].get('results', []))} fixtures)")
lines.append("")
lines.append("CLI 列 = `python3 fix_so.py` 全量修复路径(IDA 友好取向,独立基线,不与 Go 比字节)。")
lines.append("")
lines.append("| fixture | go | python | v9 | go==py bytes |")
lines.append("|---|---|---|---|---|")
lines.append("| fixture | go | python | v9 | python_cli | go==py bytes |")
lines.append("|---|---|---|---|---|---|")
for r in rows:
hm = "-" if r["hash_match_go_py"] is None else ("yes" if r["hash_match_go_py"] else "**NO**")
lines.append(f"| {r['name']} | {r['go']} | {r['python']} | {r['v9']} | {hm} |")
lines.append(f"| {r['name']} | {r['go']} | {r['python']} | {r['v9']} | {r['cli']} | {hm} |")
lines.append("")
if divergences:
lines.append(f"## 差异 ({len(divergences)})")
Expand All @@ -190,12 +218,18 @@ def render_md(fixtures: list, rows: list, divergences: list, runs: dict) -> str:

def freeze_expected(fixtures: list, runs: dict) -> None:
go = index_results(runs["go"])
payload = {"schema": 1, "baseline": "go", "files": {}}
payload = {"schema": 2, "baseline": "go", "files": {}}
for fx in fixtures:
r = go.get(fx["name"])
if r:
payload["files"][fx["name"]] = {"class": r["status"], "sha256": r["sha256"],
"where": r["where"]}
cli = index_results(runs.get("python_cli", {}))
if not runs.get("python_cli", {}).get("skipped"):
payload["python_cli"] = {
name: {"class": r["status"], "sha256": r["sha256"]}
for name, r in cli.items()
}
with open(EXPECTED, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
fh.write("\n")
Expand All @@ -205,7 +239,8 @@ def check_expected(runs: dict) -> list:
if not os.path.exists(EXPECTED):
return []
with open(EXPECTED, "r", encoding="utf-8") as fh:
exp = json.load(fh)["files"]
exp_doc = json.load(fh)
exp = exp_doc["files"]
problems = []
for impl in ("go", "python"):
res = index_results(runs[impl])
Expand All @@ -218,6 +253,18 @@ def check_expected(runs: dict) -> list:
problems.append(f"EXPECTED-CLASS {name}: {impl} {r['status']} != {e['class']}")
elif r["sha256"] != e["sha256"]:
problems.append(f"EXPECTED-HASH {name}: {impl} bytes differ from frozen baseline")
exp_cli = exp_doc.get("python_cli")
if exp_cli and not runs.get("python_cli", {}).get("skipped"):
res = index_results(runs["python_cli"])
for name, e in exp_cli.items():
r = res.get(name)
if not r:
problems.append(f"EXPECTED-MISSING {name}: no python_cli result")
continue
if r["status"] != e["class"]:
problems.append(f"EXPECTED-CLASS {name}: python_cli {r['status']} != {e['class']}")
elif r["sha256"] != e["sha256"]:
problems.append(f"EXPECTED-HASH {name}: python_cli bytes differ from frozen golden")
return problems


Expand All @@ -241,7 +288,7 @@ def main() -> int:
fixtures = load_manifest()

runs = {impl: run_impl(impl, os.path.join(WORK, f"{impl}.json"))
for impl in ("python", "go", "v9")}
for impl in ("python", "go", "v9", "python_cli")}

if runs["go"].get("skipped"):
print("[!] go baseline skipped — divergence analysis limited")
Expand All @@ -264,7 +311,8 @@ def main() -> int:
if problems:
return 1
if args.strict:
real = [d for d in divergences if d.startswith(("CLASS-DIVERGENCE", "HASH-DIVERGENCE"))]
real = [d for d in divergences if d.startswith(
("CLASS-DIVERGENCE", "HASH-DIVERGENCE", "CLI-INVARIANT"))]
if real:
print(f"[strict] {len(real)} python-vs-go divergences")
return 1
Expand Down
32 changes: 31 additions & 1 deletion tests/contract/expected.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema": 1,
"schema": 2,
"baseline": "go",
"files": {
"dex_valid.dex": {
Expand Down Expand Up @@ -87,5 +87,35 @@
"sha256": "9da6d84f7bde42c7111f07e6363443704efef97001887365d63a41141c301be8",
"where": "bad/"
}
},
"python_cli": {
"so_valid.so": {
"class": "FIXED",
"sha256": "6d3d2ec9f5bd4a8ae9dc2f31f1dc16afa42b7a51f55f1c08abc779dea979b105"
},
"so_magic_erased.so": {
"class": "FIXED",
"sha256": "6d3d2ec9f5bd4a8ae9dc2f31f1dc16afa42b7a51f55f1c08abc779dea979b105"
},
"so_offset_skew.so": {
"class": "FIXED",
"sha256": "6d3d2ec9f5bd4a8ae9dc2f31f1dc16afa42b7a51f55f1c08abc779dea979b105"
},
"so_et_exec.so": {
"class": "REJECTED",
"sha256": "ba4215d532aae8837b355c8c60606c07ad80d8f3d155f8594c5259d879355b53"
},
"so_tiny.so": {
"class": "REJECTED",
"sha256": "1f9a8a9339effe4f2b0027e148d4651c6d3bd81e5501961a26784b51e6f5a9e6"
},
"so_not_elf.so": {
"class": "REJECTED",
"sha256": "c9d8e3352f9f790d8b0be13cb1c18ed7963009888be04acc065ee5efbd934076"
},
"so_phnum_bogus.so": {
"class": "REJECTED",
"sha256": "9da6d84f7bde42c7111f07e6363443704efef97001887365d63a41141c301be8"
}
}
}
67 changes: 67 additions & 0 deletions tests/contract/run_python_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Run the Python CLI full-repair path (fix_so.repair_elf, SoFixer-style)
over a fixture work dir and emit classification + invariant JSON.

Mirrors what `python3 fix_so.py <dir>` does per file (classify -> repair_elf),
writing results in place. Unlike the aligned pipeline this path INTENTIONALLY
rebuilds SHT etc., so it gets its own frozen golden set (expected.json
"python_cli" section) plus hard invariants:

- output of a non-rejected file must pass validate_elf_deep
- a PT_DYNAMIC present in the input must survive the repair

Env: NHDD_CONTRACT_IN, NHDD_CONTRACT_MANIFEST, NHDD_CONTRACT_OUT.
"""
from __future__ import annotations

import hashlib
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
sys.path.insert(0, REPO)

from classify import load_manifest, write_result # noqa: E402

import fix_so # noqa: E402


def main() -> int:
work = os.environ["NHDD_CONTRACT_IN"]
out = os.environ["NHDD_CONTRACT_OUT"]
fixtures = load_manifest(os.environ["NHDD_CONTRACT_MANIFEST"])

results = []
for fx in fixtures:
if fx["kind"] != "so":
continue
path = os.path.join(work, "so", fx["name"])
old = fix_so.read_file(path)
entry = {"name": fx["name"]}
cat, reason = fix_so.classify_bytes(old)
if cat == "bad":
entry.update({"status": "REJECTED", "sha256": fx["orig_sha256"],
"reason": reason})
results.append(entry)
continue
new, actions = fix_so.repair_elf(old)
with open(path, "wb") as fh:
fh.write(new)
dyn_in = fix_so.ElfInfo(old).dynamic() is not None
dyn_out = fix_so.ElfInfo(new).dynamic() is not None
entry.update({
"status": "OK" if new == old else "FIXED",
"sha256": hashlib.sha256(new).hexdigest(),
"deep_valid": fix_so.validate_elf_deep(new),
"dynamic_kept": (not dyn_in) or dyn_out,
})
results.append(entry)

write_result(out, "python_cli", results)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading