一、从一个登录框开始
说实话,挖漏洞这事挺看运气的。有时候对着一个登录框看半天,什么也发现不了;有时候随手点几下,漏洞就自己跳出来了。
去年下半年参加某个众测项目,目标系统就是一个典型的"开局一个框"------一个看起来平平无奇的登录页面,输入用户名密码,点登录。没有验证码,没有图形校验,甚至没有登录次数限制。这种系统按道理说应该很容易被爆破,但我试了几轮常用密码,都没进去。
于是我决定换个思路:先不看登录功能本身,去看看它背后都调用了哪些接口。
二、前端源码是第一手情报
2.1 抓包发现端倪
先把Burp Suite打开,挂上代理,在登录页面随便输入一个账号密码点登录。拦截到的请求包没什么特别的,就是一个标准的POST /login,参数是username和password。
但我在看返回包的时候发现了一个细节:登录成功后,前端会请求一个/js/main.bundle.js的文件。这个文件很大,有几百KB,明显是webpack打包后的产物。
经验之谈:看到这种打包后的JS文件,千万别嫌大就跳过。恰恰是因为打包了,很多开发者会把敏感信息也一起打进去------接口地址、认证方式、甚至有时候会留下完整的加密逻辑。
我把这个JS文件保存下来,用Chrome的开发者工具打开,点了一下左下角的"{}"格式化按钮。看着格式化后的代码,虽然变量名都被混淆成了a、b、c这种单字母,但通过搜索一些关键词,还是能挖出不少东西。
2.2 搜索关键词锁定目标
在格式化后的JS里搜索几个常见的关键词:
-
/api/— 大概率能找到API接口地址 -
Authorization— 认证头的构造方式 -
clientId、appId、secret— 客户端凭证 -
btoa、atob— Base64编解码 -
encrypt、decrypt— 加密相关函数
搜了一圈,在代码的中间部分发现了这样一段:
javascript
var o = "client_123456"; var c = "app_789012"; var Authorization = 'Basic ' + btoa(''.concat(o, ':').concat(c)); 看到这行代码的时候,我心里基本就有数了。Basic后面跟的Base64字符串,是HTTP Basic认证的标准格式。而clientId和appId这两个值,直接就硬编码在前端代码里了。
2.3 验证Token生成逻辑
既然搞清楚了认证头的构造方式,接下来就是验证了。直接在浏览器控制台执行:
javascript
btoa('client_123456:app_789012') 得到了一串Base64编码。然后我用Burp构造了一个请求,目标是/auth/oauth/token接口(这个接口也是在JS文件里搜到的),加上Authorization: Basic <刚才生成的Base64>。
结果返回了一个access_token。这里要特别说明一下:这个token不是针对某个用户的,它是一个客户端凭证模式的token,用来证明"我这个前端应用是合法的"。有了这个token之后,再去调用其他业务接口,后端就认了。
然后我翻了翻JS文件里其他地方的代码,发现有一个登录接口是/api/user/login,它接受一个叫userId的参数。也就是说,只要我知道了某个用户的userId,直接拿着上面拿到的token去调这个接口,就能以那个用户的身份登录进去。
那么问题就变成了:怎么拿到其他用户的userId?
我又在JS文件里搜了一下,发现有个/api/user/list接口,返回所有注册用户的基本信息,包括userId和手机号。我试着用刚才的token去调了一下,果然返回了一长串用户列表。
到这里,整个攻击链就通了:
-
从JS文件里提取clientId和appId
-
构造Basic认证头获取token
-
用token调用户列表接口获取userId
-
用userId调登录接口实现任意用户登录
三、当加密参数挡住去路时
3.1 抓到加密参数了
上面那个案例是运气好,遇到了硬编码凭证的情况。但大多数系统没这么简单。
另一次测试中,我遇到的是一个需要登录的教育系统。打开登录页面,随便输个账号密码点登录,抓包一看:
text
POST /api/login HTTP/1.1 Host: target.edu.cn Content-Type: application/json {"username":"admin","password":"8a9f3c2e1b6d4f7a"} password字段是一串看起来像十六进制的乱码,显然是前端加密过的。
这意味着,如果我直接用Burp的Intruder去爆破,传过去的明文密码后端根本不认。我必须先搞清楚加密算法是什么,然后在爆破的时候先把字典里的密码加密,再发送。
3.2 定位加密函数的几种路子
找前端加密函数,说难不难,说简单也不简单。我一般按这个顺序来:
第一种:全局搜索关键词
在开发者工具的Sources面板里,按Ctrl+Shift+F全局搜索:
-
encrypt -
password -
RSA -
AES -
setPublicKey -
JSEncrypt
很多系统用的是开源的加密库,函数名和变量名都会有明显的特征。比如用JSEncrypt这个库的,代码里一定会有setPublicKey和encrypt这两个方法。
第二种:XHR断点法
这个方法更精准,但需要一点耐心。
-
打开开发者工具,切到Sources标签
-
在右侧找到XHR/fetch Breakpoints,点加号
-
输入
login(或者你要抓的接口关键字) -
在页面上点击登录按钮
-
代码会在发送请求前断住,然后在Call Stack里往上翻,就能找到调用加密函数的位置
第三种:Network面板追溯
在Network面板里找到登录请求,右键 → Copy → Copy as fetch。然后在Console里粘贴执行,再从调用栈往上找。这个方法在加密逻辑比较靠前的时候很好用。
3.3 还原RSA加密算法
那次遇到的教育系统,我用了XHR断点法,很快就在调用栈里找到了关键代码:
javascript
var rsa = new JSEncrypt(); rsa.setPublicKey(modulus, exponent); var encryptedPassword = rsa.encrypt(password); modulus是一个超长的十六进制字符串,exponent是"10001"。这是标准的RSA加密,公钥直接硬编码在前端。
有了这两个值,我就可以用Python把加密逻辑复现出来。搜索资料后,用cryptography库这样写:
python
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.backends import default_backend import binascii def rsa_encrypt(plaintext, modulus_hex, exponent_hex): n = int(modulus_hex, 16) e = int(exponent_hex, 16) public_numbers = rsa.RSAPublicNumbers(e, n) public_key = public_numbers.public_key(default_backend()) ciphertext = public_key.encrypt( plaintext.encode('utf-8'), padding.PKCS1v15() ) return binascii.hexlify(ciphertext).decode() 把前端里的modulus和exponent填进去,测试了一下"123456"加密后的结果,跟前端生成的一模一样。
接下来就简单了:把爆破用的密码字典跑一遍这个加密脚本,输出加密后的值,再用Burp的Intruder配合Payload Processing功能,就可以愉快地爆破了。
跑了大概十分钟,成功爆破出一个管理员的账号。登录后台之后发现,这个系统里有几万条学生的个人信息。
3.4 有时候不用这么麻烦
不过话说回来,有时候你可能根本不需要完整复现加密算法。
在一次测试中,我遇到了一个更简单的情况:加密函数就在全局对象里。打开控制台,输入window.encrypt,直接返回了一个函数。这种情况下,你只需要在Burp里调用这个函数就行了。
具体操作是:Burp Intruder → Payload Processing → Add → Invoke a JavaScript function。把前端加密函数的代码复制进去,设置好输入输出,剩下的就交给Burp自动处理。
四、移动端的JS逆向
4.1 小程序的路更难走
现在很多系统都做了小程序版本。小程序的特点是:前端代码被封装在wxapkg文件里,不像Web端那样直接用浏览器就能看。
要搞小程序的JS,第一步是拿到源码。这个过程有点灰色,但技术上说,确实有办法拿到。拿到wxapkg之后,用工具解包,就能看到一堆JS文件。
抓包方面,小程序默认不走系统代理,需要用Proxifier或者调整WiFi代理设置才能抓到。抓到包之后的分析思路,跟Web端基本一样。
有一次我就是这么干的:解包一个小程序,在一个JS文件里发现了一个叫getUserInfo的接口,参数是userId。然后又发现另一个接口返回所有用户的userId列表。两个接口都没做鉴权,直接就能调。最后拼在一起,又是一个任意用户登录。
4.2 小程序的动态调试技巧
小程序的JS逆向比Web端麻烦的地方在于:你不能直接在浏览器里打断点。
有个小技巧:在小程序的开发者工具里开启"不校验合法域名"选项,然后用Whistle或Charles做代理,把小程序请求的JS文件替换成本地修改过的版本。这样你就可以在本地JS里加上debugger语句,或者console.log输出关键变量。
这个方法虽然有点折腾,但确实管用。
五、有时候漏洞藏在更深处
5.1 APK里的JS文件
小程序之外,React Native写的App也是一块宝地。这类App会把JS代码打包进APK里,放在assets/index.android.bundle这个文件里。
拿到这个bundle文件之后,用工具解包(搜一下"react-native unbundle"就能找到方法),就能看到接近原始的JS代码。
有一次测试,我就是从一个旧版本的APK里解出来的bundle文件中,发现了一个直接返回用户Token的接口。只需要在请求头里加上User-Id: 任意数字,后端就返回这个用户的有效Token。没有验证,没有签名,就这么简单粗暴。
这件事给我的启发是:不要只看最新的App版本。有时候开发者在新版本里修复了漏洞,但旧版本还放在应用商店的某个角落,或者被第三方网站缓存着。拿到旧版本的APK,说不定有意外收获。
5.2 so文件里的硬编码Token
比JS更隐蔽的是Native代码。有些App会把关键逻辑写到C/C++层,编译成.so文件。但这种做法有时候反而更危险------因为有些开发者会认为"放到so文件里就安全了",然后直接在代码里硬编码密钥和Token。
提取.so文件里的字符串,用strings命令就够了:
bash
strings libapp.so | grep -E "eyJ|token|secret" 在一次测试中,我就是用这个方法,从一个APK的lib/arm64-v8a/libapp.so文件里,直接找到了几个有效的JWT Token。把这些Token复制出来,放在请求头里,就能直接登录对应用户的账号。
更离谱的是,有些Token的有效期长达一年。
5.3 Schema协议调用漏洞
移动端的攻击面不止于代码逆向。还有一种常见的漏洞是Schema协议调用。
简单解释一下:很多App会注册自定义的URL Scheme,比如myapp://,用来实现App内页面跳转或者从浏览器唤起App。如果这个机制没做好防护,就可能被利用。
一个真实的案例:某App的评论区存在存储型XSS漏洞。攻击者在评论里植入了一段恶意代码,内容是a://xxx.com/c?content=<script>a.getUserToken()</script>。其他用户点击这条评论的时候,App会通过Schema协议加载这个恶意链接,执行里面的JS代码,调用JSBridge的getUserToken方法,把用户的Token发送到攻击者的服务器上。
这个漏洞的关键点在于:
-
评论区有XSS,可以注入任意代码
-
App没有对Schema协议的调用做域名白名单校验
-
JSBridge暴露了敏感API且没有做权限控制
这三环缺一不可,但环环相扣,就构成了一个高危漏洞。
六、不只是登录框:前端信息泄露的多米诺骨牌效应
6.1 JS里的SQL语句
前端的价值不只是找认证漏洞。有时候,JS文件里直接藏着SQL语句。
在一次攻防演练中,登录页面的JS文件里有一段代码,里面拼接了一个SQL查询。顺着这个线索,我找到了一个未授权的接口,直接在这个接口的URL后面加上?id=1',居然触发了SQL报错。最终通过SQL注入拿到了数据库权限,从数据库服务器横向移动,打进了内网,拿到了靶标系统。
这个案例给我的教训是:JS里的接口越多,暴露的攻击面就越大。不要只盯着登录功能,接口文档里列出来的每一个API,都可能是突破口。
6.2 接口未授权是重灾区
就我的观察,接口未授权的问题比想象中普遍得多。很多系统的前端页面做了权限控制,不登录看不到某些按钮。但后端接口根本没做验证,只要知道了接口地址,直接就能调。
怎么找接口?几个路子:
-
JS文件里直接搜:
/api/、.do、.action、/v1/、/v2/ -
用工具提取:Burp的Target → Site map,右键 → Engagement tools → Find references。或者用findsomething这个插件,自动从JS里提取接口
-
Swagger/OpenAPI文件:很多系统的
/swagger-ui.html、/v2/api-docs、/openapi.json没关,直接就能看到所有接口定义
找到接口之后,挨个试。重点关注那些带参数的:/getUser?id=1、/detail?userId=123这种。改一下ID的值,看看能不能拿到别人的数据。
我在一个系统里就是这么干的:先从JS里发现了一个/api/order/detail?id=接口,测试发现没做鉴权,直接遍历id拿到了几千条订单信息。订单信息里有手机号和姓名,拿着手机号去调另一个/api/user/info?phone=接口,又能拿到这个人的详细资料。一层套一层,最后拿到了这个系统的全部用户数据。
6.3 接口的排列组合
有时候一个接口本身没漏洞,但多个接口组合起来就有问题了。
比如一个系统有这两个接口:
-
/api/order/list:返回订单列表,不包含敏感信息 -
/api/order/detail:根据订单ID返回详情,包含用户手机号、地址等
如果/api/order/list没鉴权,攻击者拿到一堆订单ID;然后/api/order/detail也没鉴权,攻击者就能挨个查详情。两个接口拆开看都不算严重(最多算信息泄露),但组合起来就是严重的数据泄露。
七、说说怎么防
说了这么多攻击手法,还是得提一下防御。毕竟搞安全不能只懂攻不懂防。
7.1 前端代码层面
-
不要把任何密钥、凭证、Token硬编码在前端。前端没有秘密,任何放在前端的东西都是公开的。
-
敏感接口不要在前端直接调用。用户的userId、权限信息应该由后端从会话里获取,而不是前端传过来。前端传什么就信什么,那是十年前的做法。
-
JS代码尽量做混淆。虽然混淆挡不住真正的攻击者,但能提高门槛,过滤掉一批脚本小子。而且混淆后的代码里搜关键词没那么容易,至少能让攻击者多花点时间。
-
敏感接口做好权限校验。这是后端的事,但前端开发者也要有这个意识------不是页面里没显示某个按钮,攻击者就不知道这个接口的存在。
7.2 接口层面
-
重要的接口必须做用户身份校验,不能只依赖一个前端传过来的userId参数。
-
给API加上访问频率限制,防止爆破和遍历。
-
Swagger等API文档在生产环境一定要关掉。见过太多生产环境开着
/swagger-ui.html的系统了。 -
使用HTTPS,防止中间人攻击。不过说实话,到了JS逆向这一步,HTTPS的作用已经不大了。
7.3 App层面
-
JSBridge API要做权限控制,不是任何H5页面都能随意调用敏感API。
-
Schema协议要做好白名单校验,只允许可信的域名和协议。
-
WebView要配置安全策略,禁用不必要的JavaScript执行,设置CSP。
-
敏感信息不要放在so文件里。strings命令一跑就出来了,藏不住的。
-
旧版本的App要及时下架,或者强制用户升级。
7.4 方法论层面的建议
-
把前端当作攻击者的第一手资料来对待。在安全评审时,假设攻击者已经完整拿到了所有前端代码(JS、CSS、HTML),在这个前提下评估系统的安全性。
-
做威胁建模时,把"前端代码被逆向"作为一条攻击路径,想清楚如果这条路径被走通,系统还能不能守住。
八、写在最后
说来说去,其实核心就一句话:前端没有秘密。
不管是Web端、小程序、还是App,最终用户拿到的就是一坨代码。这坨代码可以被格式化、被搜索、被调试、被修改。你把加密逻辑放在前端,攻击者就能把它抠出来;你把密钥写在前端,攻击者就能拿走它;你相信前端传过来的数据,攻击者就能伪造它。
但这并不意味着前端代码就没法保护了。混淆、加固、动态加载,这些手段都能增加攻击者的时间成本。安全从来不是0和1的问题,而是成本和收益的博弈。你的防护措施不需要做到攻不破------世界上没有攻不破的系统------只需要做到让攻击者觉得,花时间去破解你的系统不如去找个更容易的目标。
最后老生常谈一句:本文提到的所有技术手法,请在授权范围内使用。未经授权对他人系统进行测试,是违法的。咱们搞技术的,要有底线。