这个标题里的时间戳,是我某次授权渗透测试项目的台账编号,恰好翻到它时,项目里连续遇到了两个“任意账户接管”级别的小程序漏洞。那段时间基本就是白天写报告、晚上复盘利用链,最后把这两个案例归拢出来,才发现它们其实指向同一类根因:服务端过度信任了客户端传来的身份信息。这篇文章就围绕这两个案例展开,讲讲我是怎么发现它们、怎么定位根因、又该怎么从开发侧彻底堵住的。如果你正在做小程序安全测试,或者你是小程序开发者想了解这类高危漏洞的成因和修法,这篇应该能帮上忙。
1. 授权测试现场的前置工作与小程序高危面梳理
1.1 小程序为什么容易出现账户接管类漏洞
我测试过不少Web站点,也测过一些原生App,坦白说,小程序在账户接管这个问题上的“中招率”比传统Web系统要高出不少。原因并不复杂:小程序的业务逻辑几乎都集中在后端接口上,前端只是个薄薄的壳,一旦前端壳被拆开、接口被枚举出来,整个系统的攻击面就完全暴露在测试者眼前。
小程序的代码包就是一组wxapkg文件,拿到手之后可以直接解包,JavaScript逻辑基本是明文或者半混淆状态。很多开发者以为做了代码压缩就算安全了,其实接口路径、参数名、甚至一些硬编码的密钥都能从JS里翻出来。我在拿到测试目标后第一件事就是解包,把里面所有的接口路径、自定义header、登录态存储方式全部拉一个清单,这个清单基本决定了后续测试的覆盖范围。
另外,小程序普遍采用微信授权登录的链路:前端通过wx.login拿到code,传给后端,后端再用code换openid,然后返回自定义会话凭证。这里就产生了几类典型问题:有的后端把openid直接当用户身份主键,有的后端把openid和自增user_id混着用,有的后端在登录态里塞了一堆可预测的参数。这些设计上的混乱,往往是账户接管漏洞的第一现场。因为账户接管本质上不是某一个接口的问题,而是“身份标识体系”在某个环节被绕过了。
1.2 测试前的准备清单
正式开测之前,我通常会把下面这些基础工作做完,省得测到一半发现环境不对,又要倒回来补。
- 确认测试授权范围:明确哪些小程序、哪些域名、哪些账号在测试范围内,并保留授权文件。
- 准备两个以上测试账号:账户接管测试最常用的方法是对照实验,至少需要A、B两个账号才能验证越权是不是真的存在。我一般会准备两个手机号、两台测试设备,方便模拟不同身份。
- 搭建抓包环境:小程序抓包比Web要麻烦一点,因为小程序有内置的证书校验,直接挂代理经常抓不到HTTPS流量。我通常用开发者工具开启真机调试模式,配合代理工具把流量导出来。这一步的核心目的只有一个:看清每个业务请求到底发了什么参数出去,而不是只看页面UI。
- 解包小程序代码:把wxapkg解包,检索接口路径、敏感字段、加密逻辑、登录态处理方式。这一步能帮我提前画出接口地图。
- 准备一个测试手机号池:接收验证码用的号码要能随时换,尤其测“绑定手机号”这类逻辑的时候,一个号不够用。
这些准备工作做完,基本就可以进入接口测试阶段了。我接触到的小程序项目里,真正复杂的漏洞往往不是那种需要绕WAF、拼内存马的高阶攻击,而是藏在业务逻辑里的低级错误。下面这两个案例,就是典型的“低级但致命”。
2. 案例一:验证码校验与业务操作分离,手机号绑定被直接篡改
2.1 漏洞现场:一个“更换手机号”功能的奇怪表现
第一个案例是某电商类小程序。这个小程序有完整的用户中心,支持更换绑定手机号。正常流程是这样的:用户先输入新手机号,点“获取验证码”,收到短信后填入验证码,再点“确认更换”,后端校验通过后把账号绑定的手机号换成新号码。
我在解包这个小程序前端代码的时候,注意到一个细节:更换手机号的流程被拆成了两个独立接口。第一个接口负责“发送验证码”,第二个接口负责“提交新手机号并完成更换”。前端的UI表现是连在一起的,但在网络请求层面,它们是两次完全独立的请求。
这个拆分本身不奇怪,很多系统都这么设计,关键在于第二个接口到底校验了什么。我用测试账号A走了一遍正常流程,抓到请求包后发现,提交新手机号的那个接口,请求体里带了新手机号、验证码,还有一个前端生成的标记字段。我当时的第一反应是:这个标记字段很危险。因为它不是服务端签发的,而更像是前端用来告诉自己“验证码已经通过了”的。
为了验证这个猜想,我直接用测试账号B走接口,绕开前端页面,直接构造第二个接口的请求,把新手机号换成另一个受控号码,验证码随便填了一个。结果后端竟然直接返回“更换成功”。也就是说,第二个接口根本没有独立校验验证码的有效性,所谓的验证码校验只发生在第一个接口的响应里,而第二个接口完全信任了前端传过来的那个标记。
2.2 完整排查链路:从抓包到确认接管
这里把排查过程完整梳理一遍,方便想复现这类问题的人参考。
- 第一步,正常走一遍更换手机号流程,用抓包工具把每个HTTP请求按时间顺序排好。这一步的目的是拿到正常业务的请求基线。
- 第二步,逐个接口对比请求参数。我很快就发现第二个接口多了一个前端生成的标记字段,而且这个字段在第一个接口的响应里能找到对应的值。这个值看起来是服务端返回的,但仔细看它的生成逻辑,其实是前端在本地拼接的。
- 第三步,构造异常请求。去掉页面校验逻辑,直接拿到正常流程里第二个接口的完整请求体,然后把里面的新手机号改成另一个号码,验证码改成任意值,提交给服务端。
- 第四步,观察服务端行为。如果服务端返回成功,那就说明存在逻辑缺陷。我这个案例里,服务端确实把绑定号码改了。
- 第五步,验证实际影响。把更换后的号码退出登录,再用这个新号码走一遍短信验证码登录,结果直接登录成功,而我登录进去看到的是测试账号B的个人信息,包括订单、收货地址、余额,全部暴露在眼前。到这里,账户接管的实际影响就确认了。
整个链路走下来,其实没有一个地方是传统意义上的“漏洞利用”,没有注入、没有爆破,纯粹是业务接口之间的逻辑脱节。攻击者一旦发现这一点,就可以把任意受害者的手机号替换成自己的受控号码,然后利用“短信验证码登录”直接接管受害者的账户,整个过程甚至不需要知道受害者原密码。
2.3 根因分析与修复方案
这个案例的根因可以概括成一句话:服务端把“验证码发送成功”和“验证码校验通过”混为一谈了,而且业务操作的完整性完全依赖前端标记来维持。
正确的做法应该是在后端维护一套完整的校验状态,而不是让前端来传递“是否已通过验证”的信号。具体来说:
- 发送验证码接口生成一个短信会话ID,服务端把这个会话ID和手机号、验证码关联起来,存储在服务端缓存里,设置合理的过期时间。
- 提交更换手机号接口必须同时校验三样东西:当前登录用户身份、短信会话ID、验证码。三者必须一一对应,缺一不可。
- 验证码校验通过后,这个短信会话ID应该立即作废,防止重复使用。
- 更换手机号属于敏感操作,后端最好再加一层风控校验,比如校验当前账号最近是否有登录环境异常、是否频繁发起此类请求等。
这类问题的修复并不难,难点在于让团队意识到“校验必须在后端闭环完成”。很多业务负责人会说“前端已经验证过了,后端再做一次是不是重复了”,但安全测试的经验恰恰是,所有关键业务操作的后端校验都不该省略,因为前端永远默认不可信。
3. 案例二:Token只验证“存在”不验证“归属”,用户ID成了越权钥匙
3.1 漏洞现场:顺藤摸瓜找到一串越权接口
第二个案例是一个工具类小程序,业务场景是用户购买虚拟服务、查看服务记录、修改个人资料。这个系统比案例一稍微复杂一点,登录后服务端会给客户端返回一个token,后续请求都带着这个token。
我照例先解包小程序,检索所有接口路径,然后挨个测试。测试到个人资料接口时,我注意到请求体里有一组参数,其中有个字段看起来像是用户ID。一般的做法是后端从token里解析出当前用户ID,但这个接口不一样,它是直接从请求体里读用户ID。
我把测试账号A的token保持不变,把请求体里的用户ID改成测试账号B的用户ID,结果返回了测试账号B的姓名、手机号、邮箱、收货地址等隐私数据。这就已经构成水平越权了,它的本质是只校验了“你登录了没有”,没有校验“你是不是在访问自己名下的数据”。
如果只是能“看”,影响已经不小了,但我注意到这个接口风格的覆盖面似乎很广。于是我把所有请求体里包含用户ID参数的接口全部列出来,逐个去测。结果发现,修资料、查订单、换绑手机号等一大串接口都存在同一个问题:只要用户ID传的是别人的,返回的就是别人的数据。有些接口甚至支持写入操作,比如修改昵称、修改头像、更新绑定邮箱,这些接口同样没有校验数据归属权。
3.2 从“越权查看”升级到“账户接管”的验证路径
越权查看是一回事,账户接管是另一回事。前者是信息泄露,后者是身份控制。我在这个案例里验证了从越权到接管的完整路径,思路可以简单归纳如下。
- 先把所有可读的越权接口测完,收集到受害账号的敏感信息,尤其是绑定手机号、邮箱这些可用于身份验证的信息。
- 再看可写的越权接口里有没有“修改绑定”类的能力。在这个案例里,后端有一个“修改绑定手机号”的接口,理论上需要在旧手机号验证通过后才能改,但这个接口同样是只校验token、不校验归属,直接用测试账号A的token,配合测试账号B的用户ID,就能把测试账号B的绑定手机号修改成受控号码。
- 修改成功后,用受控号码走“短信验证码登录”,直接接管测试账号B的账户。到这里,原本的单点越权漏洞就被组合成了任意账户接管。
这一套路径里,真正致命的设计缺陷有两个。第一个是登录态主键不统一:token代表的是会话身份,但业务接口用user_id代表资源身份,两者没有建立“当前会话用户只能操作自己的user_id”这一层约束。第二个是敏感操作缺少二次校验:修改绑定手机号这种高危操作,不管是从哪个入口触发,后端都应该强制要求进行短信验证码或支付密码级别的复核。
3.3 根因分析与修复方案
这个案例的修复方案其实也很明确,核心是后端必须在所有涉及用户数据的接口中,从会话凭证里解析当前用户的真实身份,而不是从请求参数里拿用户ID。
- 业务接口默认不接收前端传来的用户ID,统一从token中解析。
- 如果业务场景确实需要访问其他用户的数据,比如查看他人主页、处理投诉等,必须在后端显式做一次授权校验,确认当前用户对该资源有访问权。
- 敏感写操作走独立接口,强制二次验证,不能信任已经被越权风险污染的token。
- 上线前做一个IDOR专项自查:把所有带用户ID参数的接口列成表格,逐个标记“是否需要登录”“是否校验归属”,没有校验归属的一律修复。
顺带说一句,修复这类漏洞的时候,开发团队经常只改单独一个接口,比如查资料的接口加了校验,订单接口就忘了。我的建议是,这种越权问题必须按“接口族”来治理,把所有用户数据接口一次性纳入改造范围,而不是头痛医头。
4. 两个案例的共性根因与可直接落地的防护清单
4.1 两个漏洞背后的同一本质
这两个案例,一个错在“验证码校验与业务操作分离”,一个错在“会话身份与资源身份脱节”,表面看是两种完全不同的漏洞,但往底层挖,它们都指向同一个共同点:服务端对整个操作过程的某些关键环节缺乏闭环校验。
第一个案例是后端没有闭环地校验“验证码是否真被验证过”,把校验职责甩给了前端。第二个案例是后端没有闭环地把“当前登录用户”和“被操作资源的所有者”绑定在一起,把身份校验降级成了“有没有登录”这一个布尔判断。本质上都是同一句话:服务端不该信任的东西,被当成了可信输入。
我后来跟不少开发团队复盘过这类问题,发现造成它们的原因也很一致:接口迭代太快,前端和后端并行开发,为了赶进度,前后端约定了一套“临时方案”,比如前端传一个标记、前端传用户ID,结果临时方案上了线就再也没改回来。安全测试在项目里能发现的漏洞,很多时候就是这种“技术债”的显性暴露。
4.2 一份可以直接抄走的防护清单
如果你是小程序开发者,正在排查自己项目里有没有类似风险,我建议你把下面这张表当成自查清单用。这些措施都是低成本、高收益的,属于“早做早安心”的类型。
| 风险场景 | 推荐防护措施 | 优先级 |
|---|---|---|
| 验证码/短信校验 | 服务端维护短信会话ID,校验结果与会话绑定,一次性使用 | 最高 |
| 敏感业务状态传递 | 所有关键业务状态由服务端下发并校验,前端标记一律不采信 | 最高 |
| 用户资源访问 | 接口从token解析当前用户,不读请求参数里的user_id | 最高 |
| 敏感写操作 | 独立接口+二次验证(短信/密码/人脸,视敏感级别) | 高 |
| 多套身份标识混用 | openid、user_id、手机号等标识统一映射,业务接口只用一种主键 | 高 |
| 登录态存储 | 自定义token定期换发,微信会话凭证不过期绑定在单一设备 | 中 |
这个清单里的每一项,我都在实际测试中见过对应的漏洞。尤其是“接口不读user_id”这条,很多团队会说“我这是内部系统,调用方都是自己的客户端”,但小程序本身就意味着客户端代码完全暴露给用户,攻击者可以拿你的包逆向分析,然后把每个请求改得面目全非。
4.3 测试与修复人员的复盘心得
最后聊聊我在处理完这两个案例之后的一些心得。
作为测试人员,遇到账户接管类漏洞,报告一定要写清楚利用链的完整路径,不能只丢一个“水平越权”的结论上去。开发者最需要知道的是“从哪一步开始可以拦截”,而不是仅仅被告知“你有个洞”。我在两份报告里都专门画了攻击流程图,把每一步的请求、响应、参数变化标出来,这样开发人员拿到报告就能直接对到代码位置。
作为研发侧的同学,修复这类漏洞时最忌讳只补单点。比如案例二里,如果只修了查资料的接口,第二天就可能从订单接口再爆出来。建议修复时直接对全部用户数据接口做一次专项梳理,把“取当前用户的唯一方式”抽成公共方法,要求所有接口必须调用,不允许任何人再从请求体里裸读用户标识。
这两个案例也让我养成了一个习惯:每次拿到小程序测试目标,我都会先问一句,这个系统的“身份主键”到底是什么?是手机号、openid、还是自增ID?一旦这个答案不清晰,账户类漏洞基本就已经埋下伏笔了。相反,如果整个系统从头到尾只有一条身份主线,所有业务接口都坚定不移地从会话中解析用户,那么账户接管这类高危漏洞,往往也就顺带被消灭了一大半。