apk-index

mcp
Security Audit
Warn
Health Warn
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 5 GitHub stars
Code Pass
  • Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

零依赖的本地 APK/AAR/DEX 结构索引 + MCP Server。17 个工具,为 Xposed 模块开发提供静态反查。

README.md

apk-index —— Android 逆向 / Xposed 模块开发用的 APK 索引 MCP Server

静态索引 APK / split APK / AAR / 裸 dex / vdex / compact-dex,给 Agent 提供
「查类、查成员、查签名、查字符串、查交叉引用、生成 hook 起手块」的只读能力。
纯 Python 3 标准库(zipfile / sqlite3 / struct / json / hashlib),stdio 上说 MCP JSON-RPC 2.0。

运行要求

  • Python >= 3.10,无第三方依赖
  • 可选:JDK 的 javac(AAR fixture)与 javap(.class 交叉校验);缺省时用内置 tools/classwrite.py 兜底
  • 只读挂载:索引写 APK_INDEX_CACHE(默认 ~/.cache/apk-index),永不写源文件

多客户端隔离(0.5.1+)

同时开两个对话 / 两个工作连同一个实例时,会话按 owner(租户) 隔离:

  • HTTP:客户端 initialize 后回传 MCP-Session-Id 请求头 → 每个客户端一个
    owner;不带头的老客户端归 default(兼容)。
  • stdio:每个进程随机 owner(可用环境变量 APK_INDEX_OWNER 固定)。
  • CLI:APK_INDEX_OWNERcli

隔离语义:sessionList 只显示本客户端的会话;unload 只解除自己的引用,
索引文件全局共享(同一包只建一次索引,多客户端 load 命中缓存),
最后一个引用者释放时才删文件。跨进程写索引有文件锁(<db>.lock),
两个客户端同时索引同一新包不会互相踩。

注册到 MCP 客户端

{
  "mcpServers": {
    "apk-index": {
      "command": "python3",
      "args": ["-m", "apkindex.mcp", "--stdio"],
      "env": {
        "PYTHONPATH": "/data/local/tmp/apk-index/src",
        "APK_INDEX_CACHE": "~/.cache/apk-index",
        "APK_INDEX_ALLOWED_ROOTS": "/sdcard:/data/local/tmp"
      }
    }
  }
}

APK_INDEX_ALLOWED_ROOTS(冒号分隔)是路径白名单,越界一律 INVALID_PATH —— 防止越权读宿主隐私文件。

23 个工具

