【Android安全- AI 时代逆向工程基建:从 GUI 到 API 的思考 (以算法助手 MCP 为例)】此文章归类为:Android安全。
毕业接触 Android 安全的第一天, 用 Frida 脚本成功修改函数的返回值,看着 APP 按自己的意志运行,兴奋得半夜 3 点还没睡着觉.
但随着逆向做得越来越多, 事情变味了.
每次拿到一个新的 App, 又要重新找入口、写 Hook、拼参数、看日志 … 心里烦躁居多: "又特么要写遍 Hook?”
于是开始 "封装", 写 Frida 模板、Python 脚本, XPosed 模块.
但很快又发现: 需求总是定制化, 而自己的封装往往过度设计, 最后还是得乖乖回去手写脚本, 然后发现很多工具函数又得手动复制一遍.中间的复制粘贴得有十几次, 感觉全是重复性工作, 有些枯燥.
用我高中数学老师的话来说, 这不 "美".
那在 AI 时代, 什么是 "美" 的逆向工程?
目前我觉得, 要做的重复性工作越少, 就越美. 越能让我偷懒, 就越美.
随着大模型 (Cursor, Claude, Codex) 的普及, AI 辅助静态代码分析已经成为常态. 前几天看到 frida-mcp, 意识到 AI 现在也可以动态分析了.
我当前工作也总会有点小外挂分析需求, 什么卡密校验绕过, 加密链路分析之类的活, 最常用的 APP 就是军哥的算法助手 Pro, 什么过弹窗, 允许截屏, 增强 Reqable 抓包, 文件读写监控, 常用的密码算法 hook 之类, 还能自己选择 hook 哪些类, 比写 XPosed 脚本方便多了.
但果然没干几次我的 "牛夫人感应" 就又出现了.
每次都得选择 hook 哪个 App, 手动增加要 Hook 的方法, 重启进程, 查看日志, 有时候我还得给 frida 脚本放到 /sdcard/ 并打开文件管理器选择载入, 怎么又特么得点一遍? 弄了半小时, 得到了一堆还是需要我自己分析的日志.
要不让 AI 点?
试一下会发现, 就算是 Opus4.6, 每点击一下它也得想想下一步做什么, 点几个页面他得想 10 次, 看它操作比我自己手点还慢, 再一想这还要花我 Token, 就开始生闷气了.
岂止是不美, 简直是有点丑陋.
事实上, GUI 本身是为人类设计的, 但对 AI 来说, 文本命令天然匹配 LLM 的输入格式, 可自由串联成复杂工作流, API 和 CLI 才是面向 agent 的.
昨天也看到了 CLI-Anything, 是基于开源代码, 将所有的开源软件功能暴露出 CLI 接口, 让 agent 可以更好的使用.
这是针对开源项目的, 闭源还得有逆向的环节.
因此, 这篇是给我自己工作的提效探索开个头: 面向 AI 的逆向 GUI 工具利用.
我想做的, 就是把这些面向人类的 GUI 软件, 通过逆向分析剥离出可以直接被大模型调用的 API. 已经做过的事, 就别再反复手点了.
当你拿到一个只能通过 UI 点击的 APP,想要把它变成脚本或 AI 可以自动控制的接口时,先不要去想“按键精灵”或 UI 自动化测试 (那太人类了)
可以期待作者提供接口, 也可以先尝试自己探索探索, 通常有以下 3 个通用的切入点:
UI 的每次点击,最终必然对应着某处数据的修改
思路: 监控 /data/data/pkg/shared_prefs、databases, 以及外部存储 /sdcard/Android/data/pkg/files. 只要找到配置文件 (XML/JSON/DB) , 可尝试用脚本修改文件, 就能绕过 UI.
如果配置不在常规文件里,或者修改文件后不生效,说明存在内存缓存或跨进程通信。
思路: 反编译工具 APK, 重点排查 AndroidManifest.xml 中的 provider、receiver、service (特别是 exported=true 的组件) . 很多工具的 UI 和后台服务是通过这些 Android 标准机制通信的.
有些工具为了方便高级用户或自身调试,会暗藏命令行接口。
思路: 检查工具的二进制文件、安装脚本, 或者在反编译代码中搜索 Runtime.getRuntime().exec、su -c 等关键字, 寻找隐藏的 shell 命令.
几个阶段性目标:
实验时,LSPosed 里勾选的是:
系统框架com.reqable.androidcom.example.app算法助手自己 UI 里勾选的是:com.lerist.fakelocationcom.example.app开始找, 先看算法助手自己的常见目录:
/data/user/0/com.junge.algorithmAidePro/shared_prefs/data/user/0/com.junge.algorithmAidePro/files/sdcard/Android/data/com.junge.algorithmAidePro/files/config/data/adb/lspd/config很快可以确认一件事:目标包的 Hook 配置 确实在外置目录里路径是:/sdcard/Android/data/com.junge.algorithmAidePro/files/config/<targetPackage>.json但这只是配置, 算法助手 UI 那份 "应用勾选状态 (AppSwitch)" 并没有直接出现在应用私有目录里. shared_prefs 下看到的是几个 .sp 文件, 看起来像配置, 但直接搜包名没有命中.这里最容易犯的错误, 就是默认认为 "找不到明文包名 = 没有本地持久化" .实际不然, 既然文件层面找不到, 我们就转向代码层面 (寻找阻力最小路径)
把 base.apk 拉下来后,重点不是全量看代码,而是找配置读写路径。
反编译后很快能抓到几个关键点:
ConfigReader.getInstanceByAlgorithmAidePro(String str)ConfigProviderandroid:authorities="algorithmAidePro"xposedsharedprefs=true其中最关键的是 ConfigProvider. 它直接暴露了两个查询维度:
projection=configprojection=AppSwitch# 查询 AppSwitch
adb shell content query --uri content://algorithmAidePro/com.example.app --projection AppSwitch
# 写入 AppSwitch
adb shell content insert --uri content://algorithmAidePro/com.example.app --bind AppSwitch:s:true
先拿 Provider 当读校验面, 再反推真实落点
AppSwitch.json 不是 Provider到这里出现了一个反常现象:
AppSwitch/data/user/0/com.junge.algorithmAidePro/shared_prefs/AppSwitch.xml 并不存在说明不是这个文件, 使用 AppSwitch 关键词找到了实际落点
/data/system/junge/AppSwitch.json
直接读取内容会得到一份包名到布尔值的映射,例如:
{
"com.example.app": true,
"com.lerist.fakelocation": true,
}
当前版本和这台设备上,算法助手 UI 的应用勾选状态主仓库已经和 AlgorithmServer 里的常量对上了:
APP_SWITCH = AppSwitch.jsonBASE_DIR = /data/system/junge/LSPosed_modLSPosed 的作用域是另一份配置, 不在算法助手目录里.
真实位置在:
/data/adb/lspd/config/modules_config.db
/data/adb/lspd/config/modules_config.db-wal
通过主库、WAL 和备份库的字符串命中, 可以确认这个 db 里存的是 LSPosed 的模块生效信息.但直接改 sqlite3 不太优雅, 所以后面直接转向 LSPosed_mod 提供的 CLI.
先确认环境:
/data/adb/lspd/bin/cli, CLI 需要 root 权限Enable CLIsu -c /data/adb/lspd/bin/cli scope set -a com.junge.algorithmAidePro com.qiyi.video/0
/sdcard/Android/media/<targetPackage>/database/algorithmAidePro.db
一开始最自然的思路, 是围绕日志页面里的 "保存所有日志" 做自动化.这条路后来确认过两个事实:
ThreadSaveLogList -> ConfigReader.createLogFile(null)/sdcard/Android/data/com.junge.algorithmAidePro/files/Log/<yyyy-MM-dd_HH_mm_ss>.log
第一轮里, 借用已有的经验, 先沿 "已验证路径" 查找:
content://algorithmAidePro/...content://algorithmAidePro/... 能稳定读到: projection=configprojection=AppSwitch但没看到任何 "导出日志" 相关 projection, 也没看到稳定可用的 insert/update/call 写入口.第二, Frida 日志这条链路是独立成立的.
/sdcard/Android/data/com.junge.algorithmAidePro/files/files/fridaLog.html
这个能直接拉, 但它只对应 Frida 脚本日志, 不是原生 hook 日志.而且 fridaLog.html 的确存在, 但它偏向 UI/导出面, 不一定是最底层运行时写入面. 后面继续实机推进时, com.example.app 又看到了一个更直接的文件:
/sdcard/Android/media/com.example.app/database/frida.log
这个文件会直接记录 Frida 运行时日志, 做 smoke test 比 fridaLog.html 更直接.
到这里警觉了, "保存所有日志" 本身就是一个面向人看的导出动作. 它本质上是:
files/Log/*.log继续研究 "怎么替代按钮点击" , 应该是走远了, 且绕不过那个触发 UI 动作.更该问的是: "日志页面展示的数据, 最原始的存储到底在哪?"思路从 "模拟 UI 导出" 变成了 "直接找日志源" .
接下来先排了几处看起来最像“会放日志”的地方:
/data/user/0/com.junge.algorithmAidePro/files/data/user/0/com.junge.algorithmAidePro/databases/data/system/junge//data/system/junge/ 里面确实东西很多,而且看起来很像“算法助手系统侧仓库”:
AppSwitch.jsonlogList.jsoncom.example.app/config.jsoncom.example.app/script_data.json但再往里看就会发现,这里主要是:
以 com.example.app/config.json 为例,里面已经能直接看到:
hookListprintLogenableScript但这里后来踩了一个很典型的坑。
一开始很容易顺着笔记继续默认:
files/config/<pkg>.json 是当前生效配置enableScript 在这份 JSON 里第一句是对的,后两句不完整。
后面实机和反编译一起对账后,边界变成了:
files/config/<pkg>.json 决定当前“选中了哪个脚本名”/data/system/junge/<pkg>/frida/<script>.js/data/system/junge/<pkg>/script_data.json以 com.example.app 为例,当前能直接对上的就是:
/data/system/junge/com.example.app/config.json/data/system/junge/com.example.app/script_data.json/data/system/junge/com.example.app/frida/bezierzhixian.jsenableScript 管的是脚本选择, 不是脚本内容本身.这时候已经能排掉几类常见误判了:
/data/system/junge/ 更偏向配置仓库, 不是日志明细仓库files/Log/*.log 更偏向导出结果, 不是长期主存储按这个思路继续往下找, Android/media/<pkg>/database 这条线就变得很顺了. 想想也是, 对这类“一套宿主管多个目标”的形态, 配置统一放宿主侧, 日志按目标包落地, 本来就很合理.
第四轮:开始按数据库名全局反查既然 UI 页面背后大概率是结构化数据,就应该反过来找数据库,而不是继续盯着文本文件。转折点不在代码, 而在设备全局搜索:
/data/media/0/Android/media/com.example.app/database/algorithmAidePro.db
这个路径一出现,很多事情就串起来了:
algorithmAidePro.db,和产品本身高度相关第五轮:验证这个库是不是日志源把库拉下来后,看 sqlite_master,结果非常干净:
table|LOG_DATA_V2|LOG_DATA_V2
table|android_metadata|android_metadata
table|sqlite_sequence|sqlite_sequence
表结构也直接指向日志用途:
CREATE TABLE IF NOT EXISTS "LOG_DATA_V2" (
"_id" INTEGER PRIMARY KEY AUTOINCREMENT,
"GROUP" INTEGER NOT NULL,
"TYPE" INTEGER NOT NULL,
"OBJ_NAME" TEXT,
"CLASS_NAME" TEXT,
"LOG_NAME" TEXT,
"TIME" INTEGER NOT NULL,
"IS_READ" INTEGER NOT NULL,
"LOG_DETAILS_RAW" BLOB,
"CALL_STACK" TEXT
);
到这里基本已经坐实了:
再继续查最近几条:
com.example.app.MainActivity | unregisterPluginTestReceiver()com.example.app.MainActivity | onDestroy()com.example.app.MainActivity | lambda$setupTestButtons$3$com-example-app-MainActivity()而且当前设备上行数是实打实的:
124
做到这里, 结论就很清楚了.
最初设想是:
.logadb pull而现在找到的路径是:adb pull /sdcard/Android/media/<pkg>/database/algorithmAidePro.dbsqlite3 / GUI 工具直接查询后者明显更适合后续 MCP 化:
结构化
可筛选
可排序
可增量导出
可直接转 TSV / CSV / JSON
UI 文本导出链路已经逆出来了,但它不是最优自动化目标
content://algorithmAidePro/... 仍然是可靠读校验面,不是日志导出面
Frida 日志仍然独立落在 fridaLog.html
原生 hook 日志已经找到更好的非 UI 主路径:Android/media/<pkg>/database/algorithmAidePro.db
这轮跑通的小闭环不是“自动触发 UI 保存日志”, 而是“直接通过 CLI 导出结构化 hook 日志数据库并用 SQL 查询”.
示例:
adb pull /sdcard/Android/media/com.example.app/database/algorithmAidePro.db .
sqlite3 algorithmAidePro.db 'select count(*) from LOG_DATA_V2;'
sqlite3 -header -column algorithmAidePro.db "
select
_id,
\"GROUP\",
TYPE,
ifnull(OBJ_NAME,'') as obj_name,
ifnull(CLASS_NAME,'') as class_name,
ifnull(LOG_NAME,'') as log_name,
TIME,
length(LOG_DETAILS_RAW) as raw_len
from LOG_DATA_V2
order by TIME desc
limit 10;
"
只要目标自己暴露了 Provider,就不要只做静态分析因为 Provider 能直接回答:
/data/misc/.../prefs很多人会一直盯着:
/data/user/0/<pkg>/shared_prefs但带 xposedsharedprefs 的模块,配置可能根本不落在应用私有目录,而是 Xposed 可共享读取的位置如果后面要把算法助手做成一个通用 Java Hook MCP,动作至少要拆成三层:
/sdcard/Android/data/com.junge.algorithmAidePro/files/config/<targetPackage>.json/data/system/junge/AppSwitch.jsonAppSwitch.jsonlogList.jsonprojection=AppSwitch(仅作参考)/data/adb/lspd/bin/cli/data/adb/lspd/config/modules_config.db这三层不拆开,后面做 CLI 和 MCP 很容易把状态混在一起。
这次主要拆开的就是:
当所有默认开关都打开时,实机里这份包级 JSON 大致会长这样:
{
"ApplicationSwitch": true,
"ExceptionSwitch": true,
"SharedPreferencesPutSwitch": true,
"activitySwitch": true,
"assetsSwitch": true,
"cameraHookSwitch": true,
"checkRootSwitch": true,
"cipherSwitch": true,
"closeDialogSwitch": true,
"dialogKeyword": "注册码,机器码,激活码",
"dialogSwitch": true,
"digestSwitch": true,
"exitSwitch": true,
"fileDeleteSwitch": true,
"fileSwitch": true,
"fileWriteSwitch": true,
"getSharedPreferencesSwitch": true,
"hiddenVpnSwitch": true,
"hiddenWifiProxySwitch": true,
"hiddenXposedSwitch": true,
"justTrustMePlushSwitch": true,
"logSwitch": true,
"macSwitch": true,
"onClickSwitch": true,
"reqableSwitch": true,
"reqableSwitch_native": true,
"screenSwitch": true,
"shellSwitch": true,
"signSwitch": true,
"sqliteDeleteSwitch": true,
"sqliteExecSQLSwitch": true,
"sqliteInsertSwitch": true,
"sqliteOpenSwitch": true,
"sqliteQuerySwitch": true,
"sqliteUpdateSwitch": true,
"textViewSwitch": true,
"webCryptSwitch": true,
"webViewDebugSwitch": true,
"webViewLoadUrlSwitch": true
}
projection=config 不是 config.xml 说了算一开始很容易觉得:
AppSwitch.json 管 UI 勾选config.xml 管功能配置files/config/<pkg>.json 只是导出副本但实机验证下来,至少对 projection=config 不是这样。做了两组对照实验:
pkg.json.digestSwitch=true,config.xml.digestSwitch=falseadb shell content query --uri content://algorithmAidePro/com.example.app --projection configdigestSwitch=truepkg.json.digestSwitch=false,config.xml.digestSwitch=falsedigestSwitch=false这已经足够说明:
/sdcard/Android/data/com.junge.algorithmAidePro/files/config/<pkg>.json
才是 projection=config 的主控制源,优先级高于:
/data/misc/<uuid>/prefs/com.junge.algorithmAidePro/config.xml
所以按字段改目标包配置时,直接改包级 JSON 就行,不用碰 config.xml。
这台机器上 su 的上下文是:
uid=0(root) gid=0(root) context=u:r:magisk:s0
SELinux 仍然是 Enforcing。而且:
setenforce 0 会直接失败config.xml 会报 Permission denied这说明“有 root”不代表“随便哪条写法都能写成功”。尤其是带 /data/misc/.../prefs 的路径,要考虑 magisk su 的上下文限制,不能靠想当然。如果目标是“不要点 UI,只靠命令行改成功”,当前最稳定的闭环已经收敛成下面这 4 步:
force-stop 算法助手命令顺序如下:
adb shell am force-stop com.junge.algorithmAidePro
adb push com.example.app.json /data/local/tmp/com.example.app.json
adb shell su -c 'cp /data/local/tmp/com.example.app.json /sdcard/Android/data/com.junge.algorithmAidePro/files/config/com.example.app.json'
adb shell monkey -p com.junge.algorithmAidePro -c android.intent.category.LAUNCHER 1
adb shell content query --uri content://algorithmAidePro/com.example.app --projection config
这次顺手把“算法助手里手工快速添加的自定义 hook 方法”也导出看了一眼。以 com.example.app 为例,设备上能看到两份同内容文件:
/sdcard/Android/data/com.junge.algorithmAidePro/files/config/com.example.app.json/sdcard/Android/data/com.junge.algorithmAidePro/files/exportConfig/com.example.app.jsonconfig/<pkg>.json 是当前生效配置exportConfig/<pkg>.json 是导出快照com.example.app 当前导出的核心内容大致如下:
{
"enableScript": "bezierzhixian.js",
"hookList": [
{
"argsValues": [],
"className": "com.example.app.DemoTarget",
"constructor": true,
"description": "来自快速添加的Hook",
"enable": true,
"intercept": false,
"methodName": "<init>",
"parameterSign": "",
"printLog": true,
"results": ""
},
{
"argsValues": [],
"className": "com.example.app.DemoTarget",
"constructor": false,
"description": "来自快速添加的Hook",
"enable": true,
"intercept": false,
"methodName": "a",
"parameterSign": "Landroid/content/Context;",
"printLog": true,
"results": ""
}
]
}
每条 hookList 至少包含:
classNamemethodNameconstructorparameterSignenableprintLoginterceptresultsargsValues例如:
Landroid/content/Context;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;这类写法更接近 JNI / Smali 描述符,不是 Java 源码签名。
这次导出里,构造函数同时具备两个特征:
constructor=truemethodName="<init>"后面如果要抽 DSL,constructor 和 <init> 最好别让调用方重复写。
enableScript 说明脚本和 hookList 可以并存这份 JSON 不只是 hookList,还包含:
"enableScript": "bezierzhixian.js"
并且这次设备上也确实找到了对应脚本文件。
也就是说,结构化 Hook 和额外脚本本来就能并存。
后面不应该只盯着“把算法助手 JSON 原样搬来搬去”,而应该往更高一层抽象:
hookListenableScriptfiles/config/<pkg>.jsonfiles/exportConfig/<pkg>.json这三种东西后面完全可以统一进一个更高阶的 Hook DSL.
enableScript 误当成脚本内容落点实测里先改了:
/sdcard/Android/data/com.junge.algorithmAidePro/files/config/com.example.app.json/sdcard/bezierzhixian.js然后重启算法助手去验证。这个动作本身不算错, 但它只能证明 "脚本名选择生效" , 不能证明 "运行时读到的是这份外部脚本内容".
原因是:
/data/system/junge/com.example.app/frida/bezierzhixian.jssu -c 一定已经切到 root这台设备上一个很隐蔽的问题是:
adb shell su -c '...'有时候实际上仍在 shell 身份跑。
直到显式改成:
adb shell 'su 0 sh -c "...'"
才拿到 uid=0(root),并成功覆盖 /data/system/junge/com.example.app/frida/bezierzhixian.js。
否则很容易误以为“目录有缓存”或者“文件不可写”,实际只是 root 没真的切成功。
后面直接改了真实执行脚本之后,com.example.app 对应目录下的:
/sdcard/Android/media/com.example.app/database/frida.log
已经能看到我们主动写入的:
[smoke-probe] script loaded pid=...
这之后如果 onCreate、onResume、showMessage 这类 hook 没看到,不应再回头怀疑“脚本没生效”,而应该优先怀疑:
后面要解决的就不是“脚本存哪”, 而是“选什么 hook 点才一定会触发”。
对现有算法助手而言,当前更可靠的 Frida 改脚本闭环是:
force-stop com.junge.algorithmAidePro/data/system/junge/<pkg>/frida/<script>.js/sdcard/Android/media/<pkg>/database/frida.log这条链已经够短, 也适合后面继续做 CLI/MCP.
onResume 不是不能用, 前提是 Hook 点要选得更硬, 时序也要对前面一度会怀疑:
MainActivity.onResume() 这类点到底会不会触发单独盯某个 Activity 自己声明的方法,确实可能踩到两个问题:
这轮后面换了个更稳的 smoke hook:
android.app.Activity.onResume()com.example.app*然后按下面时序跑:
com.example.app最后 frida.log 里就稳定拿到了:
com.example.app.DemoCamera2Activitycom.example.app.MainActivity的 onResume 日志。
现在在 algorithmaide-mcp 里,Frida 这条链已经额外做了一层受控适配:脚本写入时统一预置 __aaLog / __aaLogHit 结构化 logger,并强制要求脚本按契约打点。读取侧则按真机实际格式解包 frida.log 外层 {"type":"log","payload":"..."} envelope,再回收到统一查询视图里。
上面这套东西如果再往前推一步, 已经是在做一个可重复迭代的动态分析闭环:
这里核心在于“日志格式统一”。
因为不管接的是:
enableScript 对应的附加脚本日志最后都不能停留在“原始文本打印”这一层。更合适的是把它们都收敛成统一事件结构,例如:
如果只靠动态侧自己盲打,效率会很差。所以它和 jadx-ai-mcp 很适合协同:
jadx-ai-mcp 提供静态分析结果jadx-ai-mcp 偏静态分析前端algorithmaide-mcp有了这些底层 API, 接下来就是把它们封装成大模型能直接调用的 MCP Tool.algorithmaide-mcp 的架构被有意设计为分层结构
我们把这些底层动作, 例如包级 JSON 写入, AppSwitch 同步校验, LSPosed scope CLI 接管, 封装成了类似 apply_algorithm_aide_config 这样的高级工具.
现在, 你只需要对 Cursor 说一句: "帮我用算法助手 Hook com.example.app, 开启网络抓包和加解密打印", AI 就会自动调用 MCP, 在真机上完成所有的配置、强杀应用、重启并抓取日志.
当然, 写 SKILL.md 时候加上 "不确定的时候, 先用 jadx-ai-mcp 自己分析下" 会更好.
项目开源地址:algorithmaide-mcp

更多【Android安全- AI 时代逆向工程基建:从 GUI 到 API 的思考 (以算法助手 MCP 为例)】相关视频教程:www.yxfzedu.com