

APP安全测试:从客户端信息泄露到越权#
前言
最近在对某 APP 进行安全测试时,发现一个比较有意思的APP。从最开始的网络层暴力破解失败,到转向客户端逆向分析,最终发现隐藏在代码深处的逻辑后门,并成功利用报错信息泄露实现了未授权访问。
这篇文章记录了整个渗透测试的思路演变,轻喷。
免责声明:本文仅用于技术交流与安全教育。文中涉及的漏洞已提交给相关厂商修复,敏感信息均已脱敏处理。请勿利用文中技术进行非法攻击。
第一阶段:碰壁——失效的暴力破解#
起初,我试图对 APP 的登录接口进行传统的暴力破解测试。
目标接口:POST /api/password
经过观察 Burp Suite 的流量,我发现请求头中包含几个动态字段:
timestamp: 毫秒级时间戳sign: 一个 32 位的哈希值

再此登录请求的接口之前,还需要访问一个token接口生成对应的accesstoken参数。

逻辑等于是首先访问token接口,返回accesstoken参数,然后进入下一步登录的接口,携带accesstoken参数和singn参数作为登录请求的Header。
测试了一下应该是只有头部签名校验:签名只校验 Header 中的字段(如 appId, timestamp, nonce)。Body 的内容不参与签名计算。
在这种设计下,只要时间戳在有效期内,你可以随意修改 Body 里的密码,签名依然合法。
如果服务器采用的是这种策略,那么防重放机制其实是只校验了时间戳,但防篡改机制只保护了 Header,没有保护 Body。这属于 API 设计上的缺陷,正确的做法应该是连带body一起保护。