分类 工具 一句话
装载 loadApk APK(+split 目录) → 会话;返回指纹 / manifest 摘要 / 组件 / 加固探测
装载 loadAar classes.jar + R.txt + consumer-rules.pro + jni/<abi>/*.so,可 mergeInto 并进 App 会话
装载 loadDex 裸 classes.dex / vdex / compact-dex,可追加进已有会话
会话 sessionList / stats / unload 忘了 sessionId 先 list;stats 看体量;unload 删登记(keepFiles 可选)
查询 searchClasses exact / prefix / regex,`scope=app
查询 listMembers 类内方法与字段,include 走 namePattern
查询 getSignature 一个类或成员 → smali / reflector / java / helper-ktx 四形态 + hook 起手块 + 注解(含 visibility)
查询 searchByString 常量字符串反查方法(contains/exact/regex,按命中数聚合排序)
查询 findImplementations 接口实现 / 父类子类,含传递继承链
查询 xref callers / callees,depth 控制跳数
代码 decompile outline(骨架)/ smali / javamaxLines 截断
生成 matchSignature 只给结构条件(参数个数/类型/返回值/引用字符串)→ 候选成员 + 可粘 DSL
生成 diffSessions 两版本差分:exact-structure / structural-similarity / added / removed(入参 sessionA/sessionB
综合 probe 一句人话问题 → 抽字面量/类名/关键词,串起 checkPacker+stats+各查询的证据包
探测 checkPacker 识别加固与 dex 加壳证据,只识别不静默脱壳
清单 listManifest 会话里的完整 manifest 摘要:包/版本/SDK/权限/四大组件/native 库,可按组件类型与 exported 过滤
清单 findComponent 按名字/类型/exported 精确定位一个组件(比 listManifest 窄)
资源 searchResources resources.arsc 条目枚举(type/name/typeId);解析失败报 NOT_FOUND 不装可用
资源 resourceRefs 找代码里引用某资源的方法:字符串常量 + const-class 真实 xref
安全 resourceSecurity 一页安全面:exported 组件、危险权限、native 库、资源表可用性
运维 doctor 只读缓存体检:逐会话 quick_check / schemaVersion / 体积 / 未登记 db 文件

返回契约(每个工具都保证)

  • 成功:{ok:true, sessionId, total, items[], truncated, hint, tool, elapsedMs, ...专有字段}
  • 失败:{ok:false, code, message, suggestion, hint}
  • items 上限 100 条、单 item ≤ 32KB;超出置 truncated:truehint 说明怎么收窄
  • hint 在成功与失败两种 envelope 里都恒存在,客户端只看一个字段就能决定下一步
  • 错误码:INVALID_PATH NOT_FOUND CLASS_NOT_FOUND INVALID_REFERENCE SESSION_NOT_FOUND
    APK_TOO_LARGE PACKED_DEX ENCRYPTED BACKEND_MISSING INTERNAL

自测

bash build.sh                              # 打 tar 分发包
python3 tools/make_fixture.py --out fixtures
APK_INDEX_CACHE=$PWD/.cache python3 tests/test_tools.py
# 真机样本(混淆目标 + 跨版本差分):
APK_INDEX_TEST_REAL_APK=/sdcard/Download/a.apk,/sdcard/Download/b.apk python3 tests/test_tools.py

已知边界

  • 只读静态索引,不脱壳、不改二进制;加壳 APK 只报证据与 PACKED_DEX
  • 无 JDK 时 decompile(java) 是内置可读伪 java(保留控制流与常量,不做数据流还原)
  • dex 解码是 mterp 子集,遇到未覆盖 opcode 标 partial:true 而不是猜
  • 未修问题清单见 TESTPLAN.md(真机 dex 的 mUTF-8 lone surrogate 已修,剩余为错误码与差分阈值)

参数与行为速查(对齐 tools/list 实测输出)

23 个工具:loadApk loadAar loadDex sessionList unload stats checkPacker
searchClasses listMembers getSignature searchByString findImplementations
xref decompile matchSignature diffSessions probe
listManifest findComponent searchResources resourceRefs resourceSecurity doctor

会话与索引

  • loadApk(path, splits?, fromDevice?, packageName?, backend?)按 dex 内容去重
    同一个 APK 无论重命名还是重打包,只要 dex 字节集合不变,拿回的是同一个 sessionId
    和同一份索引。想强制重来用 unload(keepFiles=false) 再 load。
  • 参数名写错会直接拒收BAD_ARGUMENT + 有效参数列表),不再静默丢弃。以前把
    searchClasseskind 写成 match,regex 不生效、返回 total=0,看着就像
    "这个包里根本没有"——假的空结果比崩溃更害人。注意两个搜索工具的参数名不同:
    searchClasses(query, kind=exact|prefix|regex)searchByString(text, match=contains|exact|regex)
  • loadAar(path, mergeInto?):读 classes.jar / R.txt / consumer-rules.pro /
    jni/**/*.somergeInto 是"并进宿主会话",宿主的 stats 会多出 library 类;
    需要干净的对照会话时别用 mergeInto
  • unload(sessionId, keepFiles?) / sessionList() / stats(sessionId) /
    checkPacker(sessionId):加固包在 checkPacker 里给壳特征与真实 dex 位置;
    静态可索引类数为 0 时会明确写"索引不到不等于没有"。

查询

  • searchClasses(sessionId, query, kind=exact|prefix|regex, scope=app|library|all, packageFilter, annotatedWith, limit)
    —— annotatedWith 只回类上带该注解的(@Keepdalvik.annotation.KeepLkotlin/Metadata; 三种写法都认),混淆包里挑名字稳定的锚点就用它。
  • listMembers(sessionId, class, include, namePattern, scope, withStrings, limit)
  • getSignature(sessionId, class, member, scope):一个类(或一个成员)的完整外形。注解走 annotationsdescriptor / javaName / visibility(build|runtime|system) / values
  • searchByString(sessionId, text, match=contains|exact|regex, minLen, scope, methodLimit, limit)
  • findImplementations(sessionId, interface|superClass, method, transitive, includeAbstract, scope, limit)
    —— 裸类名可用(等于该类的实现/继承查找)。
  • xref(sessionId, method, direction=callers|callees|both, depth, scope, limit)
    —— method 支持三种写法:com.a.b.C(整类全部成员的引用)、com.a.b.C->m(sig)
    com.a.b.C#m(sig);认不出来时报 NOT_FOUND 并把原样入参带回来,不静默返回空。
  • matchSignature(sessionId, params, returnType, namePattern, packagePrefix, modifiers, requireConstructor, invokedMethods, accessedFields, referredStrings, scope, limit)
    —— params 支持 any 与具体类型混写;参数个数上限 8(不是 3);
    packagePrefix 同时匹配 com/example/democom.example.demo 两种存法。
  • decompile(sessionId, target, format=java|smali, maxLines):三档兜底
    SMALI → JAVA+SMALI → SMALI,结果里标 backend/format/truncated
    JADX 不存在、崩了或导不出源码时不会装作成功。

