Restructure to the skills/<name>/ layout the skills CLI resolves
The skill lived at the repository root, which the CLI reads as a single-skill repository and which makes a second skill impossible to add. SKILL.md, references/ and scripts/ move under skills/apk-reverse/ with git mv, so history follows and no in-skill reference needs changing (they are relative to SKILL.md). check_repo.py now discovers every skills/*/SKILL.md, validates frontmatter by parsing it instead of pattern-matching it, and requires the README to name skills through explicit skills/<name>/... paths. check_refs.py is new: it audits file.md section cross-references against the real headings in the target file. It found six dangling ones, all fixed here. SKILL.md: the description is now quoted. Unquoted, it contained a colon-space, which is invalid YAML, so the CLI skipped the skill and reported No skills found. Adds a Coverage section stating what is out of scope and what to do when nothing matches. toolchain.md: IDA is not GUI-only (idalib-mcp is a headless MCP server), plus a section on MCP servers as external dependencies and the deobfuscate-before-LLM boundary. README.md: install via npx skills add, the new layout, coverage and maintenance tools. Verified: check_repo.py and check_refs.py both clean, build_scripts.py audit 0 findings, npx skills add . --list reports apk-reverse, and an install into a scratch directory lands SKILL.md plus 28 references and 38 scripts.
This commit is contained in:
@@ -34,8 +34,9 @@ clean log is not one of them. Anything short of all six is a checkpoint, and sho
|
|||||||
checkpoint with what remains.
|
checkpoint with what remains.
|
||||||
|
|
||||||
If you are an agent reading this: the cheapest possible first command is
|
If you are an agent reading this: the cheapest possible first command is
|
||||||
`python scripts/doctor.py`. It tells you which of these tools exist here, which scripts can actually
|
`python skills/apk-reverse/scripts/doctor.py`. It tells you which of these tools exist here, which
|
||||||
run, and whether something in the environment is already poisoning your measurements.
|
scripts can actually run, and whether something in the environment is already poisoning your
|
||||||
|
measurements.
|
||||||
|
|
||||||
## What it is good at
|
## What it is good at
|
||||||
|
|
||||||
@@ -76,6 +77,10 @@ run, and whether something in the environment is already poisoning your measurem
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
`SKILL.md`, `references/` and `scripts/` are all inside the skill directory, `skills/apk-reverse/`.
|
||||||
|
Everything at the repository root is maintenance tooling shared across skills, not part of an
|
||||||
|
installed skill.
|
||||||
|
|
||||||
```
|
```
|
||||||
SKILL.md a procedure with gates, not background reading:
|
SKILL.md a procedure with gates, not background reading:
|
||||||
how-to-use -> four override rules (R1-R4)
|
how-to-use -> four override rules (R1-R4)
|
||||||
@@ -229,20 +234,29 @@ scripts/ parameterized, path-agnostic
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Drop this directory where your agent finds skills, e.g.:
|
This repository is a **skills repository**: the skill lives at `skills/apk-reverse/`, which is the
|
||||||
|
layout the `skills` CLI resolves, and it is installed by name rather than by copying a directory:
|
||||||
|
|
||||||
```
|
```
|
||||||
<skills-dir>/apk-reverse/SKILL.md
|
npx skills add newliver666/apk-reverse # install every skill in the repo
|
||||||
|
npx skills add newliver666/apk-reverse --list # list what is here, install nothing
|
||||||
|
npx skills add newliver666/apk-reverse --skill apk-reverse -y
|
||||||
|
npx skills use newliver666/apk-reverse@apk-reverse # use it once, without installing
|
||||||
```
|
```
|
||||||
|
|
||||||
The agent loads `SKILL.md` when a task matches its description, and pulls in
|
The CLI symlinks the skill into your agent's skills directory by default (`--copy` makes independent
|
||||||
`references/*` only as needed. No global state, no machine-specific paths.
|
copies instead), and `-g` installs for every project rather than the current one. With one skill in
|
||||||
|
the repository, `--skill apk-reverse` is redundant today; it is written out here because it is what
|
||||||
|
selects a single skill once a second one exists.
|
||||||
|
|
||||||
|
Once installed, the agent loads `SKILL.md` when a task matches its description, and pulls in
|
||||||
|
`references/*` only as needed. No global state, no machine-specific paths, and no build step.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Nothing is mandatory; each script checks what it needs. `scripts/doctor.py` reports which of these
|
Nothing is mandatory; each script checks what it needs. `skills/apk-reverse/scripts/doctor.py` reports
|
||||||
are present here, which scripts can therefore run, and — usefully — which tools exist somewhere other
|
which of these are present here, which scripts can therefore run, and — usefully — which tools exist
|
||||||
than PATH.
|
somewhere other than PATH.
|
||||||
|
|
||||||
If your toolchain lives outside PATH (a project-local `tools/` directory, a versioned SDK folder, a
|
If your toolchain lives outside PATH (a project-local `tools/` directory, a versioned SDK folder, a
|
||||||
runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directories and `doctor.py`
|
runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directories and `doctor.py`
|
||||||
@@ -259,7 +273,7 @@ Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix
|
|||||||
| Tool | Used for |
|
| Tool | Used for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Python 3.9+ | all scripts |
|
| Python 3.9+ | all scripts |
|
||||||
| `ddc` (optional but recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. Turns string cross-referencing from a crawl into a lookup, and reports package identity reliably — see `references/toolchain.md` |
|
| `ddc` (optional but recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. Turns string cross-referencing from a crawl into a lookup, and reports package identity reliably — see `skills/apk-reverse/references/toolchain.md` |
|
||||||
| `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching |
|
| `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching |
|
||||||
| JDK (`javac`, `java`) | building/running the dexlib2 patcher; also provides `keytool`/`jarsigner` |
|
| JDK (`javac`, `java`) | building/running the dexlib2 patcher; also provides `keytool`/`jarsigner` |
|
||||||
| Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing. **`apksigner` is the signer to use** — `jarsigner` rewrites the archive and breaks the alignment Android R+ requires |
|
| Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing. **`apksigner` is the signer to use** — `jarsigner` rewrites the archive and breaks the alignment Android R+ requires |
|
||||||
@@ -269,13 +283,14 @@ Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix
|
|||||||
| a rooted device or emulator | anything beyond static analysis |
|
| a rooted device or emulator | anything beyond static analysis |
|
||||||
|
|
||||||
None of these need to be on `PATH`: every script accepts an explicit path for the
|
None of these need to be on `PATH`: every script accepts an explicit path for the
|
||||||
tools it shells out to, and `references/toolchain.md` covers finding an install that
|
tools it shells out to, and `skills/apk-reverse/references/toolchain.md` covers finding
|
||||||
`PATH` does not know about (the common case for `apksigner` and `keytool`).
|
an install that `PATH` does not know about (the common case for `apksigner` and
|
||||||
|
`keytool`).
|
||||||
|
|
||||||
## Read this first
|
## Read this first
|
||||||
|
|
||||||
`references/pitfalls.md`. It is the most valuable file here — every entry is a failure
|
`skills/apk-reverse/references/pitfalls.md`. It is the most valuable file here — every entry is a
|
||||||
that produced a broken artifact while looking completely healthy.
|
failure that produced a broken artifact while looking completely healthy.
|
||||||
|
|
||||||
The four that hurt most:
|
The four that hurt most:
|
||||||
|
|
||||||
@@ -294,3 +309,22 @@ The four that hurt most:
|
|||||||
Built for working on your own applications, on samples you are authorized to analyze,
|
Built for working on your own applications, on samples you are authorized to analyze,
|
||||||
and in CTF/competition sandboxes. It contains no vendored third-party binaries and no
|
and in CTF/competition sandboxes. It contains no vendored third-party binaries and no
|
||||||
target-specific data.
|
target-specific data.
|
||||||
|
|
||||||
|
What it covers, and what it deliberately does not, is stated at the top of `SKILL.md`
|
||||||
|
under **Coverage**. The short version: Android only (no iOS), and deep on the layers
|
||||||
|
that have been worked through for real — dex patching, repacking, packers and custom
|
||||||
|
loaders, native tamper response, and Flutter/Dart AOT. Unity/IL2CPP logic recovery,
|
||||||
|
React Native/Hermes bytecode internals, and defeating a server-side authority are **not**
|
||||||
|
covered, and the skill is written to say so and stop rather than apply the nearest
|
||||||
|
documented procedure to a target it was not written for.
|
||||||
|
|
||||||
|
## Repository maintenance
|
||||||
|
|
||||||
|
Three tools live at the root and are not part of the installed skill:
|
||||||
|
|
||||||
|
```
|
||||||
|
check_repo.py every skill discovered, frontmatter valid, scripts runnable,
|
||||||
|
documented paths resolve, README paths explicit and existing
|
||||||
|
check_refs.py every `file.md` §section cross-reference reaches a real heading
|
||||||
|
build_scripts.py audit for machine-specific leftovers (absolute paths, credentials)
|
||||||
|
```
|
||||||
|
|||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Audit cross-references of the form `file.md` §section, and `file.md` P12.
|
||||||
|
|
||||||
|
A reference like `references/toolchain.md` §"not on PATH" is a promise that the
|
||||||
|
target file has a section by that name. When a section is renamed or removed the
|
||||||
|
promise silently breaks: the reader arrives at a file that does not contain the
|
||||||
|
thing they were sent for, and the most likely outcome is that they conclude the
|
||||||
|
material is missing rather than that the link is stale. Prose links do not fail
|
||||||
|
loudly the way a missing file does, which is why this script exists separately
|
||||||
|
from `check_repo.py` (that one only proves the *file* resolves).
|
||||||
|
|
||||||
|
A section reference is reported as:
|
||||||
|
* OK -- normalised, it matches a heading (or bold numbered rule) in the target
|
||||||
|
* WARN -- its words appear in the target body, but no heading carries them;
|
||||||
|
usually a stale or paraphrased link, worth a human look
|
||||||
|
* FAIL -- nothing in the target file corresponds; the link is dangling
|
||||||
|
|
||||||
|
Layout is discovered the same way as in `check_repo.py`, so a second skill under
|
||||||
|
skills/ is audited with no change here.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SKILLS_DIR = os.path.join(ROOT, 'skills')
|
||||||
|
|
||||||
|
# A section reference. The optional leading file may be separated from the
|
||||||
|
# section mark by the closing backtick and a line break, since references are
|
||||||
|
# often wrapped mid-sentence.
|
||||||
|
REF_RE = re.compile(
|
||||||
|
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
|
||||||
|
r'§\s*(?P<sec>[^\n|`();,.\u2014\u3002]{2,90})')
|
||||||
|
# A failure-catalogue reference, e.g. `pitfalls.md` P31, or a bare (P18).
|
||||||
|
PITFALL_RE = re.compile(
|
||||||
|
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
|
||||||
|
r'\(?\bP(?P<num>\d{1,2})\b')
|
||||||
|
|
||||||
|
HEADING_RE = re.compile(r'^#{1,6}\s+(?P<title>.+?)\s*$')
|
||||||
|
BOLD_RE = re.compile(r'^\*\*(?P<title>.+?)\*\*\s*$')
|
||||||
|
|
||||||
|
|
||||||
|
def norm(text):
|
||||||
|
"""Fold a heading or a reference into comparable tokens.
|
||||||
|
|
||||||
|
Hyphens and slashes become spaces so that `finding-the-call-site` matches
|
||||||
|
"Finding the call site", and quotes/emphasis are dropped so that
|
||||||
|
`§"not on PATH"` matches a heading containing the same words quoted.
|
||||||
|
"""
|
||||||
|
text = text.lower()
|
||||||
|
text = text.replace('\u2014', ' ').replace('\u2013', ' ')
|
||||||
|
text = re.sub(r'[`*_"\'()\[\]]', '', text)
|
||||||
|
text = re.sub(r'[^a-z0-9]+', ' ', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def discover_skills():
|
||||||
|
"""Return [(name, skill_dir)] for each skill root, shallow shadowing deep."""
|
||||||
|
found = []
|
||||||
|
if not os.path.isdir(SKILLS_DIR):
|
||||||
|
return found
|
||||||
|
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
|
||||||
|
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
|
||||||
|
rel = os.path.relpath(dirpath, SKILLS_DIR)
|
||||||
|
depth = 0 if rel == '.' else rel.count(os.sep) + 1
|
||||||
|
if depth > 3:
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
if 'SKILL.md' in filenames:
|
||||||
|
found.append((rel.replace(os.sep, '/'), dirpath))
|
||||||
|
dirnames[:] = []
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_docs():
|
||||||
|
"""Return [(path, skill_name_or_None, text)] for every markdown document."""
|
||||||
|
docs = []
|
||||||
|
readme = os.path.join(ROOT, 'README.md')
|
||||||
|
if os.path.exists(readme):
|
||||||
|
docs.append((readme, None, open(readme, encoding='utf-8').read()))
|
||||||
|
for skill, skill_dir in discover_skills():
|
||||||
|
for dirpath, dirnames, filenames in os.walk(skill_dir):
|
||||||
|
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
|
||||||
|
for fname in sorted(filenames):
|
||||||
|
if not fname.endswith('.md'):
|
||||||
|
continue
|
||||||
|
path = os.path.join(dirpath, fname)
|
||||||
|
docs.append((path, skill, open(path, encoding='utf-8').read()))
|
||||||
|
return docs
|
||||||
|
|
||||||
|
|
||||||
|
def anchors(text):
|
||||||
|
"""Headings, and bold stand-alone lines (`**2a. ...**`), as match targets."""
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
m = HEADING_RE.match(line) or BOLD_RE.match(line)
|
||||||
|
if m:
|
||||||
|
out.append(m.group('title'))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def matches(sec, anchor_list):
|
||||||
|
want = norm(sec)
|
||||||
|
if not want:
|
||||||
|
return True
|
||||||
|
want_tokens = want.split()
|
||||||
|
for raw in anchor_list:
|
||||||
|
have_tokens = norm(raw).split()
|
||||||
|
if not have_tokens:
|
||||||
|
continue
|
||||||
|
# A numbered heading (`## 3. Reading AOT code`) carries the number as a
|
||||||
|
# token, but a reference may name the section with or without it, so
|
||||||
|
# both readings are candidates.
|
||||||
|
candidates = [have_tokens]
|
||||||
|
if have_tokens[0].isdigit() and len(have_tokens) > 1:
|
||||||
|
candidates.append(have_tokens[1:])
|
||||||
|
for have in candidates:
|
||||||
|
text = ' '.join(have)
|
||||||
|
if text == want or text.startswith(want + ' ') \
|
||||||
|
or want.startswith(text + ' '):
|
||||||
|
return True
|
||||||
|
if all(tok in have for tok in want_tokens):
|
||||||
|
return True
|
||||||
|
# A reference frequently runs on into the sentence that follows it
|
||||||
|
# (`§Gates are cleared here or not at all`), leaving prose attached
|
||||||
|
# to the heading name. Sharing the leading word is the strongest
|
||||||
|
# signal available in that shape.
|
||||||
|
if have[0] == want_tokens[0]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def first_token_match(sec, anchor_list):
|
||||||
|
"""True when some heading starts with the same word as the reference.
|
||||||
|
|
||||||
|
A reference is often a heading plus the rest of the sentence
|
||||||
|
(`§Gates are cleared here or not at all`), so an exact match is too strict
|
||||||
|
to be the only accepted shape. This is deliberately weaker than matches()
|
||||||
|
and is reported as a warning, not as a pass.
|
||||||
|
"""
|
||||||
|
want = norm(sec).split()
|
||||||
|
if not want:
|
||||||
|
return False
|
||||||
|
for raw in anchor_list:
|
||||||
|
have = norm(raw).split()
|
||||||
|
if have and have[0] == want[0]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def in_body(sec, text):
|
||||||
|
"""Weaker fallback: every token of the section name occurs in the body."""
|
||||||
|
want_tokens = norm(sec).split()
|
||||||
|
if not want_tokens:
|
||||||
|
return True
|
||||||
|
body = norm(text)
|
||||||
|
return all(tok in body for tok in want_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
docs = discover_docs()
|
||||||
|
by_name = {}
|
||||||
|
for path, skill, text in docs:
|
||||||
|
by_name.setdefault(os.path.basename(path), []).append((path, skill, text))
|
||||||
|
|
||||||
|
ok = warn = 0
|
||||||
|
problems = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
for path, skill, text in docs:
|
||||||
|
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
|
||||||
|
for m in REF_RE.finditer(text):
|
||||||
|
raw_sec = m.group('sec').strip().strip('.,;:!?\'")*').lower()
|
||||||
|
# `§7 gives the exact framing` -- keep only the number.
|
||||||
|
num = re.match(r'(\d{1,2}[a-z]?)\b', raw_sec)
|
||||||
|
sec = num.group(1) if num else raw_sec
|
||||||
|
if not sec:
|
||||||
|
continue
|
||||||
|
# `§8C` names sub-item C of section 8, so the bare section number is
|
||||||
|
# an acceptable fallback target.
|
||||||
|
variants = [sec]
|
||||||
|
sub = re.fullmatch(r'(\d{1,2})[a-z]', sec)
|
||||||
|
if sub:
|
||||||
|
variants.append(sub.group(1))
|
||||||
|
|
||||||
|
target_name = m.group('file')
|
||||||
|
if target_name is None:
|
||||||
|
target_text, target_list = text, [path]
|
||||||
|
target_label = where
|
||||||
|
else:
|
||||||
|
candidates = [c for c in by_name.get(target_name, [])
|
||||||
|
if skill is None or c[1] == skill] \
|
||||||
|
or by_name.get(target_name, [])
|
||||||
|
if not candidates:
|
||||||
|
problems.append('%s: §%s -> %s (no such file)'
|
||||||
|
% (where, sec, target_name))
|
||||||
|
continue
|
||||||
|
target_text = candidates[0][2]
|
||||||
|
target_label = os.path.relpath(candidates[0][0], ROOT).replace(os.sep, '/')
|
||||||
|
|
||||||
|
if any(matches(v, anchors(target_text)) for v in variants):
|
||||||
|
ok += 1
|
||||||
|
elif len(norm(sec).split()) >= 2 and first_token_match(
|
||||||
|
sec, anchors(target_text)):
|
||||||
|
warn += 1
|
||||||
|
warnings.append('%s: §%s -> %s (heading starts with this word '
|
||||||
|
'but the wording differs)'
|
||||||
|
% (where, sec, target_label))
|
||||||
|
elif in_body(sec, target_text):
|
||||||
|
warn += 1
|
||||||
|
warnings.append('%s: §%s -> %s (words present, no matching heading)'
|
||||||
|
% (where, sec, target_label))
|
||||||
|
else:
|
||||||
|
problems.append('%s: §%s -> %s (section not found)'
|
||||||
|
% (where, sec, target_label))
|
||||||
|
|
||||||
|
# Failure-catalogue references: `pitfalls.md` P31, or a bare (P18).
|
||||||
|
pit_anchors = set()
|
||||||
|
for name, entries in by_name.items():
|
||||||
|
for path, _skill, text in entries:
|
||||||
|
for raw in anchors(text):
|
||||||
|
m = re.match(r'P(\d{1,2})[.\s]', raw)
|
||||||
|
if m:
|
||||||
|
pit_anchors.add(m.group(1))
|
||||||
|
for path, _skill, text in docs:
|
||||||
|
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
|
||||||
|
for m in PITFALL_RE.finditer(text):
|
||||||
|
if m.group('num') not in pit_anchors:
|
||||||
|
problems.append('%s: P%s (no such pitfall)' % (where, m.group('num')))
|
||||||
|
else:
|
||||||
|
ok += 1
|
||||||
|
|
||||||
|
print('== markdown documents ==')
|
||||||
|
for path, _skill, _text in docs:
|
||||||
|
print(' %s' % os.path.relpath(path, ROOT).replace(os.sep, '/'))
|
||||||
|
|
||||||
|
print('\n== section references: %d reachable ==' % ok)
|
||||||
|
for w in sorted(set(warnings)):
|
||||||
|
print(' WARN %s' % w)
|
||||||
|
for p in sorted(set(problems)):
|
||||||
|
print(' FAIL %s' % p)
|
||||||
|
|
||||||
|
print('\n== result: %d dangling, %d warning(s) =='
|
||||||
|
% (len(set(problems)), len(set(warnings))))
|
||||||
|
return 1 if problems else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
+256
-65
@@ -1,80 +1,271 @@
|
|||||||
"""Pre-commit checks: every script parses and answers --help; every referenced file exists."""
|
#!/usr/bin/env python3
|
||||||
|
"""Pre-commit checks for this repository.
|
||||||
|
|
||||||
|
Assumed layout -- the one the `skills` CLI resolves:
|
||||||
|
|
||||||
|
README.md repository-level docs (root)
|
||||||
|
skills/<skill-name>/SKILL.md the skill itself
|
||||||
|
skills/<skill-name>/references/*.md loaded on demand
|
||||||
|
skills/<skill-name>/scripts/* run, not read
|
||||||
|
|
||||||
|
Every skill under `skills/` is discovered automatically (up to three levels;
|
||||||
|
a SKILL.md at a shallower level shadows anything nested below it, which is the
|
||||||
|
CLI's own rule), so adding a second skill requires no change to this script.
|
||||||
|
|
||||||
|
Per skill:
|
||||||
|
1. frontmatter exists, `name` matches the directory name, `description` <= 1024
|
||||||
|
2. every scripts/*.py parses and answers --help without a traceback
|
||||||
|
3. every `references/X.md` / `scripts/X.py` reference resolves inside that skill
|
||||||
|
4. every reference file is reachable from that skill's SKILL.md or the README
|
||||||
|
5. every script is mentioned somewhere in the docs
|
||||||
|
|
||||||
|
On the root README:
|
||||||
|
6. every path it names is an explicit `skills/<name>/...` path that exists
|
||||||
|
"""
|
||||||
import ast
|
import ast
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # the skills CLI is the real authority; this is a local gate
|
||||||
|
yaml = None
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
os.chdir(ROOT)
|
SKILLS_DIR = os.path.join(ROOT, 'skills')
|
||||||
|
README = os.path.join(ROOT, 'README.md')
|
||||||
|
|
||||||
|
REF_RE = re.compile(r'`?(references/[A-Za-z0-9_\-]+\.md)')
|
||||||
|
SCRIPT_RE = re.compile(r'`?(scripts/[A-Za-z0-9_\-]+\.(?:py|js))')
|
||||||
|
NAMED_PATH_RE = re.compile(
|
||||||
|
r'skills/(?P<skill>[a-z0-9\-]+)/(?P<sub>references|scripts)/'
|
||||||
|
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
|
||||||
|
BARE_PATH_RE = re.compile(r'(?<![\w/])(?P<sub>references|scripts)/'
|
||||||
|
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
|
||||||
|
|
||||||
fail = []
|
fail = []
|
||||||
|
|
||||||
print("== python syntax + --help ==")
|
|
||||||
for name in sorted(os.listdir('scripts')):
|
def read(path):
|
||||||
if not name.endswith('.py'):
|
with open(path, encoding='utf-8') as fh:
|
||||||
continue
|
return fh.read()
|
||||||
path = os.path.join('scripts', name)
|
|
||||||
|
|
||||||
|
def discover_skills():
|
||||||
|
"""Return [(name, skill_dir)] for every skills/<...>/SKILL.md."""
|
||||||
|
found = []
|
||||||
|
if not os.path.isdir(SKILLS_DIR):
|
||||||
|
return found
|
||||||
|
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
|
||||||
|
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
|
||||||
|
rel = os.path.relpath(dirpath, SKILLS_DIR)
|
||||||
|
depth = 0 if rel == '.' else rel.count(os.sep) + 1
|
||||||
|
if depth > 3:
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
if 'SKILL.md' in filenames:
|
||||||
|
found.append((rel.replace(os.sep, '/'), dirpath))
|
||||||
|
dirnames[:] = [] # shallower SKILL.md shadows what is below
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
NAME_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
|
||||||
|
|
||||||
|
|
||||||
|
def check_frontmatter(name, skill_dir):
|
||||||
|
"""Validate the frontmatter the way the CLI will, not merely its presence.
|
||||||
|
|
||||||
|
This has to be a real parse. Frontmatter that satisfies a regex can still be
|
||||||
|
rejected by a YAML parser -- an unquoted `description` containing ": " does
|
||||||
|
it, because the colon starts a nested mapping -- and the `skills` CLI then
|
||||||
|
reports "No skills found" and installs nothing at all.
|
||||||
|
"""
|
||||||
|
path = os.path.join(skill_dir, 'SKILL.md')
|
||||||
|
text = read(path)
|
||||||
|
m = re.match(r'^---\r?\n(.*?)\r?\n---\r?\n', text, re.S)
|
||||||
|
if not m:
|
||||||
|
fail.append('%s: no YAML frontmatter block' % path)
|
||||||
|
return
|
||||||
|
raw = m.group(1)
|
||||||
|
|
||||||
|
if yaml is None:
|
||||||
|
print(' WARN PyYAML is not installed, so the frontmatter was not parsed')
|
||||||
|
line = re.search(r'^description:(.*)$', raw, re.M)
|
||||||
|
if line:
|
||||||
|
value = line.group(1)
|
||||||
|
if value[:1] not in ('"', "'", '>', '|') and ': ' in value:
|
||||||
|
fail.append('%s: unquoted description contains ": ", which a YAML '
|
||||||
|
'parser rejects; quote the value' % path)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ast.parse(open(path, encoding='utf-8').read())
|
data = yaml.safe_load(raw)
|
||||||
except SyntaxError as e:
|
except yaml.YAMLError as exc:
|
||||||
fail.append('%s: syntax error %s' % (path, e))
|
detail = str(exc).splitlines()[0]
|
||||||
print(' FAIL %s' % path)
|
fail.append('%s: frontmatter is not valid YAML (%s); the skills CLI '
|
||||||
continue
|
'skips a skill whose frontmatter will not parse'
|
||||||
r = subprocess.run([sys.executable, '-B', path, '--help'],
|
% (path, detail))
|
||||||
capture_output=True, text=True, timeout=60)
|
return
|
||||||
# A script may exit non-zero on --help if it uses the "print usage and exit 2"
|
if not isinstance(data, dict):
|
||||||
# convention. That is acceptable; a traceback is not.
|
fail.append('%s: frontmatter is not a YAML mapping' % path)
|
||||||
crashed = 'Traceback (most recent call last)' in (r.stderr or '')
|
return
|
||||||
helped = bool((r.stdout or '').strip()) or bool(r.stderr)
|
|
||||||
ok = (not crashed) and helped
|
|
||||||
verdict = 'ok' if r.returncode == 0 and not crashed else \
|
|
||||||
('usage-ok rc=%d' % r.returncode if ok else 'CRASH rc=%d' % r.returncode)
|
|
||||||
print(' %-32s %s' % ('%s --help' % name, verdict))
|
|
||||||
if not ok:
|
|
||||||
fail.append('%s --help crashed: %s' % (path, (r.stderr or '')[:300]))
|
|
||||||
|
|
||||||
print("\n== markdown references resolve ==")
|
got = data.get('name')
|
||||||
refs = set(os.listdir('references'))
|
if not got:
|
||||||
scripts = set(os.listdir('scripts'))
|
fail.append('%s: frontmatter has no `name`' % path)
|
||||||
missing = []
|
elif str(got) != name:
|
||||||
for name in ['SKILL.md', 'README.md'] + ['references/' + f for f in refs if f.endswith('.md')]:
|
fail.append('%s: frontmatter name %r does not match directory %r'
|
||||||
text = open(name, encoding='utf-8').read()
|
% (path, got, name))
|
||||||
for m in re.finditer(r'`?(references/[A-Za-z0-9_\-]+\.md)', text):
|
elif not NAME_RE.match(str(got)) or len(str(got)) > 64:
|
||||||
target = m.group(1).split('/')[1]
|
fail.append('%s: name %r violates the spec (lowercase alphanumerics and '
|
||||||
if target not in refs:
|
'single hyphens, max 64 chars)' % (path, got))
|
||||||
missing.append('%s -> %s' % (name, m.group(1)))
|
|
||||||
for m in re.finditer(r'`?(scripts/[A-Za-z0-9_\-]+\.(?:py|js))', text):
|
|
||||||
target = m.group(1).split('/')[1]
|
|
||||||
if target not in scripts:
|
|
||||||
missing.append('%s -> %s' % (name, m.group(1)))
|
|
||||||
if missing:
|
|
||||||
for x in sorted(set(missing)):
|
|
||||||
print(' MISSING %s' % x)
|
|
||||||
fail.extend(missing)
|
|
||||||
else:
|
|
||||||
print(' all referenced reference/script paths exist')
|
|
||||||
|
|
||||||
print("\n== every reference file is reachable from SKILL.md or README.md ==")
|
desc = data.get('description')
|
||||||
bodies = open('SKILL.md', encoding='utf-8').read() + open('README.md', encoding='utf-8').read()
|
if not desc:
|
||||||
unlisted = [f for f in sorted(refs) if f.endswith('.md') and f not in bodies]
|
fail.append('%s: frontmatter has no `description`' % path)
|
||||||
for f in unlisted:
|
else:
|
||||||
print(' UNLISTED references/%s' % f)
|
desc = str(desc)
|
||||||
if unlisted:
|
if len(desc) > 1024:
|
||||||
fail.append('unlisted references: %s' % unlisted)
|
fail.append('%s: description is %d chars (spec max 1024)'
|
||||||
|
% (path, len(desc)))
|
||||||
|
elif len(desc) < 40:
|
||||||
|
fail.append('%s: description is only %d chars, too short to tell an '
|
||||||
|
'agent when this skill applies' % (path, len(desc)))
|
||||||
|
|
||||||
print("\n== every script is mentioned somewhere in the docs ==")
|
|
||||||
all_docs = bodies
|
|
||||||
for f in refs:
|
|
||||||
if f.endswith('.md'):
|
|
||||||
all_docs += open('references/' + f, encoding='utf-8').read()
|
|
||||||
undoc = [s for s in sorted(scripts) if s.endswith(('.py', '.js')) and s not in all_docs]
|
|
||||||
for s in undoc:
|
|
||||||
print(' UNDOCUMENTED scripts/%s' % s)
|
|
||||||
if undoc:
|
|
||||||
fail.append('undocumented scripts: %s' % undoc)
|
|
||||||
|
|
||||||
print("\n== result: %d problem(s) ==" % len(fail))
|
def check_scripts(skill_dir):
|
||||||
for f in fail:
|
scripts = os.path.join(skill_dir, 'scripts')
|
||||||
print(' - %s' % f)
|
if not os.path.isdir(scripts):
|
||||||
sys.exit(1 if fail else 0)
|
return
|
||||||
|
for name in sorted(os.listdir(scripts)):
|
||||||
|
if not name.endswith('.py'):
|
||||||
|
continue
|
||||||
|
path = os.path.join(scripts, name)
|
||||||
|
try:
|
||||||
|
ast.parse(read(path))
|
||||||
|
except SyntaxError as exc:
|
||||||
|
fail.append('%s: syntax error %s' % (path, exc))
|
||||||
|
print(' FAIL %s' % name)
|
||||||
|
continue
|
||||||
|
run = subprocess.run([sys.executable, '-B', path, '--help'],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
cwd=ROOT)
|
||||||
|
# A script may exit non-zero on --help under the "print usage, exit 2"
|
||||||
|
# convention. That is acceptable; a traceback is not.
|
||||||
|
crashed = 'Traceback (most recent call last)' in (run.stderr or '')
|
||||||
|
helped = bool((run.stdout or '').strip()) or bool(run.stderr)
|
||||||
|
ok = (not crashed) and helped
|
||||||
|
verdict = 'ok' if run.returncode == 0 and not crashed else \
|
||||||
|
('usage-ok rc=%d' % run.returncode if ok else
|
||||||
|
'CRASH rc=%d' % run.returncode)
|
||||||
|
print(' %-32s %s' % (name, verdict))
|
||||||
|
if not ok:
|
||||||
|
fail.append('%s --help crashed: %s' % (path, (run.stderr or '')[:300]))
|
||||||
|
|
||||||
|
|
||||||
|
def check_references(name, skill_dir, root_docs):
|
||||||
|
"""Resolve in-skill references, and confirm every file is reachable."""
|
||||||
|
ref_dir = os.path.join(skill_dir, 'references')
|
||||||
|
script_dir = os.path.join(skill_dir, 'scripts')
|
||||||
|
refs = set(os.listdir(ref_dir)) if os.path.isdir(ref_dir) else set()
|
||||||
|
scripts = set(os.listdir(script_dir)) if os.path.isdir(script_dir) else set()
|
||||||
|
|
||||||
|
# Documents whose relative paths are rooted at the skill directory.
|
||||||
|
own = [os.path.join(skill_dir, 'SKILL.md')]
|
||||||
|
own += [os.path.join(ref_dir, f) for f in sorted(refs) if f.endswith('.md')]
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for doc in own:
|
||||||
|
if not os.path.exists(doc):
|
||||||
|
continue
|
||||||
|
text = read(doc)
|
||||||
|
for m in REF_RE.finditer(text):
|
||||||
|
if m.group(1).split('/')[1] not in refs:
|
||||||
|
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
|
||||||
|
for m in SCRIPT_RE.finditer(text):
|
||||||
|
if m.group(1).split('/')[1] not in scripts:
|
||||||
|
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
|
||||||
|
if missing:
|
||||||
|
for x in sorted(set(missing)):
|
||||||
|
print(' MISSING %s' % x)
|
||||||
|
fail.extend(missing)
|
||||||
|
|
||||||
|
# Reachability: nothing may be orphaned from the skill entry point.
|
||||||
|
bodies = read(os.path.join(skill_dir, 'SKILL.md')) + root_docs
|
||||||
|
for f in sorted(refs):
|
||||||
|
if f.endswith('.md') and f not in bodies:
|
||||||
|
fail.append('%s: references/%s is not mentioned anywhere' % (name, f))
|
||||||
|
print(' UNLISTED references/%s' % f)
|
||||||
|
|
||||||
|
all_docs = bodies
|
||||||
|
for f in sorted(refs):
|
||||||
|
if f.endswith('.md'):
|
||||||
|
all_docs += read(os.path.join(ref_dir, f))
|
||||||
|
for s in sorted(scripts):
|
||||||
|
if s.endswith(('.py', '.js')) and s not in all_docs:
|
||||||
|
fail.append('%s: scripts/%s is undocumented' % (name, s))
|
||||||
|
print(' UNDOCUMENTED scripts/%s' % s)
|
||||||
|
|
||||||
|
|
||||||
|
def check_readme():
|
||||||
|
"""README paths must be explicit and must exist."""
|
||||||
|
text = read(README)
|
||||||
|
if not os.path.isdir(SKILLS_DIR):
|
||||||
|
fail.append('no skills/ directory at the repository root')
|
||||||
|
return
|
||||||
|
skills = {d for d, _ in discover_skills()}
|
||||||
|
named = set()
|
||||||
|
for m in NAMED_PATH_RE.finditer(text):
|
||||||
|
named.add(m.group(0))
|
||||||
|
skill, sub, fname = m.group('skill'), m.group('sub'), m.group('file')
|
||||||
|
if skill not in skills:
|
||||||
|
fail.append('README.md: names unknown skill %r' % skill)
|
||||||
|
print(' UNKNOWN SKILL skills/%s' % skill)
|
||||||
|
elif not os.path.exists(os.path.join(SKILLS_DIR, skill, sub, fname)):
|
||||||
|
fail.append('README.md: %s does not exist' % m.group(0))
|
||||||
|
print(' MISSING %s' % m.group(0))
|
||||||
|
# Bare relative paths are ambiguous once there is more than one skill, so the
|
||||||
|
# README must not use them at all.
|
||||||
|
stripped = NAMED_PATH_RE.sub('', text)
|
||||||
|
for m in BARE_PATH_RE.finditer(stripped):
|
||||||
|
fail.append('README.md: bare path %r should be skills/<name>/%s'
|
||||||
|
% (m.group(0), m.group(0)))
|
||||||
|
print(' BARE PATH %s' % m.group(0))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
skills = discover_skills()
|
||||||
|
print('== skills discovered ==')
|
||||||
|
if not skills:
|
||||||
|
print(' none (expected at least skills/<name>/SKILL.md)')
|
||||||
|
for name, _ in skills:
|
||||||
|
print(' skills/%s' % name)
|
||||||
|
|
||||||
|
print('\n== frontmatter ==')
|
||||||
|
for name, skill_dir in skills:
|
||||||
|
check_frontmatter(name, skill_dir)
|
||||||
|
print(' %d checked' % len(skills))
|
||||||
|
|
||||||
|
root_docs = read(README)
|
||||||
|
for name, skill_dir in skills:
|
||||||
|
print('\n== %s: python syntax + --help ==' % name)
|
||||||
|
check_scripts(skill_dir)
|
||||||
|
print('\n== %s: reference and script paths resolve ==' % name)
|
||||||
|
before = len(fail)
|
||||||
|
check_references(name, skill_dir, root_docs)
|
||||||
|
if len(fail) == before:
|
||||||
|
print(' all referenced reference/script paths exist')
|
||||||
|
|
||||||
|
print('\n== README paths are explicit and exist ==')
|
||||||
|
check_readme()
|
||||||
|
|
||||||
|
print('\n== result: %d problem(s) ==' % len(fail))
|
||||||
|
for f in fail:
|
||||||
|
print(' - %s' % f)
|
||||||
|
return 1 if fail else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: apk-reverse
|
name: apk-reverse
|
||||||
description: Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behavior. Use when a task involves an .apk/.aab/.dex/.so sample, smali or dex patching, Frida/objection runtime hooking, repacking and re-signing, removing ads or SDK trackers, probing a mobile app's HTTP API, or deciding whether a client-side patch is even capable of achieving the goal. Covers recon, anti-tamper, ad removal, membership/paywall limits, dex-level surgical patching, repack pitfalls, device and emulator setup, and a hard-won failure catalogue. Load the body before planning any patch work: it opens with a symptom index and four gates that must be cleared first.
|
description: "Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behavior. Use when a task involves an .apk/.aab/.dex/.so sample, smali or dex patching, Frida/objection runtime hooking, repacking and re-signing, removing ads or SDK trackers, probing a mobile app's HTTP API, or deciding whether a client-side patch is even capable of achieving the goal. Covers recon, anti-tamper, ad removal, membership/paywall limits, dex-level surgical patching, repack pitfalls, device and emulator setup, and a hard-won failure catalogue. Load the body before planning any patch work: it opens with a symptom index and four gates that must be cleared first."
|
||||||
---
|
---
|
||||||
|
|
||||||
# APK Reverse Engineering & Patching
|
# APK Reverse Engineering & Patching
|
||||||
@@ -49,6 +49,57 @@ native, Dart/Unity, server). Patching the wrong layer either does nothing or bre
|
|||||||
patch "had no effect", the diagnosis was wrong — go back to classification instead of patching harder.
|
patch "had no effect", the diagnosis was wrong — go back to classification instead of patching harder.
|
||||||
→ `references/recon.md`, then the layer-specific file the symptom index points at
|
→ `references/recon.md`, then the layer-specific file the symptom index points at
|
||||||
|
|
||||||
|
## Coverage — what this skill claims, and what it does not
|
||||||
|
|
||||||
|
The failure this section prevents is not ignorance. It is **a confident wrong answer produced by
|
||||||
|
applying the nearest available procedure to a target it was never written for.** A documented method
|
||||||
|
that almost fits is more dangerous than no method at all, because it arrives with a plan, a
|
||||||
|
vocabulary and a set of reassuring numbers.
|
||||||
|
|
||||||
|
**Covered, by verified mechanisms:**
|
||||||
|
|
||||||
|
- Client-side ads, promos and splash/popup/tab configuration — including the server-issued UI config
|
||||||
|
that has no SDK to find (`ad-removal.md`, `server-config-and-updates.md`).
|
||||||
|
- Deciding whether a membership, paywall or feature gate is *client-enforceable* at all, and saying
|
||||||
|
so plainly when it is not (`membership-and-limits.md`, `account-gates.md`).
|
||||||
|
- dex-level surgical patching: equal-length byte edits and dexlib2 method rewrites, plus the header,
|
||||||
|
verifier and alignment rules that decide whether the build loads at all (`dex-patching.md`,
|
||||||
|
`byte-level-patching.md`, `patch-audit.md`).
|
||||||
|
- Repacking, signing, installing, and the install refusals that look like a broken build
|
||||||
|
(`repack-and-sign.md`).
|
||||||
|
- Packers, custom loaders and code virtualization: identifying them, measuring the validation
|
||||||
|
boundary, and the routes that survive it (`packers.md`,
|
||||||
|
`code-virtualization-and-custom-linkers.md`).
|
||||||
|
- The native layer: `.so` hosts, tamper-triggered self-termination, forged ELF structure, and
|
||||||
|
neutralising a terminate path without freezing the process (`native-and-so.md`,
|
||||||
|
`native-tamper-and-suicide.md`).
|
||||||
|
- Flutter / Dart AOT: pinning the engine version, decoding the object pool, locating and patching
|
||||||
|
business logic inside `libapp.so` (`dart-aot.md`).
|
||||||
|
- Runtime analysis with Frida, server-side API probing, feature-scoped TLS failures, update and
|
||||||
|
forced-upgrade neutralisation, and the verification discipline everything above rests on.
|
||||||
|
|
||||||
|
**Not covered — say so rather than improvise:**
|
||||||
|
|
||||||
|
- **Unity / IL2CPP logic recovery.** `framework-runtimes.md` identifies the runtime and establishes
|
||||||
|
that the dex is not the battlefield; it does not carry the IL2CPP equivalent of `dart-aot.md`.
|
||||||
|
There is no verified recipe here for locating a method inside `libil2cpp.so` plus
|
||||||
|
`global-metadata.dat`.
|
||||||
|
- **React Native / Hermes bytecode** and Cordova/hybrid internals, beyond runtime identification and
|
||||||
|
the generic "find the string, then find what references it" approach.
|
||||||
|
- **iOS / `.ipa` of any kind.** Every device, signing and packaging instruction here is Android.
|
||||||
|
- **Defeating a server-side authority.** `server-api.md` exists to determine *who owns a gate*, not
|
||||||
|
to break an authorization the server performs.
|
||||||
|
- **A general unpacker, or an anti-detection arms race.** `detection-and-anti-analysis.md` decides by
|
||||||
|
cost and often concludes "switch to static"; it is not a catalogue of evasion for every detector
|
||||||
|
you might meet.
|
||||||
|
|
||||||
|
**The fallback, as an instruction:** if the target does not match that list, or no symptom-index row
|
||||||
|
matches, **stop and classify before choosing a branch.** Answer the thirteen questions first. If the
|
||||||
|
shape still does not fit — an unknown runtime, a mechanism you cannot name — say exactly that, and
|
||||||
|
propose the cheapest experiment that would identify it, rather than taking the closest documented
|
||||||
|
route and applying it anyway. A wrong branch here does not fail loudly: it produces an artifact that
|
||||||
|
builds, runs, and does the wrong thing.
|
||||||
|
|
||||||
## Symptom index — a matching row is a stop signal
|
## Symptom index — a matching row is a stop signal
|
||||||
|
|
||||||
You arrive at a symptom, not at a file name. Each row below is a failure that has already been paid
|
You arrive at a symptom, not at a file name. Each row below is a failure that has already been paid
|
||||||
@@ -128,7 +179,7 @@ Answer these before touching a tool. Every one of them changes the whole plan.
|
|||||||
- Feature flag, UI gate, debug switch → usually client-side
|
- Feature flag, UI gate, debug switch → usually client-side
|
||||||
- Anything decided by an API response → server-side → `references/server-api.md`
|
- Anything decided by an API response → server-side → `references/server-api.md`
|
||||||
3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?**
|
3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?**
|
||||||
Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §code-location and `references/framework-runtimes.md`.
|
Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §Where does the app's own code live and `references/framework-runtimes.md`.
|
||||||
**Runtime check (cheap -- do it before committing to a layer):** hook the obvious Java classes for the UI you care about, then reproduce that UI. If those hooks fire, the behavior is Java-owned. If they fire **zero times** while the UI is plainly on screen, the behavior is drawn by the runtime or by native code, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe -- that is the most expensive wrong turn in this skill's history.
|
**Runtime check (cheap -- do it before committing to a layer):** hook the obvious Java classes for the UI you care about, then reproduce that UI. If those hooks fire, the behavior is Java-owned. If they fire **zero times** while the UI is plainly on screen, the behavior is drawn by the runtime or by native code, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe -- that is the most expensive wrong turn in this skill's history.
|
||||||
4. **What must the deliverable be able to do?** Write the answer as a testable sentence before
|
4. **What must the deliverable be able to do?** Write the answer as a testable sentence before
|
||||||
planning anything, then re-read it at every checkpoint. This is the drift guard, and the drift it
|
planning anything, then re-read it at every checkpoint. This is the drift guard, and the drift it
|
||||||
+1
-1
@@ -36,7 +36,7 @@ in this skill's history.
|
|||||||
3. **A shipping library whose SONAME does not match its filename.** A private loader is often
|
3. **A shipping library whose SONAME does not match its filename.** A private loader is often
|
||||||
named for its role while sitting under an innocuous filename. Read `DT_SONAME` from
|
named for its role while sitting under an innocuous filename. Read `DT_SONAME` from
|
||||||
`.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see
|
`.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see
|
||||||
`native-and-so.md` §forged headers) gives you the truth. A `lib<something>.so` claiming
|
`native-tamper-and-suicide.md` §Forged section headers) gives you the truth. A `lib<something>.so` claiming
|
||||||
`soname = liblinkerloader.so` is a private ELF loader.
|
`soname = liblinkerloader.so` is a private ELF loader.
|
||||||
|
|
||||||
Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version;
|
Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version;
|
||||||
@@ -337,7 +337,7 @@ Java.perform(function () {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Read the log after driving the UI. `RAW-URL` (with no `OKHTTP` events) is how you learn the request went over `HttpsURLConnection` — a different trust path entirely (`references/server-api.md` § TLS). `THROW` is how you learn an upper layer swallowed the real error.
|
Read the log after driving the UI. `RAW-URL` (with no `OKHTTP` events) is how you learn the request went over `HttpsURLConnection` — a different trust path entirely (`references/tls-and-cert.md` §If OkHttp is the failing path). `THROW` is how you learn an upper layer swallowed the real error.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# pull the record (app-private path needs root to read)
|
# pull the record (app-private path needs root to read)
|
||||||
@@ -357,7 +357,7 @@ adb -s <serial> shell "su -c 'cat /data/user/0/<app.package>/files/probe.log'" >
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
3. **Is that code path reached at all?** Layer 4 settles it: if `THROW` events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help.
|
3. **Is that code path reached at all?** Layer 4 settles it: if `THROW` events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help.
|
||||||
4. **Did your UI action trigger business logic?** A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (`references/environment.md` § UI automation), not just that the button animated.
|
4. **Did your UI action trigger business logic?** A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (`references/environment.md` §Driving the UI from adb), not just that the button animated.
|
||||||
|
|
||||||
## `Java.choose` also matches dead instances
|
## `Java.choose` also matches dead instances
|
||||||
|
|
||||||
@@ -4,9 +4,10 @@ Load this when you are choosing tools, when a tool produces an answer that smell
|
|||||||
something is not installed. It is a map, not a tutorial: each entry says what the tool is *for*, how
|
something is not installed. It is a map, not a tutorial: each entry says what the tool is *for*, how
|
||||||
to drive it non-interactively, and the failure mode that wastes time.
|
to drive it non-interactively, and the failure mode that wastes time.
|
||||||
|
|
||||||
**Prefer tools you can drive from a command line.** An agent cannot click. Tools that exist only as
|
**Prefer tools you can drive from a command line.** An agent cannot click. A tool that ships only a
|
||||||
a GUI are listed where they matter, marked as requiring a human, so you can ask for that instead of
|
GUI is not automatically out of reach — check for a headless or MCP path first, which is an
|
||||||
stalling or silently substituting a weaker method.
|
install-and-set-up step rather than a reason to substitute the tool. When no such route exists, ask
|
||||||
|
for a human instead of stalling or silently downgrading to a weaker method.
|
||||||
|
|
||||||
## Tier 0 — present on almost any machine, no install
|
## Tier 0 — present on almost any machine, no install
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ not that the method is empty.
|
|||||||
| `capstone` (python) | library | decoding in your own scripts — see the silent-stop trap below |
|
| `capstone` (python) | library | decoding in your own scripts — see the silent-stop trap below |
|
||||||
| `keystone` (python) | library | assembling a short patch when you are editing bytes by hand |
|
| `keystone` (python) | library | assembling a short patch when you are editing bytes by hand |
|
||||||
| `pyelftools` (python) | library | **section-based — unreliable on hardened targets.** Prefer hand-walking `PT_LOAD`/`PT_DYNAMIC`, as `scripts/elf_plt.py` does |
|
| `pyelftools` (python) | library | **section-based — unreliable on hardened targets.** Prefer hand-walking `PT_LOAD`/`PT_DYNAMIC`, as `scripts/elf_plt.py` does |
|
||||||
| IDA Pro | GUI | human-driven. Ask for it when decompiler output is genuinely needed; do not pretend a raw disassembly is equivalent |
|
| IDA Pro | GUI, **or headless via `idalib-mcp`** | the strongest decompiler output. Do not assume it needs a human in front of it — a headless MCP server exists (see *MCP tool servers* below). Ask for a human only when that setup is genuinely unavailable |
|
||||||
| `x64dbg` | GUI, Windows | human-driven live debugging of a native Windows target |
|
| `x64dbg` | GUI, Windows | human-driven live debugging of a native Windows target |
|
||||||
| `gdb` / `lldb` | CLI | live debugging where a device or emulator allows it |
|
| `gdb` / `lldb` | CLI | live debugging where a device or emulator allows it |
|
||||||
|
|
||||||
@@ -112,6 +113,64 @@ completeness matters (`native-tamper-and-suicide.md` §Scanner traps).
|
|||||||
4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage.
|
4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage.
|
||||||
Bound your window with a known entry point or a known call site.
|
Bound your window with a known entry point or a known call site.
|
||||||
|
|
||||||
|
## MCP tool servers — an external dependency, not a tool on the shelf
|
||||||
|
|
||||||
|
An MCP server is neither a CLI tool nor one of the scripts here. It is a **separate installation plus
|
||||||
|
a running process**: something to install, a service to start, usually an extension to load, often an
|
||||||
|
authorization step. Listing one beside `readelf` would imply it is already present. Declare it as a
|
||||||
|
dependency with its prerequisites, and check whether it is actually available *before* planning work
|
||||||
|
that needs it — discovering this mid-task is the "missing tool" failure again.
|
||||||
|
|
||||||
|
### IDA Pro, headless
|
||||||
|
|
||||||
|
IDA does **not** require a human at a GUI. `mrexodia/ida-pro-mcp` ships `idalib-mcp`, a headless MCP
|
||||||
|
server that drives an IDA database with no GUI process at all:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run idalib-mcp --host 127.0.0.1 --port 8745 path/to/executable # open a binary up front
|
||||||
|
uv run idalib-mcp --host 127.0.0.1 --port 8745 # open databases on demand
|
||||||
|
uv run idalib-mcp --stdio # for stdio-based clients
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites, all of which must be arranged before the first call: IDA Pro 8.3+ (9 recommended;
|
||||||
|
**IDA Free is not supported**), a Python 3.11+ that `idapyswitch` can select, `uv`, and an `idalib`
|
||||||
|
activated globally via `py-activate-idalib.py`. Each open database lives in a worker process that
|
||||||
|
outlives the supervisor and is adopted transparently by a later supervisor on the same host, so
|
||||||
|
several sessions can share one analysis. Every tool call carries an explicit `database` argument —
|
||||||
|
there is no implicit "current database" — and `idb_open` returns the session id you must pass.
|
||||||
|
|
||||||
|
The GUI-plugin variant of the same project is deprecated upstream in favour of `idalib-mcp`; do not
|
||||||
|
set that up for agent work.
|
||||||
|
|
||||||
|
### Ghidra
|
||||||
|
|
||||||
|
`LaurieWired/GhidraMCP` is a Ghidra **extension** plus a separate Python bridge, not a headless
|
||||||
|
analysis server: Ghidra must be running with the plugin loaded (its HTTP server defaults to
|
||||||
|
`127.0.0.1:8080`) and the bridge is another process your client starts. Treat it as requiring an
|
||||||
|
interactive GUI session. For unattended work the shape that actually runs without a GUI is
|
||||||
|
`analyzeHeadless` with a post-script.
|
||||||
|
|
||||||
|
### The boundary: deobfuscate before you hand the binary to a model
|
||||||
|
|
||||||
|
Both are LLM-driven, and they inherit the model's weaknesses. The IDA MCP authors say it plainly in
|
||||||
|
their own README: **"LLMs will not perform well on obfuscated code"**, and they advise removing
|
||||||
|
string encryption, import hashing, control flow flattening, code encryption and anti-decompilation
|
||||||
|
tricks *before* asking a model to solve anything.
|
||||||
|
|
||||||
|
That matters more here than in most domains, because it is precisely the shape of the targets this
|
||||||
|
skill deals with. An OLLVM-style or virtualized binary is not a job for a model reading decompiler
|
||||||
|
output; the deobfuscated version is a *different binary*, and producing it is the work already
|
||||||
|
described in `code-virtualization-and-custom-linkers.md` and
|
||||||
|
`native-tamper-and-suicide.md`. The same advice has a second half: resolve library code first
|
||||||
|
(FLIRT/Lumina) so the model is not asked to reason about `memcpy` as if it were the program.
|
||||||
|
|
||||||
|
Two consequences worth carrying into the plan:
|
||||||
|
|
||||||
|
- Ask a model for **navigation and reading** — rename, comment, summarise a function, convert an
|
||||||
|
immediate — and verify anything that decides the patch against the disassembly yourself.
|
||||||
|
- If the server is not installed, that is a **missing dependency to report**, not a licence to present
|
||||||
|
a weaker tool's output as equivalent (`packers.md`).
|
||||||
|
|
||||||
## Tier 3 — runtime and network
|
## Tier 3 — runtime and network
|
||||||
|
|
||||||
| Tool | Invoke | Notes |
|
| Tool | Invoke | Notes |
|
||||||
+3
-3
@@ -22,7 +22,7 @@ to updates:
|
|||||||
|
|
||||||
A fourth case is a **resource / hot-update channel**: the app fetches a bundle (JS, a Dart/RN patch, a
|
A fourth case is a **resource / hot-update channel**: the app fetches a bundle (JS, a Dart/RN patch, a
|
||||||
config blob, a plugin APK) that can reintroduce the behaviour you removed. That is a separate code path
|
config blob, a plugin APK) that can reintroduce the behaviour you removed. That is a separate code path
|
||||||
from version checking — handle it explicitly if the app has one (see §hot-update below).
|
from version checking — handle it explicitly if the app has one (see §Step 5 below).
|
||||||
|
|
||||||
## Step 1: locate the check
|
## Step 1: locate the check
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ update / upgrade / new version / check version / force update
|
|||||||
```
|
```
|
||||||
|
|
||||||
Search those in the app's string table and in class names; on heavy runtimes search the string pool,
|
Search those in the app's string table and in class names; on heavy runtimes search the string pool,
|
||||||
remembering its encoding (see `framework-runtimes.md` §string encoding — searching only one encoding
|
remembering its encoding (see `framework-runtimes.md` §Locating logic without symbols — searching only one encoding
|
||||||
misses half your hits, and "nothing found" is usually the encoding, not an absence).
|
misses half your hits, and "nothing found" is usually the encoding, not an absence).
|
||||||
|
|
||||||
**Signals that a version check exists even if you cannot read the strings:**
|
**Signals that a version check exists even if you cannot read the strings:**
|
||||||
@@ -143,7 +143,7 @@ If the app can fetch a bundle at runtime, version checking is not the only way y
|
|||||||
|
|
||||||
Note the flip side: a **server-issued configuration** is the same category. If the thing you removed was
|
Note the flip side: a **server-issued configuration** is the same category. If the thing you removed was
|
||||||
driven by remote config, the client patch holds only while the server keeps sending that config — see
|
driven by remote config, the client patch holds only while the server keeps sending that config — see
|
||||||
`references/ad-removal.md` §server-driven and `references/membership-and-limits.md` for the general
|
`references/server-config-and-updates.md` §6 and `references/membership-and-limits.md` for the general
|
||||||
rule that *client-side enforcement can be patched, server-side authority cannot*.
|
rule that *client-side enforcement can be patched, server-side authority cannot*.
|
||||||
|
|
||||||
## The honest answer for silent version gates
|
## The honest answer for silent version gates
|
||||||
Reference in New Issue
Block a user