第二阶段:突破——APK 逆向与算法还原#
刚好这个APP没加壳,通过反编译 APK(使用 JADX),我开始寻找签名的生成逻辑。
1. 定位核心配置#
那么如何搜索这些特征点呢,我个人的思路是如下:
1. 搜索 HTTP 请求头中的关键词 (最有效)#
这是最快的方法。我举例如下,
刚开始我在Burp Suite 里看到了几个特殊的 Header,直接在反编译工具(如 JADX)中搜索这些字符串:
"sign"(搜字符串,注意带引号)"timestamp""Id""access-token"或"accessToken"
搜索技巧: 如果搜 "sign" 结果太多(因为这是常用词),尝试搜 "sign": (带冒号) 或者 .addHeader("sign" 。
2. 搜索网络库拦截器 (Interceptor)#
现代 Android App (90%以上) 使用 OkHttp 或 Retrofit 发送网络请求。开发者通常不会在每个页面单独写签名逻辑,而是写在一个全局拦截器里。
搜索以下关键词:
Interceptor(查看实现了这个接口的类)addHeaderchain.proceed
典型代码长这样:
Java
public class SignInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) {
Request original = chain.request();
// ... 这里就是你要找的签名逻辑 ...
String sign = MD5.encrypt(original.body() + timestamp + secret);
Request newRequest = original.newBuilder()
.addHeader("sign", sign) // 关键特征
.build();
return chain.proceed(newRequest);
}
}plain3. 搜索加密相关的关键词#
如果以上都找不到,尝试搜索通用的加密类名或方法名:
MD5(通常签名是 MD5 或 SHA256)MessageDigest(Java 原生加密类)Mac(HMAC 签名常用类)SecretKeysortedMap/TreeMap(签名通常需要对参数进行字母排序,搜索这个能找到排序逻辑)
4. 搜索 URL 路径#
直接搜索你在抓包里看到的 URL 路径片段:
"/api/token""/dPassword"
找到定义这些 URL 的地方,通常顺藤摸瓜就能找到是谁在使用它们,以及使用前做了什么处理
通过搜索抓包中看到的 clientId 字符串,我迅速定位到了一个名为 Getsign 的配置文件。:
public final String getSign(String timestamp, boolean z) {
String str;
// 1. 关键判断:z 代表是否有 Token (是否已登录)
if (z) {
// 情况 A:有 Token (用于业务接口)
// 拼接规则:[密钥] + [Token] + [时间戳]
str = "xxxxxxxxxxxxxx"
+ UserManager.INSTANCE.getLoginModel().getAuth().getToken()
+ timestamp;
} else {
// 情况 B:无 Token (用于获取 Token 的接口)
// 拼接规则:[密钥] + [时间戳]
str = "xxxxxxxxxxxxxx"
+ timestamp;
}
// 2. 算法选择:MD5
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
byte[] bytes = str.getBytes(Charsets.UTF_8);
byte[] digest = messageDigest.digest(bytes);
// 3. 格式化:转为 32位 Hex 字符串
String format = String.format("%032x", Arrays.copyOf(new Object[]{new BigInteger(1, digest)}, 1));
// 4. 大写转换:转为大写
return format.toUpperCase(Locale.ROOT);
}java2. 还原签名算法#
接着,通过搜索 .addHeader("sign" 关键字,我在 App.kt 中找到了签名的计算逻辑:
// App.kt 伪代码
fun getSign(timestamp: String, hasToken: Boolean): String {
val raw = if (hasToken) {
FULL_KEY + token + timestamp
} else {
FULL_KEY + timestamp
}
return MD5(raw).toUpperCase()
}plain算法逻辑明确了: Sign = MD5(SecretKey + [Token] + Timestamp)。
有了这个算法和密钥,我编写了一个 Python 脚本,能够实时生成合法的签名。
第三阶段:发现——代码中的逻辑后门#
在审计 App.kt 的网络拦截器(Interceptor)代码时,一段奇怪的逻辑引起了我的注意:

// 拦截器逻辑
if (url.contains("/queryUser")) {
//高危逻辑:如果是查询用户接口,强制将 userid 设为 system1
request.addHeader("userid", "system1");
} else {
request.addHeader("userid", currentUser.uid);
}plain漏洞点分析:
这意味着有一个接口 /XXX/queryUser,它大概率是用来获取用户列表的。且服务器可能存在逻辑漏洞:只要 Header 里的 userid 是 system1 ,它就允许查看所有数据,而不校验 Token 是否真的是管理员的。
由于 API 签名(Sign)和时间戳(Timestamp)是动态的,我们需要先生成一个合法的token和sign,然后修改userid信息来测试system1发送请求。
第四阶段:利用——报错信息泄露与 Fuzzing#
我利用 Python 脚本获得了新的sign信息,目标直指 /queryUsers 接口,并在 Header 中伪造了 userid: system1。
1. Fuzz1#
我发送了一个空的 JSON Body {}。

服务器响应:
{
"code": 500,
"message": "请输入PageSize",
"result": null
}plain突破口!
- 服务器没有返回“403 Forbidden”或“权限不足”,说明伪装
system1成功绕过了鉴权! - 服务器报错信息泄露了必需参数名:
PageSize。
2. Fuzz2#
既然知道了 PageSize,肯定是代表某个页码,根据开发经验,分页参数通常是成对出现的。
在编程开发中(特别是 Java 后端),分页永远需要两个参数:
- 一页显示多少条 (刚刚服务器泄露了,叫
PageSize) - 当前是第几页 (这个我们需要猜)
既然参数1叫 PageSize,那么参数2通常会遵循相同的命名风格。根据经验,常见的组合只有以下几种:
pageSize+pagepageSize+pageIndex(常见于 C# 或某些前端框架)pageSize+currentpageSize+pageNum(常见于 Java 的 PageHelper 插件)
虽然报错信息显示的是 PageSize (大写开头,PascalCase),但这个 App 是安卓应用,后端大概率是 Java (Spring Boot)。 Java 的标准变量命名规范是 小驼峰 (camelCase) ,即首字母小写。

我构建了一个 Fuzzing 列表来猜测另一个参数:
{"PageSize": 10, "Page": 1}-> 失败{"pageSize": 10, "page": 1}-> 失败{"pageSize": 10, "pageIndex": 1}-> 失败{"pageSize": 10, "pageNum": 1}-> 成功 (200 OK)
服务器返回了:"code": 0, "message": "接口调用成功",并吐出了大量的用户列表数据。

第五阶段:危害扩大#
通过这个漏洞,我编写了自动化脚本,利用 pageNum 遍历,成功拉取了全量的用户信息。

泄露数据包括:
- 真实的用户 ID (
userId) - 用户昵称
- 手机号 (部分脱敏)
后续危害链 (Kill Chain):
- 精准撞库:拥有了 100% 准确的用户 ID 列表后,配合弱口令(如 123456)进行撞库,成功率极高。

总结与防御建议#
这次实战展示了一个典型的 API 漏洞组合拳:硬编码密钥 + 签名校验逻辑缺陷+APP后门。
给开发者的防御建议:#
- 严禁硬编码密钥:
- 不要将
Client Secret写死在客户端代码中,APK 反编译有成本,真遇到高手了也是秒了。 - 建议使用动态密钥交换或将签名逻辑放在服务端(如网关层)。
- 不要将
- 修复鉴权逻辑 (Broken Access Control) :
- 永远不要相信客户端提交的 UserID。
- 后端必须从验证过的
AccessToken中解析用户身份(Subject),以此作为权限判断的依据。Header 里的userid只能作为参考,不能作为凭证。
- 统一错误处理:
- 生产环境应屏蔽详细的错误堆栈和具体的参数提示,统一返回模糊的错误码,防止攻击者利用报错信息进行 Fuzzing。
- API 签名不是万能药:
- 签名只能保证传输安全,不能保证业务逻辑安全。不要因为有了签名就忽略了越权检测。