从JS逆向到任意用户登录:一次真实漏洞挖掘的全记录

UID:5 游客组

一、从一个登录框开始

说实话,挖漏洞这事挺看运气的。有时候对着一个登录框看半天,什么也发现不了;有时候随手点几下,漏洞就自己跳出来了。

去年下半年参加某个众测项目,目标系统就是一个典型的"开局一个框"------一个看起来平平无奇的登录页面,输入用户名密码,点登录。没有验证码,没有图形校验,甚至没有登录次数限制。这种系统按道理说应该很容易被爆破,但我试了几轮常用密码,都没进去。

于是我决定换个思路:先不看登录功能本身,去看看它背后都调用了哪些接口。

二、前端源码是第一手情报

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 — 认证头的构造方式

  • clientIdappIdsecret — 客户端凭证

  • btoaatob — Base64编解码

  • encryptdecrypt — 加密相关函数

搜了一圈,在代码的中间部分发现了这样一段:

javascript

var o = "client_123456"; var c = "app_789012"; var Authorization = 'Basic ' + btoa(''.concat(o, ':').concat(c)); 

看到这行代码的时候,我心里基本就有数了。Basic后面跟的Base64字符串,是HTTP Basic认证的标准格式。而clientIdappId这两个值,直接就硬编码在前端代码里了。

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去调了一下,果然返回了一长串用户列表。

到这里,整个攻击链就通了

  1. 从JS文件里提取clientId和appId

  2. 构造Basic认证头获取token

  3. 用token调用户列表接口获取userId

  4. 用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这个库的,代码里一定会有setPublicKeyencrypt这两个方法。

第二种:XHR断点法

这个方法更精准,但需要一点耐心。

  1. 打开开发者工具,切到Sources标签

  2. 在右侧找到XHR/fetch Breakpoints,点加号

  3. 输入login(或者你要抓的接口关键字)

  4. 在页面上点击登录按钮

  5. 代码会在发送请求前断住,然后在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发送到攻击者的服务器上。

这个漏洞的关键点在于:

  1. 评论区有XSS,可以注入任意代码

  2. App没有对Schema协议的调用做域名白名单校验

  3. JSBridge暴露了敏感API且没有做权限控制

这三环缺一不可,但环环相扣,就构成了一个高危漏洞。

六、不只是登录框:前端信息泄露的多米诺骨牌效应

6.1 JS里的SQL语句

前端的价值不只是找认证漏洞。有时候,JS文件里直接藏着SQL语句。

在一次攻防演练中,登录页面的JS文件里有一段代码,里面拼接了一个SQL查询。顺着这个线索,我找到了一个未授权的接口,直接在这个接口的URL后面加上?id=1',居然触发了SQL报错。最终通过SQL注入拿到了数据库权限,从数据库服务器横向移动,打进了内网,拿到了靶标系统。

这个案例给我的教训是:JS里的接口越多,暴露的攻击面就越大。不要只盯着登录功能,接口文档里列出来的每一个API,都可能是突破口。

6.2 接口未授权是重灾区

就我的观察,接口未授权的问题比想象中普遍得多。很多系统的前端页面做了权限控制,不登录看不到某些按钮。但后端接口根本没做验证,只要知道了接口地址,直接就能调。

怎么找接口?几个路子:

  1. JS文件里直接搜/api/.do.action/v1//v2/

  2. 用工具提取:Burp的Target → Site map,右键 → Engagement tools → Find references。或者用findsomething这个插件,自动从JS里提取接口

  3. 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的问题,而是成本和收益的博弈。你的防护措施不需要做到攻不破------世界上没有攻不破的系统------只需要做到让攻击者觉得,花时间去破解你的系统不如去找个更容易的目标。

最后老生常谈一句:本文提到的所有技术手法,请在授权范围内使用。未经授权对他人系统进行测试,是违法的。咱们搞技术的,要有底线。

最新回复

请先登录后再回复 登录

发帖 0
评论 0
粉丝 0
关注 0
发新帖
目录
从JS逆向到任意用户登录:一次真实漏洞挖掘的全记录