ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

一个普通API到底有多危险?从接口越权到敏感数据泄露全面解析

一个普通API到底有多危险?从接口越权到敏感数据泄露全面解析 一、引言最危险的不是 0day而是那个正常返回 200的接口在渗透测试和红队项目里我见过太多这样的场景目标系统 WAF 齐全、边界防护严密、RCE 打不进去但最后拿到全量用户数据的路径往往只是一个看起来很普通的查询接口——GET /api/v1/orders/{id}。它没有任何注入没有命令执行甚至代码写得很规范。它只是忘了检查这个id是不是属于当前登录用户。于是攻击者把 id 从100001递增到100050用自己账号的 Token把别人的订单、手机号、收货地址、甚至身份证号全部拖走。这类问题的危险之处在于三点它不触发任何异常。请求合法、Token 有效、状态码 200传统 WAF 的规则库几乎无感它是业务逻辑层的缺陷无法靠打补丁或加规则一次性解决需要改授权模型它的杠杆率极高。一个越权点 一个批量遍历脚本等于一次完整的数据库脱库。再补充一组更直观的数据感受在 OWASP API Security Top 10 2023 版的统计中BOLA 相关漏洞在受访的 API 安全事件里占比超过 40%而在真实赏金项目中一个水平越权接口的赏金通常在 5005000 美元之间一旦能批量遍历往往直接按严重定级。换句话说攻击者投入的成本改个数字和获得的收益整库数据之间完全不成比例。这也是为什么越权类漏洞长期霸榜而高级的内存马、反序列化反而越来越难打——因为防守方把预算都堆在了边界却没人去数一数业务接口到底有几个做了对象级授权。本文从授权模型的本质讲起拆解 BOLA / BFLA / 过度数据暴露三类典型问题给出可直接落地的修复代码与验证脚本并总结工程化防御中的踩坑清单。二、核心原理认证只回答你是谁授权才回答你能碰什么很多人把登录校验当成安全边界这是最根本的误解。认证Authentication解决的是身份问题授权Authorization解决的是权限问题。一个请求通过了认证仅仅意味着它来自某个真实用户不代表它可以访问这个资源。举个更生活化的类比认证相当于进小区时刷门禁卡证明你是本小区业主授权相当于进楼栋后再刷一次卡决定你只能进 3 号楼 502进不了 6 号楼 801。很多系统的安全模型只做了第一道门禁然后就默认所有刷卡进来的人都能推开任意一扇房门——这就是越权的本质。OWASP API Security Top 102023把 API1 直接给了BOLABroken Object Level Authorization对象级授权失效也就是常说的水平越权 / IDOR。这不是巧合它是 API 时代最高频、最致命的漏洞类型。要理解为什么它这么普遍得回到 REST 的设计哲学本身。REST 鼓励把资源抽象成 URI/orders/{id}、/users/{id}/profile、/files/{file_id}这类接口天然就把资源标识符暴露给了客户端。前端为了展示我的订单详情必然要传一个 id而一旦这个 id 由客户端控制服务端如果只拿它当查询主键而不是查询条件之一漏洞就诞生了。传统 Web 应用多用 session 服务端渲染很多页面直接WHERE user_id session.user_id就把权限带上了而现代前后端分离 微服务架构下业务逻辑被切碎、数据被下沉授权这一环反而更容易在某个 service 里被漏掉。2.1 BOLA对象级授权缺失典型形态是接口接收一个资源标识符id、uuid、订单号、文件名服务端直接用这个标识符去数据库查询然后把结果返回查询条件里没有带上当前用户的归属约束。# 错误示范id 是攻击者可控的查询条件里只有 idorderdb.query(Order).filter(Order.idorder_id).first()returnorder修复的核心不是判断 id 是否合法而是把归属条件写进查询本身——让数据库层保证查不到不属于你的数据而不是靠应用层 if 判断。这里要强调一个关键的设计原则授权应该是默认拒绝、按需放行而不是默认放行、事后检查。前者意味着每条查询天然带归属条件漏掉的概率极低后者意味着你要在几十上百个 handler 里逐个人工地加if order.owner_id ! user.id只要有一个忘了整条防线就破了。工程上更稳妥的做法是通过 ORM 的 query filter、Repository 层的统一封装、或者数据库的行级安全Row Level SecurityPostgreSQL 的 RLS、MySQL 视图 权限来强制归属把人会不会忘这个变量彻底消掉。此外BOLA 不仅存在于读同样存在于改、删、导出、分享、评论、上传等所有带资源 id 的操作上。PUT /orders/{id}、DELETE /addresses/{id}、POST /files/{id}/share都是重灾区。很多时候团队只修了查询接口却忘了写接口——攻击者改成PUT一样能篡改别人的数据。2.2 BFLA功能级越权垂直越权指普通用户调用了管理员接口比如DELETE /api/v1/admin/users/{id}。常见成因是前端隐藏了按钮但后端接口没有任何角色校验网关层只对/admin/*前缀做了拦截而管理功能实际挂在/api/v1/users/batch-delete用role字段判断但role来自客户端可控的 JWT payload 或请求头。这里展开说一下 JWT 的经典坑。很多人以为用了 JWT 就安全了但如果服务端只做签名校验、不做权限校验甚至把role当成 JWT payload 的一部分直接信任那就等于把钥匙交给了客户端。更糟的情况是密钥用了弱口令或默认值攻击者可以自行伪造任意role: admin的 Token。正确的姿势是JWT 里只放你是谁user_id角色和权限每次请求都从服务端缓存/数据库实时查询或者至少在签发时校验、在网关处二次校验绝不信任客户端回传的任何权限字段。BFLA 的另一个隐蔽形态是HTTP 方法级越权接口只对GET做了鉴权却没对POST/PUT/DELETE做攻击者换一个动词就绕过了。这也是为什么做接口测试时要针对同一个 URL 把所有支持的方法都过一遍。2.3 过度数据暴露Excessive Data Exposure这是敏感数据泄露的主渠道也是最容易被忽视的。接口做了正确的授权但返回了远超业务需要的字段{id:ORD100001,status:paid,amount:199.00,user:{id:8821,phone:13800001234,id_card:310***********1234,password_hash:$2b$12$...,internal_risk_score:87,access_token:eyJhbGciOi...}}前端只用了status和amount剩下的字段却全部通过网络传输。攻击者不需要任何漏洞只需要打开 DevTools 或抓包。前端不展示从来不是安全措施。泄露面还远不止响应体错误堆栈、Swagger/OpenAPI 文档、GraphQL introspection、导出任务、变更历史接口、日志系统、消息队列都可能成为二次泄露源。尤其要警惕几个高频场景一是异常处理500时把原始堆栈和 SQL 直接吐给前端泄露表结构、字段名、甚至数据库连接串二是调试接口/actuator/env、/debug/vars、/swagger-ui在生产环境未关闭等于把系统的内部结构画成地图送给攻击者三是GraphQL introspection一条__schema查询就能拿到全部类型定义接着就能精准构造越权查询四是移动端接口由于客户端抓包成本低很多 App 的接口字段冗余到令人发指直接返回整个 user 对象。三、实战案例一个订单查询接口的完整沦陷链路3.1 漏洞版本# vuln_api.pyfromfastapiimportFastAPI,Depends,HTTPExceptionfromsqlalchemy.ormimportSession appFastAPI()app.get(/api/v1/orders/{order_id})defget_order(order_id:str,userDepends(current_user),db:SessionDepends(get_db)):# 认证做了授权没做并且直接返回 ORM 对象全字段外泄orderdb.query(Order).filter(Order.idorder_id).first()ifnotorder:raiseHTTPException(status_code404,detail订单不存在)returnorder这个接口有两个独立缺陷叠加没有对象级授权任何人可以查任何订单没有响应字段白名单连带泄露用户敏感信息。单独任何一个都够危险叠加起来就是一次完整的 PII 泄露。3.2 修复版本# fixed_api.pyfrompydanticimportBaseModelfromfastapiimportFastAPI,Depends,HTTPExceptionclassOrderOut(BaseModel):id:strstatus:stramount:floatcreated_at:str# 只声明需要暴露的字段其余一律不外传classConfig:from_attributesTrueapp.get(/api/v1/orders/{order_id},response_modelOrderOut)defget_order(order_id:str,userDepends(current_user),db:SessionDepends(get_db)):order(db.query(Order).filter(Order.idorder_id,Order.tenant_iduser.tenant_id,# 多租户隔离Order.owner_iduser.id,# 对象级授权归属写进查询条件).first())iforderisNone:# 统一返回 404避免通过 403/404 差异探测资源是否存在raiseHTTPException(status_code404,detail订单不存在)returnorder关键点有三个授权下沉到查询条件而不是在返回前做 if 判断杜绝某条分支漏判response_model做字段白名单从序列化层强制收敛输出即使 ORM 对象里有敏感字段也不会外泄统一 404 而非 403避免资源存在性枚举这是很多团队的盲区。这里再多说一句404 还是 403的取舍。有人会担心统一返回 404 会让运维排查困难其实可以在日志侧记录真实的失败原因是不存在还是无权访问给监控告警用但对外只暴露一个模糊的 404。这是典型的内部丰富、外部极简原则。同理登录接口的用户名不存在和密码错误也应该返回同一句提示防止账号枚举。3.3 批量验证脚本判断越权是否真实存在修复之后需要验证。判定 BOLA 的核心逻辑只有一句用 A 的凭证能否拿到 B 的数据。所以需要两个测试账号。# bola_check.py —— 仅限授权测试环境使用importasyncioimporthttpx BASEhttps://api.example.comTOKEN_AeyJ...# 账号 A攻击者视角MY_UIDuser_aasyncdefprobe(client,order_id):urlf{BASE}/api/v1/orders/{order_id}rawaitclient.get(url,headers{Authorization:fBearer{TOKEN_A}})ifr.status_code!200:returnNonebodyr.json()ownerbody.get(owner_id)orbody.get(user_id)# 判定三要素200 归属字段不属于 A 响应体含业务数据ifownerandowner!MY_UID:returnorder_id,owner,len(r.content)returnNoneasyncdefmain():# 控速并发压到 5避免触发风控或压垮生产limitshttpx.Limits(max_connections5)asyncwithhttpx.AsyncClient(limitslimits,timeout10)asclient:semasyncio.Semaphore(5)asyncdefworker(oid):asyncwithsem:returnawaitprobe(client,oid)tasks[worker(fORD{100000i})foriinrange(50)]forresinawaitasyncio.gather(*tasks):ifres:print(BOLA confirmed:,res)asyncio.run(main())如果接口没有返回归属字段可以退化为响应长度差异 时间盲判合法 id 返回 200/完整 body非法 id 返回 404/空 body或者合法 id 因为要连表查询而响应时间明显更长。此时把判定逻辑改成状态码 响应体长度 耗时三元组即可asyncdefprobe_blind(client,order_id):urlf{BASE}/api/v1/orders/{order_id}t0time.perf_counter()rawaitclient.get(url,headers{Authorization:fBearer{TOKEN_A}})costtime.perf_counter()-t0# 存在性盲判状态码 body 长度 耗时三者组合识别资源是否存在returnorder_id,r.status_code,len(r.content),round(cost,3)需要注意的是在生产环境做批量探测一定要先拿到书面授权并且严格控速。真实的越权验证其实只需要两个账号 两三个 id就能证伪或证实没必要真的把 10 万条数据拖一遍——拖库行为可能已经构成违法务必在授权范围内、以最小影响原则执行。3.4 一个更隐蔽的变体通过导出接口绕过授权很多团队修好了详情接口的 BOLA却忽略了导出接口。比如POST /api/v1/orders/export参数是{start: 2024-01-01, end: 2024-12-31}服务端按时间范围查全表数据打包成 Excel 返回。详情接口你只能一次查一条导出接口一次给你一整个租户甚至全平台的数据。这类接口的授权更复杂不仅要校验这个用户能不能导出还要校验导出范围是否限定在他自己的数据里。攻击者只需把时间范围拉到最大或者把tenant_id参数改成别的租户就能一次性拿到海量数据。防御思路是导出任务必须绑定user_id/tenant_id作为强制过滤条件导出结果通过异步任务 带签名的临时下载链接交付并对单次导出行数、频率做限制。四、常见问题FAQQ1我把 id 换成了 UUID是不是就防住越权了不是。UUID 只提高了猜 id的难度属于隐晦式安全security by obscurity并没有改变授权缺失的本质。攻击者依然可以通过分享链接、日志泄露、前端接口、第三方 SDK 回传等渠道拿到别人的 UUID。UUID 可以作为一个辅助手段但不能替代对象级授权。Q2前端已经做了权限判断后端还需要再做一遍吗必须要。前端代码运行在用户可控的环境里用户可以改 JS、可以直接调接口、可以用抓包工具重放。前端的权限判断只解决体验问题不让用户看到没权限的按钮后端的权限判断才解决安全问题。凡是安全决策必须以服务端为准。Q3我的接口返回了owner_id会不会本身就是信息泄露会而且很多团队为了让前端判断恰恰把归属字段返回给了前端等于把越权的探针直接递给攻击者。正确做法是服务端根据归属关系决定返回什么前端不需要知道owner_id。如果确实需要展示也应只返回脱敏后的展示字段。Q4网关统一鉴权能解决越权吗不能。网关能做的是认证和粗粒度功能授权比如/admin/*需要 admin 角色但它不知道这个订单 100001 属于谁。对象级授权依赖业务数据天然只能在业务层做。网关的价值是兜住 BFLA 这类功能级越权不能指望它解决 BOLA。Q5用了 ORM 就自动防越权了吗不会。ORM 只帮你生成 SQL不会自动帮你加WHERE owner_id ?。除非你显式配置了全局的查询过滤比如 SQLAlchemy 的with_loader_criteria、Hibernate 的Filter、Django 的自定义 Manager否则每一条 query 都要自己负责归属条件。Q6如何快速发现存量接口里的越权三个层次一是代码审计搜索所有filter(.*\.id 、findById、get_object_or_404这类只按 id 查的调用二是接口测绘把 Swagger/OpenAPI 里所有带 path 参数的接口列出来重点盯{id}、{uuid}、{order_no}三是自动化扫描用两个账号对同一批接口做交叉验证A 的 Token 请求 B 的资源这个思路可以脚本化但要注意控速和授权。五、工程化防御踩坑清单在实际落地防御时以下这些坑几乎是每个团队都会踩一遍的只修了读接口漏了写接口。GET修了PUT/DELETE/PATCH忘了攻击者改数据照样成功。修复时务必按资源 全方法的矩阵逐格确认。只修了主接口漏了批量接口。/orders/{id}修了/orders?ids1,2,3这种批量查询却直接IN (...)全查出来等于把批量越权留了个后门。把授权写在 Controller 之外的公共层。看似优雅实则一旦有人绕过这层直接调 Service权限就丢了。授权应该尽量贴近数据访问层越靠近数据库越安全。统一 404 了但导出/下载接口仍用 302 跳转通过跳转目标差异依然能判断资源是否存在。日志里记了完整请求体和响应体包含手机号、身份证、Token日志系统被拖走等于二次脱库。敏感字段必须脱敏后再落盘。测试环境的数据被复制到生产或生产数据被拉到测试库权限模型没同步导致越权在测试环境测不出来上线才炸。缓存键没带 user_id/tenant_id。比如cache.get(order: order_id)A 查过之后 B 再查直接命中 A 的缓存等于缓存层又制造了一个越权点。消息队列 / 异步任务里丢了上下文。请求线程校验了权限丢到 MQ 后消费者只拿到order_id再去查库时没有任何归属约束异步链路成了盲区。GraphQL 的 resolver 忘了逐字段鉴权。REST 里一个接口对应一个资源GraphQL 里一个查询可能嵌套三层关联对象每个 resolver 都要独立鉴权。依赖前端传的role/permission字段做判断或者把权限塞进 JWT 且长期不失效导致降权后 Token 仍然有效。优化建议方面核心是把授权做成框架能力而不是人的自觉。具体可以做三件事一是建立统一的资源访问入口Repository/DAO 层强制注入tenant_id、owner_id过滤二是引入自动化回归测试把两个账号交叉访问的用例固化成 CI 的一部分任何新增接口必须带上越权测试三是对敏感数据做分级定义清楚哪些字段属于 PII、哪些可以外发用统一的序列化白名单收敛出口。六、总结一个普通的 API 之所以危险是因为它把高杠杆的破坏力藏在了一个看起来完全正常的 200 响应里。它不靠漏洞利用链不靠绕过 WAF只需要改一个数字、换一个 Token、抓一次包。防守方要挡住它靠的不是更强的边界设备而是把授权做进数据访问的每一层认证只回答你是谁授权才回答你能碰什么而对象级授权必须下沉到查询条件本身。记住三句话**响应即攻击面归属即权限默认拒绝即安全。更多硬核网安与AI工具包请扫码获取完整源码** 只要这三条落到代码里、落到 CI 里、落到每一次 Code Review 里那个普通接口才不会变成下一次数据泄露的起点。
返回列表