差分与聚合

  • diffSessions(sessionA, sessionB, scope, minScore, limit):条目按 kind 分三种,
    键不一样 —— renamedfrom/to/score/how/fromDescriptor/toDescriptor/
    packageFrom/packageTo/methodsOnlyInA/methodsOnlyInBadded/removed 只有
    class/descriptor/superClass/source/methodCount/sampleMethods
    同一个会话自己比是合法的,结果为空差分。
    改名配对是启发式(同外形 + 同常量),how 说明依据;库类混进旧会话会搅动候选集,
    所以对照实验要在没被 mergeInto 污染过的会话上做。
  • probe(sessionId, question, target?, maxDepth?, limit):一句问题跑完整套侦察
    (引号字面量→字符串检索、点号/驼峰→类检索、中文串→UI 文案检索、加固→提示脱壳)。
    给了 target 就连带 xref + getSignature + decompile 深挖;问"有哪些实现类/
    子类"时给 implementations 分节。深挖失败另放 xrefErrors,不会把整次 probe 打死。

清单、资源与安全面(0.5.0 新增)

  • listManifest(sessionId, componentType?, query?, exported?, limit) —— 数据在 loadApk
    时已写入会话 summary,查询不重新解包。componentType=component 看全部四类;
    exported=true 只匹配清单里显式 true 的组件,未声明(None)不算——Android 默认规则
    和显式 false 不是一回事,安全审计上混淆这两者会给出假的干净列表。
  • findComponent(sessionId, name?, componentType?, exported?, limit) —— name 支持短名
    后缀匹配(.MainActivityMainActivity 都能命中)。
  • searchResources(sessionId, query?, match, resourceType?, limit) —— best-effort 解析
    resources.arsc(纯 stdlib,条目样本上限 512)。解析不了时NOT_FOUND + notes
    不返回 ok+空列表冒充"这个包没有资源"。
  • resourceRefs(sessionId, resource?, resourceType?, limit) —— 两种证据:DEX 字符串常量
    app_name / R.string.app_name)与 const-class(R$string / BuildConfig)。
    0 命中不等于没用到:getIdentifier、XML 直接引用、插件化加载都不留这两种痕迹,
    hint 里会说明。
  • resourceSecurity(sessionId) —— 单条目聚合,适合跨版本 diff:包/SDK/权限/危险权限/
    exported 组件/native 库/资源类型。
  • doctor() —— 只读:catalog 里每个会话的 db 存在性、PRAGMA quick_check、schemaVersion、
    缓存总大小、未登记的 .db 文件。它不修任何东西;处置是 unload + loadApk 重建。

注意:loadAar(mergeInto=...) 在 0.5.0 前会把库自己的 manifest 顶掉宿主清单;已修,
旧的混合会话要 force=true 重建才能拿回正确的组件与安全面。

命令行

PYTHONPATH=src python3 -m apkindex.cli doctor fixtures/demo-v1.apk
PYTHONPATH=src python3 -m apkindex.cli sessions
PYTHONPATH=src python3 -m apkindex.cli tools
PYTHONPATH=src python3 -m apkindex.cli call loadApk '{"path":"fixtures/demo-v1.apk"}'
PYTHONPATH=src python3 -m apkindex.cli call xref '{"sessionId":"ses_xxx","method":"com.example.demo.User","direction":"callers"}'
PYTHONPATH=src python3 -m apkindex.cli pull com.tencent.mm   # adb 在连时才可用
# 子命令只有 tools/call/pull/doctor/sessions;具体查询一律走 call <tool> '<json>'

从设备取包(adb)

没有独立脚本,走 loadApk 的入参:

{ "name": "loadApk",
  "arguments": { "fromDevice": true, "packageName": "com.example.app",
                 "path": "" } }

fromDevice=true 时依次 adb shell pm path <pkg>(拿 base.apk 与 split APK 全部路径)
adb shell dumpsys package <pkg>(取 versionName/versionCode/minSdk/targetSdk,
和清单里的值互相校正)→ adb pull 到缓存目录再索引。ADB 环境变量指定 adb 路径
(默认 adb_bin() 的探测结果)。找不到 adb、设备没授权或超时都报 ADB_UNAVAILABLE
并给出下一步;不假装能拉到自己拉不动的东西 —— 系统分区或受保护路径请在已 root
的机器上直接把 /data/app/~~xxxx/.../base.apkpath 传进来。

