1. 从追更到动手:我为什么会对一部国漫APP做逆向复原
先交代一下背景。我追《中国惊奇先生》这部国漫有些年头了,从漫画到动画一直在看。手机里一直装着一款漫画阅读APP,平时翻翻更新、看看评论,用得还算顺手。但做逆向这事,心思动了就压不住——天天在看的应用,好奇心会从"它画得真好"慢慢变成"它的阅读器到底怎么实现的"。再加上那段时间刚好在系统性地补安卓逆向的知识,天天看别人拆APK、还原界面,手痒得不行。某天晚上又刷到评论区里有人求"离线缓存包",我突然意识到,与其到处找现成的资源,不如自己动手把这APP拆了,把它的结构、逻辑、界面布局全部逆向反编译复原出来,顺便检验一下自己这段时间的逆向学习成果。
这里必须先说清楚一个边界:我这次逆向的目标,是分析我自己设备上已经安装的、可免费使用的漫画阅读客户端,整个项目只围绕"学习安卓逆向技术、理解国漫类APP的实现方式、复原其界面与核心交互逻辑"展开,不涉及任何付费解锁、VIP绕过、授权破解或盗版资源分发。以前我也见到过有人拿逆向去改支付结果、解锁付费漫画,那类操作我不碰,也劝各位别碰。逆向本身是中性技术,跑偏了就麻烦大了。
确定了动机之后,第一步是把目标拆清楚。我要做的不是拿到一张截图就算复原,而是要把这款APP从黑暗的二进制状态,重新拉回到"能读懂、能改、能重跑"的明亮状态。具体来说,整个项目分成四个目标:
- 解包APK,摸清应用的壳、入口和整体模块划分;
- 反编译核心代码,定位漫画阅读器、章节列表、缓存策略这些关键逻辑;
- 动态调试,验证反编译代码中看不懂的分支和判断条件;
- 提取资源,把布局、图片、自定义控件全部还原,最后拼装成一个能运行、能翻页的复原Demo。
工具方面,我用了目前安卓逆向最常用的一套组合:apktool负责解包和回编译,jadx负责把dex还原成可读性较好的Java代码,jeb备用处理部分混淆严重的代码段,frida做动态hook,最后的UI复原用Android Studio新建工程把资源重新组织。环境是Windows 11主机加一台Pixel 3测试机,Android版本是12,全程真机调试。
在正式开始之前还有个小插曲。原APP装在手机上一直正常使用,但用adb shell pm path找到安装路径后,我先把APK备份了一份出来。这里建议所有做逆向的人都养成这个习惯——后续所有操作都基于备份文件,别拿正在运行的进程瞎折腾,不然手抖一下,你追更到一半的漫画进度可能就没了。
2. 解包侦查:先把APK这层皮扒干净
2.1 apktool解包与目录结构初判
拿到备份的APK后,第一件事就是解包。APK本质上是一个ZIP压缩包,但直接用解压软件打开只能看到资源和未经反编译的二进制格式AndroidManifest,真正的代码还压缩在classes.dex里。所以我用apktool来做完整解包:
apktool d comic_app.apk -o comic_src输出目录结构大概是这样的:
comic_src/ ├── AndroidManifest.xml ├── apktool.yml ├── assets/ ├── lib/ ├── original/ ├── res/ └── smali/解包过程很顺利,但有一个细节引起了我的注意:apktool在解析AndroidManifest时没有报错,说明这个APK没有做高强度的混淆或者加固。现在市面上大量APP都会接加固壳(比如360壳、腾讯乐固、梆梆之类的),一旦加固,apktool虽然能解出资源,但smali/目录下的代码会变成壳的入口,真正的业务逻辑全部加密藏在assets/或lib/底下,需要先脱壳才能继续。我的运气不错,这APP没有加固,省去了脱壳这步。
另外值得记录的是lib/目录下的.so文件。我看了一下,只有libflutter.so、libapp.so这种常见库,还是分ABI架构存放的,arm64-v8a和armeabi-v7a各一份。这说明应用的核心代码还是原生Java为主,没有用Flutter或者React Native这类跨端方案,也意味着后续用jadx反编译,基本能还原大部分逻辑。
2.2 Manifest解析与应用入口
用文本编辑器直接打开解包后的AndroidManifest.xml,虽然还是XML格式,但已经是apktool解码过的可读版本。重点看几个东西:应用包名、入口Activity、权限声明。
入口Activity在<intent-filter>里配置了MAIN和LAUNCHER的那一项,这里我们看到的是com.qjx.app.module.splash.SplashActivity。顺着这个入口往下找,就能理清应用的启动链路。同时权限声明也很有信息量——INTERNET权限是必然的,漫画APP还得加载网络图片;WRITE_EXTERNAL_STORAGE说明它有缓存到SD卡的功能,而这跟后面要复现的"离线缓存"逻辑是呼应的。
这里插一句,Manifest里还能看到application节点下面的android:name,指向的是com.qjx.app.core.AppContext,这通常是应用的自定义Application类,全局初始化、网络框架、图片加载库的init都在这里完成。把这个类拎出来,是理解整个APP架构的第一步。
2.3 签名信息与工具版本校验
在做进一步反编译前,我习惯先看一眼签名信息:
apktool -s # 后面跟APK路径可以跳过反编译资源只解出签名相关文件 keytool -printcert -jarfile comic_app.apk看到的结果是v1和v2签名都有,用的是RSA算法。签名信息本身不影响反编译,但如果你想对APK做修改后重新打包——比如后面我要把复原的Demo装到手机上——就必须处理签名校验的问题。很多APP在运行时会校验自身签名,如果被篡改就直接退出,这就是传说中的"签名校验"。
这次的APP没有额外做签名校验(后面动态调试时我会验证这一点),但我在文章第5章会专门演示如何用frida去检测这类防护。对新手来说,记住一个规律:越是金融类、支付类APP,签名校验越严;漫画类、工具类APP通常很佛系,能跑就行。
3. 代码反编译:从smali到可读Java的还原路径
3.1 jadx反编译与代码可读性评估
apktool解出来的是smali汇编代码,虽然能读懂逻辑,但效率太低。我的习惯是先用jadx把APK直接还原成Java代码,能看懂的先看Java,看不懂的再回smali里抠细节。
jadx -d comic_java comic_app.apk反编译完成后的目录结构是按包名组织的。因为原APP没有做代码混淆——这一点很关键,通常在build.gradle里配了minifyEnabled false或者proguard-rules.pro基本空置——所以jadx还原出的类名、方法名都保留得相当完整。你能直接看到ChapterListActivity、ComicReaderActivity、ImageLoaderManager这种见名知意的类,整个反向过程难度直接下降了一个量级。
这里必须说一句:很多初学者拿到一个没混淆的APK就开心,但我个人经验是,这种"幸运"只是帮你省了Mapping.txt对照的时间,真正的硬骨头在于类与类之间的调用关系,也就是业务逻辑本身。混淆去掉之后你看到的是一张完整的地图,但地图上的路怎么走,还是得自己一条条捋。
拿这个项目来说,我给自己定的还原主线是:启动流程 → 首页/列表页 → 详情页 → 阅读器页 → 缓存逻辑。五条线走完,一个漫画APP的核心骨架就出来了。
3.2 启动流程与Application层还原
先看AppContext这个类。反编译后的代码长这样(这里做简化展示):
public class AppContext extends Application { @Override public void onCreate() { super.onCreate(); initNetwork(); initImageLoader(); initCache(); } private void initNetwork() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(15, TimeUnit.SECONDS); builder.addInterceptor(new HeaderInterceptor()); builder.addInterceptor(new RetryInterceptor(3)); mClient = builder.build(); } }这段代码信息量不小。第一,网络层用的是OkHttp,加了一个HeaderInterceptor和RetryInterceptor,说明它对所有请求都统一注入请求头,还有自动重试机制。第二,图片加载专门做了initImageLoader,里面大概率是Glide或Fresco的封装。第三,initCache负责初始化磁盘缓存目录——从Manifest里看到的存储权限在这里对上了。
我一开始以为这种漫画类APP会用什么比较冷门的自研网络框架,没想到就是标准OkHttp套Glide。说到底,大多数APP的架构没有那些博客吹的那么玄乎,基础组件就那么几个,区别只在业务封装层。
3.3 列表页到详情页的页面流转复原
顺着启动链路往下走,从SplashActivity可以看到它跳转到了MainActivity,而MainActivity的底部Tab是标准的四个:书城、分类、书架、我的。书城页面对应BookStoreFragment,书架对应BookshelfFragment。
我仔细跟了BookStoreFragment到ComicDetailActivity的跳转逻辑,发现它用了一个非常典型的"宿主Activity + Fragment通信"模式:点击某个漫画条目时,先通过Intent传递一个comicId(漫画ID),然后ComicDetailActivity内部根据这个ID去请求详情接口。数据层用的是RxJava 2+Retrofit的组合,这也是2018到2022年期间安卓APP最常见的组合。
在还原这个流转逻辑时,我顺手记录了一张关键类对照表,方便后面写复原Demo时参考:
| 原APP类名 | 功能定位 | 关键方法 |
|---|---|---|
| SplashActivity | 启动页 | 延迟跳转、检查更新 |
| MainActivity | 主框架 | 底部Tab切换 |
| BookStoreFragment | 书城列表 | 加载推荐位、分类入口 |
| ComicDetailActivity | 漫画详情 | 展示简介、选集、评论区 |
| ComicReaderActivity | 阅读器 | 翻页、加载章节图片 |
| DiskCacheManager | 磁盘缓存 | 管理章节图片缓存 |
这张表在后续整个复原过程中一直是主索引,我强烈建议你做同类项目时也维护一份,不然类一多,很容易迷失在包里。
4. 关键逻辑解读:缓存策略与图片加载链路
4.1 图片加载技术与翻页模式
漫画阅读类APP最核心的体验就是图片加载。这一块我花了大量时间还原,因为它直接决定复原Demo能不能达到"能用"的标准。
ComicReaderActivity里可以看到它使用的是RecyclerView+自定义LayoutManager实现的纵向滚动阅读,也有一个HorizontalPagerAdapter用于横向翻页。两个模式并存:纵向滚动和横向翻页,这两个模式是通过一个ReaderMode枚举控制的。图片的加载则统一由ImageLoaderManager封装,内部本质上是Glide:
public class ImageLoaderManager { private static volatile ImageLoaderManager instance; public void load(String url, ImageView view) { Glide.with(view.getContext()) .load(url) .diskCacheStrategy(DiskCacheStrategy.ALL) .fitCenter() .into(view); } }diskCacheStrategy(DiskCacheStrategy.ALL)很关键,意味着所有源图和变换图都会被Glide缓存到本地。理论上,只要你在手机上浏览过的漫画章节,图片都已经在应用缓存目录下了。这个细节在复原缓存逻辑时派上了大用场。
4.2 章节缓存的数据结构还原
继续往下挖,我找到了ChapterRepository这个类,它管着章节列表的拉取和缓存。缓存的数据结构大概是这样的:
public class ChapterModel { public String chapterId; public String chapterTitle; public int comicId; public int sortOrder; public String imageBaseUrl; public List<String> pageImagePaths; }看到imageBaseUrl加pageImagePaths的组合,我大概猜到它的图片URL规则:章节下的每一页图片,都是imageBaseUrl拼接上具体的pageImagePath。这种设计在漫画APP里非常普遍,跟服务端的存储结构强相关。
缓存部分用的是SQLite加本地文件的双层结构:SQLite保存章节元信息和阅读进度,图片文件本身直接用comicId/章节Id/页码.jpg的路径存到/sdcard/Android/data/<包名>/cache/reader/下。我后来在手机存储里翻到了这个目录,里面的文件结构跟我从代码里还原的完全对上了。这种"代码还原→真机验证→相互印证"的流程,是逆向工程里最让人上头的一环。
为了把缓存逻辑彻底搞清楚,我还顺带梳理了缓存的生命周期:首次进入章节时,网络请求图片并写缓存;再次进入时,先查缓存文件是否存在,存在则直接读缓存,否则回源网络。在弱网环境下,这个策略能极大节省流量,也解释了为什么我看漫画时切到飞行模式,已经打开的章节依然能看。
4.3 网络请求参数与加密还原
如果说缓存策略是骨架,那网络请求就是血液。漫画APP的接口通常有一个规律:重签名不重加密,因为图片CDN的URL本身就需要拼接。jadx里定位到HeaderInterceptor,我看到了所有请求都追加的参数:
public class HeaderInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { Request original = chain.request(); Request request = original.newBuilder() .addHeader("Device-Id", DeviceUtils.getDeviceId()) .addHeader("App-Version", "4.3.1") .addHeader("Source", "android") .addHeader("User-Agent", "qjx_manga/4.3.1") .addHeader("Timestamp", String.valueOf(System.currentTimeMillis() / 1000)) .build(); return chain.proceed(request); } }除了一些固定的标识字段外,没有看到复杂的加密签名。有一个Timestamp字段引起了我的注意,它配合服务端可以做简单的请求时效校验,但并没有用客户端私钥做签名。也就是说,攻击者可以随意伪造请求头,这在安全层面看是比较弱的。但考虑到这是一个漫画阅读APP,厂商的资源投入重心显然不在防盗链上,我更关注的反而是在线全文阅读的URL拼接规则。
拼接规则在ChapterRepository中有一段代码:
private String buildPageUrl(ChapterModel chapter, int pageIndex) { return chapter.imageBaseUrl + "/" + chapter.comicId + "/" + chapter.chapterId + "/" + pageIndex + ".jpg"; }这种URL结构非常直白,强烈依赖CND目录划分。在复原Demo里,我甚至可以直接把缓存下来的旧路径映射成这套规则,理论上能直接复现完整的"离线漫画包"体验。但这里就不展开说具体提取方式了,原因还是那个:可以自己研究学习,不教批量抓取。
5. 动态验证:用Frida给反编译结果背书
5.1 搭建frida调试环境
静态反编译拿到了大部分信息,但有几处关键分支在纯静态下无法确定——比如点击某个按钮后到底走了哪个回调、某些条件判断的值是什么。这时候就需要上动态调试。我选择的工具是frida。
环境搭建有几个关键点,容易踩坑,记录一下:
# 手机端需要安装frida-server,注意版本必须和电脑端frida完全一致 pip install frida frida-tools adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell "/data/local/tmp/frida-server &"版本不对会出现很奇怪的报错,比如unable to connect to remote frida-server或者Failed to enumerate applications,不是网络问题,纯粹是版本号没对上。建议用frida --version和手机端./frida-server --version先对比一下。
5.2 hook验证关键方法与参数
我hook的第一个目标是HeaderInterceptor:
Java.perform(function() { var Interceptor = Java.use("com.qjx.app.network.HeaderInterceptor"); Interceptor.intercept.implementation = function(chain) { var request = chain.request(); console.log("[Hook] URL => " + request.url().toString()); var headers = request.headers(); console.log("[Hook] Headers => " + headers.toString()); return this.intercept(chain); }; });运行后日志里果然输出了所有HTTP请求的URL和请求头,跟我在静态代码里看到的完全一样。这种正反馈瞬间让整个项目进入了"心流状态"——代码告诉你它做了什么,真机验证它确实这么做了,这种双重确认带来的踏实感很难描述。
第二个hook目标是阅读器的翻页回调。我通过查看当前屏幕上的View层级来定位:在ComicReaderActivity里用frida遍历View树,找到负责翻页的CustomReaderView,然后hook它的onTouchEvent和flingToNext之类的方法。这一步的意义在于确认ReaderMode切换到底走的是哪个分支——在静态代码里这处逻辑被写得比较绕,实测一下就清楚了。
5.3 一次失败的hook让我发现了缓存双写逻辑
这里记录一个有意思的插曲。我原本以为图片缓存只走Glide的磁盘缓存,于是想hookGlide的into方法验证。结果hook上去后抓到的URL和实际文件路径对不上——文件多了很多带随机后缀的临时文件。我回头翻代码,才发现ImageLoaderManager在做完Glide加载后,还调了一个copyToReaderCache方法,把图片复制了一份到阅读器专用缓存目录,并在复制完成后给SQLite插一条reader_cache记录。
这种双写策略其实是为了保证阅读器翻页时有最快的IO速度——Glide的缓存和阅读器缓存分开,互不干扰。静态分析的时候这块逻辑散落在两三个类里,很容易忽略,动态验证帮了大忙。从这里也能看出,纯粹靠jadx看代码,很多跨类的隐性逻辑是藏得住的,不动手跑一跑,你永远不知道它还有一层。
6. UI复原实战:从资源提取到可运行Demo
6.1 资源提取的关键点
代码逻辑还原得七七八八之后,我开始做UI复原。这一步听起来简单——资源文件不都解出来了吗——实际上坑很多。
apktool解出来的res/目录里有layout、drawable、values等标准结构,但很多布局大量引用了自定义View。比如漫画详情页用了RatioImageView(按宽高比自动适配的图片控件),书架页用了ShelfItemLayout(带缩放动画的网格项),这些都是写在smali里的Java代码,光有布局文件不够,必须把他们对应的类也一并"复刻"出来。
处理策略分三步:
- 列出所有自定义View类,逐个在
jadx里找对应的Java源码; - 提取布局文件的
attribute引用,确认每个自定义属性在attrs.xml中的定义; - 在Android Studio里新建一个
view包,把自定义View类重建出来,绑定自定义属性。
这里给一个实际例子。详情页里有一个BannerViewPager,光看XML布局是这样的:
<com.qjx.app.widget.BannerViewPager android:id="@+id/banner_pager" android:layout_width="match_parent" android:layout_height="wrap_content" />但它的高度不是写死的,而是根据图片宽高比动态计算。还原它需要理解它内部的OnPageChangeListener和LayoutParams计算逻辑。好在原类名没有混淆,jadx里能看到完整实现,我用一个下午把它重写成了Kotlin版本,跑起来效果与原版几乎一致。
6.2 图片资源的二重采样问题
资源提取还有一个容易栽的坑:res/drawable-nodpi目录下有大量背景图,但res/drawable-xxhdpi下的图标资源很多被压缩过。这很正常,APP为了控制包体积不会放高清原图。想在复原Demo里得到更清晰的视觉效果,可以直接从assets/目录下找有没有对应的高清素材。
这个APP的assets/目录下有chapter_place_holder.jpg、book_cover/、ad_images/等目录。其中book_cover/里的封面图分辨率明显高于res里的同名资源。这说明原APP设计上就是封面图走网络加载,本地只放一些占位图。做复原时,我直接把book_cover里的高清文件用到了Demo里,视觉效果更接近线上版本。
6.3 复原Demo的组装与联调
拿到布局、自定义View、图片素材后,下一步就是在Android Studio里搭建一个全新的工程。我把原来的包名com.qjx.app保留,只是为了方便对照代码逻辑,但整个工程是完全新建的。核心模块大体包括:
network模块:用Retrofit+OkHttp模拟接口请求,数据来源是我手动构造的本地JSON;reader模块:实现纵向滚动和横向翻页两种模式,这是整个Demo最复杂的部分;cache模块:复刻SQLite+文件的双层缓存结构;ui模块:从原APP提取并重写的所有布局和自定义View。
第一次完整跑起来的瞬间,滑动书城列表、点进详情页、打开阅读器翻页,每一步都在顺着原APP的设计走,这种感觉实在是太满足了。但马上问题也来了:有些页面动画和交互细节在静态提取时丢失了,比如书架页的item拖拽重排、阅读器底部的进度条弹出动画,这些还原起来工作量大且收益低,我决定先标记为"待优化"。
复原Demo和原APP的差异我也整理成了一张对比表:
| 功能点 | 原APP实现 | 复原Demo实现 | 差异说明 |
|---|---|---|---|
| 网络数据 | 真实接口 | 本地JSON模拟 | 不接入真实服务端 |
| 阅读模式 | 横滑+竖滑 | 两种均实现 | 动画细节略有简化 |
| 缓存结构 | SQLite+文件双写 | 同构复刻 | 几乎一致 |
| 登录体系 | 手机号+第三方登录 | 未复刻 | 涉及账号体系,无必要 |
| 广告组件 | 多广告SDK | 未复刻 | 安全性考虑,直接跳过 |
做复原项目一定要学会"划定边界"。不是所有东西都要100%还原,涉及广告SDK、支付SDK、账号体系的部分,我在项目初期就决定跳过。这不只是工作量问题,更是合规问题——逆向学习不该变成克隆别人商业逻辑的借口。
7. 复盘避坑清单:逆向反编译复原的路上,我踩过的那些坑
项目做完后,我复盘了一下,发现有一批坑几乎所有做同类项目的朋友都会遇到。挑几个典型的记录下来,希望能帮后来者少走点弯路。
7.1 坑一:jadx卡死与堆内存溢出
我在反编译这个APK时,jadx中途报过一次OutOfMemoryError,因为APP虽然没加固,但方法数不少,jadx默认的堆内存不够用。解决方法是加大JVM堆内存:
jadx -Xmx4096m -d comic_java comic_app.apk另外jadx的--deobf开关有时候会导致解析时间暴增,除非目标APP混淆得很厉害,否则我建议不开。
7.2 坑二:smali级别的改动之后回编译失败
刚开始学逆向时,我经常改了smali后回编译失败,报的错还都是什么invalid register或者bad operand type。后来才理解,smali对寄存器数量极其敏感:
# 假设你想在某个方法前插入一个日志输出 const-string v0, "TAG" invoke-static {v0}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I如果这个方法原本声明的.registers数量不够用,你就得手动调大,否则寄存器冲突会引发一连串问题。这次我在复原Demo时也复刻了一遍smali的逻辑,虽然最后是用Kotlin重写的,但调试期间还是被smali的寄存器规则虐了一顿。我的建议是:能用Java重写的就别直接改smali,除非你改的是那种原APP本身的逻辑(比如跳过某个判断),否则在smali里修修改改只会增加不必要的复杂度。
7.3 坑三:资源混淆与res目录的映射关系
有些APP启用了资源混淆,res目录下会出现大量a、b、c这种无意义名称。这次的项目没有启用,但我在另一个项目上遇到过,当时是配合APKTool的--keep-res-name加上aapt dump命令手动建立映射表才搞定的。建议所有做UI复原的朋友提前学一下aapt的资源映射用法,别等遇到了再临时抱佛脚。
7.4 坑四:签名校验的坑——不校验不代表没校验
前面我提到这个APP没有签名校验,但这里要提醒一下,验证"没有校验"本身也需要分层验证。我在frida里hook了PackageManager.getPackageInfo的调用,看有没有人在运行时请求GET_SIGNATURES权限,同时检查了所有Java层能访问签名信息的地方。结论是没有签名校验逻辑。但有些APP会把签名校验放在.so层的JNI_OnLoad里,这需要IDA追,成本就高了。对学习目的而言,做到Java层全覆盖,可信度已经相当高。
7.5 坑五:合规边界无处不在
最后必须再强调一次合规。我在本次项目中坚持了几个原则,分享给所有打算做同类项目的朋友:
- 只分析自己合法安装、自己拥有访问权限的APP;
- 不提取、不传播任何付费或需要登录才能观看的内容;
- 不改写逻辑去绕过任何付费、登录、鉴权机制;
- 复原Demo只作为学习交流用,不上架、不发布、不公开给非学习目的的使用者;
- 研究方式的重点放在"了解实现原理"上,不放在"复制分发"上。
这套原则我现在每次做逆向项目都先跟自己对一遍。不是怂,是吃过教训——技术圈里翻车的大佬太多了,没必要为了炫技搭上自己。
最后说一点个人体会。这个项目前后花了大概两个周末,过程里最折磨人的不是技术难点,而是"耐心"。逆向反编译复原本质上是一个考古过程——你面对的是一个成品,但你要从碎片里还原出设计者当初的每一个决定。为什么这里用双缓存?为什么翻页模式要分两种?为什么请求头要加这个字段?每解开一层谜,你对这个APP的理解就深一层,也对你自己的安卓技术栈多一分踏实感。如果你也在学逆向,手边又恰好有一个你天天在用的APP,那我真心建议你挑一个好好拆一遍。别贪大,先从最简单的功能模块开始,走完一遍"解包→反编译→动态验证→复原"的闭环,你会回来感谢自己的。