11、认证与授权漏洞:本地认证绕过、Token泄露、JWT安全
认证和授权,说白了就是「你是谁」和「你能干什么」。这两个东西一旦出问题,整个应用的安全防线基本就形同虚设了。我这些年做安全测试,碰到最多的高危漏洞,十有八九都跟这俩有关。今天咱们就聊聊Android端常见的认证授权漏洞,包括本地认证绕过、Token泄露,还有JWT的那些坑。
11.1 本地认证绕过:别信客户端
很多App为了用户体验,会在本地存一些认证状态。比如你输过一次密码,下次打开就不用再输了。这个设计本身没问题,但实现方式经常翻车。
- 把「是否已认证」存成SharedPreferences里的一个布尔值
- 用本地SQLite数据库存明文密码或弱加密的PIN码
- 在本地文件里写死一个「万能密码」用于调试
我记得有一次测试一个金融类App,它的指纹支付功能有个回退方案——如果指纹验证失败三次,就让你输入PIN码。我反编译一看,好家伙,PIN码校验逻辑全写在native层的一个so文件里,而且校验结果直接返回一个整型值。我patch了一下返回值,每次调用都返回1,PIN码随便输都能过。这就是典型的本地认证绕过。
检查App中所有涉及「认证通过/失败」的逻辑点。重点关注:
- 认证结果是否在客户端本地判断
- 是否有硬编码的密钥或密码
- 生物识别回调是否被正确实现(比如fingerprint认证的callback里有没有做fallback校验)
// 一个典型的脆弱实现示例
// 千万别这么写!
public class LocalAuthActivity extends AppCompatActivity {
private SharedPreferences prefs;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
prefs = getSharedPreferences("auth_prefs", MODE_PRIVATE);
// 检查本地存储的认证状态
boolean isAuthenticated = prefs.getBoolean("is_logged_in", false);
if (isAuthenticated) {
// 直接跳转到主界面
startActivity(new Intent(this, MainActivity.class));
} else {
// 显示登录界面
showLoginScreen();
}
}
}
你想想看,这个is_logged_in存在本地,攻击者只要有root权限或者能访问到App的数据目录,直接改成true就绕过去了。我建议的做法是:本地只存一个加密的session token,真正的认证状态必须由服务端验证。
11.2 Token泄露:藏在哪都不安全
Token就像你家的钥匙,丢了就得换锁。Android端Token泄露的途径比你想的多得多。
| 泄露途径 | 风险等级 | 常见场景 |
|---|---|---|
| SharedPreferences明文存储 | 高 | 很多App直接把access_token存成明文 |
| 日志输出 | 中高 | 开发阶段忘了删Log.d("token", token) |
| WebView拦截 | 高 | 通过shouldInterceptRequest拦截请求头 |
| 剪贴板 | 中 | 用户手动复制token到其他App |
| 第三方SDK | 高 | 统计、推送SDK可能上传请求数据 |
我个人习惯,Token存储首选Android的EncryptedSharedPreferences,或者用KeyStore配合EncryptedFile。如果Token是短期有效的(比如15分钟),用内存缓存也行,但别往磁盘上写。
我曾经见过一个App,把Token存在了getExternalFilesDir()目录下的一个JSON文件里。这个目录其他App虽然不能直接访问,但只要手机连上电脑开了MTP模式,或者用户装了文件管理器,Token就暴露了。嗯,这里要注意:外部存储永远不要存敏感信息。
11.3 JWT安全:签名别乱验
JWT(JSON Web Token)现在几乎是移动端认证的标配了。但JWT本身只是一套规范,实现得好不好,差别大了去了。
11.3.1 签名算法混淆攻击
这个坑我踩过。JWT支持多种签名算法,比如HS256(对称)和RS256(非对称)。有些服务端实现会从JWT的header里读取alg字段来决定用什么算法验证。攻击者把alg改成none,或者把RS256改成HS256,然后用公钥(如果是公开的)当密钥去签名——服务端如果没做算法白名单校验,直接就过了。
// 一个被篡改的JWT示例
// header: {"alg":"none","typ":"JWT"}
// payload: {"sub":"admin","role":"admin","iat":1516239022}
// signature: (空)
// 服务端如果这样验:
// jwt.verify(token, secret, { algorithms: ['HS256', 'RS256', 'none'] });
// 那攻击者把alg改成none,signature留空,就能伪造任意身份
服务端必须显式指定允许的算法列表,并且拒绝alg: none的token。我建议只使用RS256或ES256这类非对称算法,私钥放服务端,公钥下发到客户端。
11.3.2 JWT的存储与传输
JWT本身是base64编码的,不是加密的。payload里的信息任何人都能解码看到。所以千万别把密码、身份证号这类敏感信息放进去。
传输时一定要走HTTPS,这个不用多说。但有个细节:很多App在每次请求的Header里带JWT,如果某个请求被中间人抓到了,Token就泄露了。我建议加上jti(JWT ID)字段,服务端维护一个已吊销的jti列表,这样即使Token泄露,也能及时失效。
11.3.3 JWT的过期与刷新
access_token过期时间设短一点(15-30分钟),refresh_token可以设长一点(7天)。但refresh_token的存储要更小心,因为它能换新的access_token。
我见过一个设计:refresh_token的有效期是30天,但服务端没有做refresh_token的轮换(rotation)。也就是说,同一个refresh_token可以反复使用。攻击者只要拿到一次,就能在30天内随意刷新。正确的做法是:每次使用refresh_token时,服务端返回一个新的refresh_token,旧的立即失效。
// 服务端刷新Token的伪代码(推荐做法)
function refreshToken(oldRefreshToken) {
// 1. 验证oldRefreshToken是否有效且未过期
// 2. 检查oldRefreshToken是否在已使用列表中(防止重放)
// 3. 生成新的access_token和新的refresh_token
// 4. 将oldRefreshToken标记为已使用
// 5. 返回新的token对
}
11.4 知识体系总览
下面这张图把本章的核心逻辑串起来了。你可以看到,认证和授权漏洞主要围绕三个环节:客户端存储、网络传输、服务端验证。任何一个环节出问题,整个信任链就断了。
11.5 实战测试清单
最后,我整理了一份测试清单,你可以在做安全测试时逐项检查:
- 本地存储检查:用adb backup或直接读data目录,看Token、密码、PIN码是否明文存储
- 认证逻辑逆向:反编译App,找到认证相关的判断逻辑,看是否能通过patch绕过
- 网络抓包:用Burp Suite或Charles抓取所有请求,检查Token是否出现在URL、Header、Body中
- JWT解码:拿到Token后去jwt.io解码,检查payload里是否有敏感信息
- 算法测试:尝试修改JWT的alg为none,或者将RS256改为HS256,看服务端是否拒绝
- 重放测试:截获一个有效的Token,过一段时间后再发送,看是否仍然有效
- 刷新逻辑测试:多次使用同一个refresh_token,看服务端是否允许
做认证授权测试时,别只盯着正向流程。多想想「如果这里出错了会怎样?」。比如指纹认证失败后的fallback逻辑、Token过期后的刷新流程、多设备登录时的踢下线机制——这些边缘情况往往是漏洞的高发区。
好了,认证与授权这块就聊到这儿。记住一句话:永远不要信任客户端。所有关键的认证决策,必须由服务端来做。客户端只负责安全地存储和传输凭证,仅此而已。