自测

python3 tests/test_tools.py                    # 38 组
APK_INDEX_TEST_REAL_APK=/path/a.apk,/path/b1.apk,/path/b2.apk python3 tests/test_tools.py

真机校准样本:TGAutoSign 1.2.1(1131 类,索引 ~1.6 s)、LocusMimic 2.0.0 ↔ 2.0.2
(差分识别 12 组改名候选)。只读校验用 APK_INDEX_CACHE + APK_INDEX_ALLOWED_ROOTS
把索引和读取范围钉在临时目录里。

apkindex.cli doctorok:false 只代表依赖不齐:缺 aapt2 / tools/dexlib.jar / tools/baksmali.jar / tools/apktool.jar / jadx/bin/jadx 时资源与反编译走降级路径,索引与结构化查询不受影响(缺什么会逐项列名,不猜)。

Streamable HTTP 传输(给只能填 URL 的客户端)

python3 -m apkindex.cli serve-http --host 127.0.0.1 --port 8732
# 手机上更靠谱的是这个(Android root,服务不会跟着工具环境一起被回收):
sh tools/httpd-android.sh 8732

端点 http://127.0.0.1:8732/mcp:POST 一条 JSON-RPC → 200;客户端 Accept 里写了
text/event-stream 就回 SSE 单帧(event: message + data: <同一份 JSON>),
只要 application/json 就回裸 JSON —— Kotlin/Java 系的 MCP SDK 常常只认 SSE。
通知(无 id)→ 202 空 body;批量 → 200 + 数组;GET → 健康检查(Accept 要 event-stream
则开常驻流,只发注释帧保活);DELETE → 200(回 501 会被客户端判成服务挂了)。
initialize 的应答带 Mcp-Session-IdMCP-Protocol-Version 头(只发不校验:
拒绝未知会话 id 会让实现松一点的客户端直接卡死,本服务无状态可泄漏)。
与 stdio 版共用同一个 Server.handle(),23 个工具、参数校验、响应体积预算完全一致。
默认只绑回环;本机 loopback 与宿主 Android 是同一个 network namespace,
所以手机上的 App(例:LSPilot 的 MCP 拓展 → 本地 → 新建 → Streamable HTTP)
直接填 http://127.0.0.1:8732/mcp 就能连。

鉴权、状态码与完整部署说明

docs/HTTP-DEPLOY.md:启动器与开机自启、chroot 路径绑定、路由与帧的选择、
JSON-RPC → HTTP 状态码映射表、四个大小上限、token 鉴权(APK_INDEX_MCP_TOKEN
非回环地址无 token 直接拒启)、decompileauto 降级契约、curl 全流程、排障表。

三条最容易踩的先放在这里:

  • 业务错误不占状态码:tools/call 成败都回 200,看信封里的 ok / code
  • tools/call 的 JSON-RPC method 恒为 tools/call,工具名在 params.name
    把工具名当 method 发会得到 404 / -32601
  • GET /mcp 免鉴权,是健康检查;它不含任何包内容。

HTTP 帧的选择

Accept 里同时有 application/jsontext/event-stream(ktor/OkHttp 客户端的默认写法)时,
服务端回裸 JSON;只有客户端只接受 text/event-stream 才回 SSE 单帧(event: message + data: <同一份 JSON>)。
原因是有些客户端一进 SSE 模式就按"长流"读,单条响应要等流结束才交给上层,白吃一个 request timeout。
--verbose 会把每个请求的方法/工具名、字节数和超过 500ms 的耗时打到 stderr,
慢在哪个工具、有没有卡在锁上,看一眼日志就知道。

输出:人读文本 + 机器 JSON 双通道

tools/call 的结果给两份:

  • content[0].text —— 给人读的渲染(src/apkindex/render.py):一行结论 + 对齐条目 +
    一句下一步;空结果会解释"为什么可能是空"。descriptor / smali / reflector 这些
    干活要用的字段一个都不省,因为有的客户端只把 text 喂给模型。
  • structuredContent —— 完整 envelope JSON,字段与早期版本一致,给程序解析。

以前两份内容都是同一坨 JSON,客户端常常重复显示两遍,人读很费劲;现在 text 走渲染,
JSON 只留一份。传输层不变:客户端 Accept 里同时允许 JSON 就回裸 JSON,只要 SSE 才回帧。

要自定义某个工具的样子:render.RENDERERS["toolName"] = fn(fn 收 envelope 返回字符串);
没注册的工具自动走 _r_generic,至少是 key 行 + 编号条目,不会退回裸 JSON。

Reviews (0)

No results found