ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

黑客拿到普通账号后还能做什么?从权限提升看Web系统安全防线

黑客拿到普通账号后还能做什么?从权限提升看Web系统安全防线 一、引言被低估的低权限账号在多数应急响应复盘会上当确认攻击者只拿到了一个普通用户账号时团队往往会松一口气没有管理员权限翻不起浪。这种判断几乎总是错的。现实中的入侵链条极少是一步登天。攻击者的典型路径是通过撞库、钓鱼、XSS 窃取 Cookie、第三方 SDK 泄露的 Token或者干脆在 GitHub 上捡到一个硬编码的测试账号先拿到一个合法身份。这个身份可能只能查看自己的订单、修改自己的昵称——看起来毫无价值。但它的真正价值在于三点第一它绕过了外围防线。WAF、IP 信誉库、未授权访问检测绝大多数规则针对的是匿名请求。一旦你带着一个合法 Session所有流量在网关看来都是正常业务。第二它暴露了完整的业务面。登录之后攻击者能看到真实的 API 路径、参数命名规范、ID 生成规律、错误信息格式这些是匿名状态下探测不出来的。第三它常常直接通向提权入口。水平越权读别人的数据、垂直越权调用管理员接口、JWT 伪造、Mass Assignment这些漏洞的利用前提往往就是你得先登录。本文从攻击者视角拆解权限提升的完整链路再回到防御侧讨论 Web 系统的授权体系到底应该怎么设计。补充一点低权限账号之所以危险是因为它把攻击者从外部陌生人变成了内部合法用户。在很多系统中内部合法用户默认被信任审计日志只记录匿名攻击风控模型只对未登录请求打分速率限制只按 IP 而不是按用户维度。攻击者一旦登录就像拿到了一张门禁卡虽然只能进大厅但大厅里往往放着楼层索引、员工通讯录和未上锁的消防通道。权限提升的本质就是利用这些合法但不该拥有的信息与入口一步步走到核心区域。二、权限提升的核心原理2.1 垂直提权与水平提权垂直提权Vertical Privilege Escalation指低权限角色获得高权限角色的能力例如普通用户调用/api/admin/users接口。它的根因通常是只在前端隐藏了按钮或只在网关做了角色白名单而内部服务互相信任。水平提权Horizontal Privilege Escalation指同一角色内访问他人资源即经典的 IDORInsecure Direct Object Reference。它的根因更隐蔽开发者写了if user.is_authenticated却没写if resource.owner_id user.id。在真实攻击中两者是串联使用的先用水平越权读到管理员的邮箱、重置令牌或内部工单内容再用这些信息完成垂直提权。从 OWASP API Security Top 10 的视角看水平越权对应BOLABroken Object Level Authorization垂直越权对应BFLABroken Function Level Authorization。这两类问题连续多年占据 API 安全风险的前两位原因很直接对象级授权需要开发者对每一个资源、每一个动作、每一个入口都写对判断而功能级授权则依赖框架注解和网关策略。前者是人肉密集型工作后者是配置密集型工作所以前者更容易遗漏。进一步看权限模型本身也在演进。早期系统常用RBAC基于角色的访问控制把权限绑定到角色再把角色分配给用户。RBAC 适合功能级授权但很难表达只能读自己创建的订单这种对象级规则。于是有了ABAC基于属性的访问控制用主体属性、资源属性、环境属性做策略判断例如subject.id resource.owner_id。再往后是ReBAC基于关系的访问控制典型实现是 Google Zanzibar用关系图表达用户 A 是文档 D 的编辑者。无论选哪种模型关键原则不变授权判断必须集中、可测试、不可绕过。2.2 Web 权限校验的三层失守一个健康的授权体系应该在三个层次上都有校验层次校验内容典型失效表现功能级Function-Level这个角色能不能调用这个接口接口未做角色注解靠前端路由守卫对象级Object-Level这个用户能不能操作这条数据只校验登录态不校验归属字段级Field-Level这个用户能不能读写这个字段返回体里带出password_hash、internal_note绝大多数越权漏洞都出在第二层。因为第一层有框架和网关兜底第三层容易被 DTO 掩盖唯独对象级授权需要开发者对每一个资源手动写判断——而人总会忘。还可以补充第四层租户级Tenant-Level。在多租户 SaaS 中除了用户是否拥有这条数据还要判断这条数据是否属于当前租户。很多系统在对象级校验上写了owner_id user.id却忘了tenant_id current_tenant.id。一旦用户跨租户访问即使 owner_id 不同也可能因为共享资源、缓存键未隔离、数据库查询漏加tenant_id条件而泄露。租户级失守往往影响面更大因为一个租户里可能有成百上千个用户。第五层是业务逻辑级Business Logic-Level。例如退款接口允许用户退自己的订单但没有校验订单状态、退款金额、退款次数。攻击者可以用自己的普通账号反复调用造成资金损失。这类问题不属于传统越权但本质仍是权限边界被绕过。2.3 攻击链从一个普通账号到管理员一个成熟的攻击者拿到普通账号后会按这个顺序推进信息收集拉取自己的资源列表观察 ID 规律自增UUIDv1时间戳随机数抓取所有接口路径。水平探测对每个带 ID 的接口做 ±1、±N 遍历观察返回差异200/403/404 的语义差异本身就是情报。字段探测在 PATCH/PUT 请求中追加role、is_admin、balance、tenant_id等字段测试 Mass Assignment。Token 攻击解析 JWT尝试alg: none、HS256/RS256 混淆、弱密钥爆破、篡改role或sub声明。持久化创建 API Key、绑定新邮箱、修改通知 Webhook 指向自己的服务器。每一步都有实战细节信息收集阶段攻击者会优先使用浏览器开发者工具、Burp Suite 的站点地图、以及前端 JS 文件中的接口常量。很多系统的 Swagger、GraphQL introspection、Actuator 端点未做权限控制登录后可以直接拿到完整 API 文档。水平探测阶段不要只盯着自增 ID。UUIDv1 包含时间戳和 MAC 地址可能被预测UUIDv4 虽然随机但如果系统在响应中泄露了内部 ID 映射仍然可以被枚举。更隐蔽的是通过导出功能、批量查询接口、消息通知接口来间接读取他人数据。字段探测阶段Mass Assignment 的常见目标字段包括role、is_admin、is_staff、status、balance、credit、verified、email_verified、tenant_id、org_id、owner_id。有些框架会自动绑定请求体到模型有些则需要攻击者猜测字段名。Token 攻击阶段除了 JWT还要关注 Session 固定、Cookie 作用域过宽、OAuth 授权码泄露、Refresh Token 未绑定客户端等。很多系统把角色写进 Token 且不查库导致角色变更后旧 Token 仍然有效。持久化阶段攻击者不会满足于一次访问。他们会创建自己的 API Key、修改 Webhook、绑定新的 MFA 设备、在个人资料中留下隐藏的 XSS 载荷以便后续再次进入。三、实战案例三个典型的提权入口3.1 案例一IDOR 泄露密码重置令牌某 SaaS 系统的工单接口为/api/tickets/{id}返回体包含creator_id、creator_email和reset_token历史遗留字段前端未使用但后端未剔除。攻击者用自己的账号遍历 ID直接拿到管理员的重置令牌完成账号接管。这个案例的两个教训是对象级授权缺失响应体过度返回。任何一条单独存在都不致命组合起来就是完整的接管链路。补充攻击细节攻击者通常不会直接遍历所有 ID而是先用自己的账号创建一个工单观察返回的 JSON 结构。如果发现reset_token字段再尝试修改 URL 中的 ID。为了规避告警攻击者会控制请求频率例如每分钟 3-5 次并随机化 User-Agent。有些系统对 403 和 404 返回不同页面攻击者可以通过状态码和响应长度判断哪些 ID 真实存在。修复时除了删除敏感字段还应该对工单接口增加对象级授权只有创建者、被指派人和管理员可以查看。如果业务需要管理员查看也应记录审计日志并对批量访问做限流。3.2 案例二JWT 角色字段可篡改系统把角色写进 JWT 的role声明使用 HS256 签名密钥是配置文件里的secret123。攻击者拿到自己的 Token 后离线爆破密钥重新签发一个role: admin的 Token。由于服务端只验签不查库提权瞬间完成。更隐蔽的变体是算法混淆服务端代码写成jwt.decode(token, public_key, algorithms[HS256,RS256])攻击者把头部改成HS256用公开的 RSA 公钥当 HMAC 密钥签名服务端会验签通过。防御 JWT 攻击的核心原则不要在 Token 中放可变的授权信息。Token 只放sub用户 ID、jtiToken 唯一标识、exp过期时间、iat签发时间。角色和权限每次从缓存或数据库读取。固定算法。服务端验签时只允许一种算法例如algorithms[RS256]绝不要同时接受 HS256 和 RS256。使用强密钥。HMAC 密钥至少 256 位随机值不要用配置文件中的弱口令。RSA 私钥妥善保管公钥可以公开但不要复用为 HMAC 密钥。支持强制失效。维护 Token 版本号或黑名单用户角色变更、密码修改、登出时递增版本号旧 Token 立即失效。短过期 Refresh Token 轮换。Access Token 有效期控制在 15 分钟以内Refresh Token 绑定客户端指纹并一次性使用。3.3 案例三注册接口的 Mass Assignment# 危险写法直接把请求体映射到模型app.post(/api/register)defregister(payload:UserCreate,db:SessionDepends(get_db)):userUser(**payload.dict())# payload 里若含 role 字段直接落库db.add(user);db.commit()returnuser攻击者提交{username:attacker,password:...,role:admin}注册即管理员。这类漏洞在 Spring Boot 的ModelAttribute、Rails 的params.permit!、Django REST Framework 的fields __all__中同样常见。其他框架的典型危险写法Spring BootModelAttribute User user直接绑定请求参数如果 User 类有role字段且没有InitBinder限制攻击者可以提交roleadmin。RailsUser.new(params[:user])或params.permit!会允许所有字段。安全写法是params.require(:user).permit(:username, :password)。Django REST Frameworkfields __all__或exclude []会暴露所有模型字段。安全写法是显式列出fields [username, password]并把role设为只读。GraphQL如果 mutation 的 input 类型包含role字段且 resolver 直接透传给 ORM同样会导致 Mass Assignment。防御方法是使用独立的 Input 类型并在 resolver 中显式赋值。修复 Mass Assignment 的通用原则永远不要信任客户端提交的字段名。使用 DTO/VO 显式白名单只接收业务允许的字段对于敏感字段服务端强制覆盖或忽略。3.4 案例四租户隔离失效某多租户 CRM 系统使用共享数据库、共享表通过tenant_id区分租户。订单查询接口代码如下orderdb.query(Order).filter(Order.idorder_id).first()开发者认为order_id是全局唯一 UUID不会跨租户碰撞因此没有加tenant_id条件。但攻击者发现系统在创建订单时会返回一个内部自增 ID 作为order_id的一部分且不同租户的 ID 空间存在重叠。攻击者用自己的租户账号遍历 ID成功读到了其他租户的订单详情包括客户姓名、电话、地址和金额。更隐蔽的是缓存键未隔离cache_keyforder:{order_id}如果两个租户的订单 ID 相同缓存会互相覆盖导致 A 租户看到 B 租户的数据。修复时所有数据查询和缓存键都必须带上tenant_id并且最好在数据库层使用行级安全RLS或独立 schema 做兜底。3.5 案例五导出功能越权某后台管理系统提供 CSV 导出功能接口为/api/export?typeordersuser_id123。前端只允许管理员点击导出按钮但后端没有校验角色也没有校验user_id是否属于当前用户。普通用户登录后直接调用该接口传入管理员的user_id即可导出管理员的全部订单数据。这类漏洞的根因是功能级授权缺失。前端隐藏按钮不是安全措施攻击者可以通过抓包、查看 JS 源码、猜测接口路径来发现未授权接口。防御方法是所有接口默认拒绝显式声明所需权限并在网关或框架层统一拦截。四、代码实战检测与修复4.1 攻击侧批量越权探测脚本以下脚本仅用于自有系统的授权测试。核心思路是带着自己的合法凭证访问一批不属于自己的对象观察是否返回了数据。#!/bin/bash# 验证 /api/v1/orders/{id} 是否存在水平越权COOKIEsessioneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...MY_UID10086foridin$(seq1000010050);docode$(curl-s-o/tmp/resp.json-w%{http_code}\-HCookie:$COOKIE\-HX-Requested-With: XMLHttpRequest\https://target.example.com/api/v1/orders/$id)owner$(jq-r.user_id // empty/tmp/resp.json2/dev/null)if[$code200][-n$owner][$owner!$MY_UID];thenecho[!] 越权可读: order_id$id, 归属 user_id$ownerfidone关键观察点不只是 HTTP 200还包括403 与 404 的返回差异能区分存在但无权和不存在本身就是信息泄露、响应时间差异、错误信息中是否回显对象详情。如果想更高效地测试可以用 Python 编写并发脚本并加入随机延迟和代理池importrequests,random,timefromconcurrent.futuresimportThreadPoolExecutor COOKIE{session:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...}MY_UID10086BASEhttps://target.example.com/api/v1/orders/defcheck(order_id):try:rrequests.get(BASEstr(order_id),cookiesCOOKIE,timeout5)ifr.status_code200:datar.json()ownerdata.get(user_id)ifownerandstr(owner)!MY_UID:print(f[!] 越权可读:{order_id}-{owner})elifr.status_code403:print(f[?] 存在但无权:{order_id})elifr.status_code404:passexceptExceptionase:print(f[x]{order_id}请求异常:{e})time.sleep(random.uniform(0.1,0.5))withThreadPoolExecutor(max_workers5)aspool:pool.map(check,range(10000,10051))注意测试前必须获得系统所有者书面授权否则可能违反法律。测试时也要控制速率避免影响业务。4.2 防御侧对象级授权必须落在数据访问层修复的原则是授权判断要和数据查询绑在一起而不是散落在 Controller 里。fromfastapiimportDepends,HTTPException,statusfromsqlalchemy.ormimportSessionapp.get(/api/v1/orders/{order_id})defread_order(order_id:int,db:SessionDepends(get_db),user:UserDepends(get_current_user)):# 1) 查询时即带上归属条件而不是查出来再判断order(db.query(Order).filter(Order.idorder_id).filter(Order.owner_iduser.id)# 关键写进 WHERE.first())# 2) 对无权与不存在返回同一结果避免枚举iforderisNone:raiseHTTPException(status.HTTP_404_NOT_FOUND,订单不存在)# 3) 字段级裁剪用响应模型显式白名单杜绝过度返回returnOrderOut.model_validate(order)classOrderOut(BaseModel):id:intamount:Decimal status:str# 显式排除 reset_token / internal_note / cost_price 等敏感字段对于角色判断不要散写if user.role admin而应统一成能力Permission模型用策略引擎Casbin、OPA集中管理# Casbin 策略示例policy.csv# p, role, resource, action# p, user, order, read_own# p, user, order, create# p, admin, order, read_any# p, admin, order, refund更完整的 Casbin 集成示例importcasbinfromfastapiimportDepends,HTTPException enforcercasbin.Enforcer(model.conf,policy.csv)defauthorize(sub,obj,act):ifnotenforcer.enforce(sub,obj,act):raiseHTTPException(status_code403,detailForbidden)app.get(/api/v1/orders/{order_id})defread_order(order_id:int,user:UserDepends(get_current_user)):# 先做功能级授权authorize(user.role,order,read_own)# 再做对象级授权orderdb.query(Order).filter(Order.idorder_id,Order.owner_iduser.id).first()ifnotorder:raiseHTTPException(404,订单不存在)returnOrderOut.model_validate(order)如果使用 ABAC可以把对象属性传入策略enforcer.enforce(user.id,order.owner_id,read)策略文件p, alice, data1, read g, alice, admin这样授权逻辑集中管理新增接口时只需要在策略中增加规则而不需要修改业务代码。4.3 检测侧用日志发现越权行为越权利用必然留下跨对象访问的痕迹。可以在访问日志上做聚合检测-- 10 分钟内同一会话访问了超过 5 个不属于自己的资源疑似 IDOR 扫描SELECTsession_id,user_id,COUNT(DISTINCTresource_owner_id)ASforeign_targetsFROMaccess_logWHEREactionread:orderANDuser_idresource_owner_idANDcreated_atNOW()-INTERVAL10 minutesGROUPBYsession_id,user_idHAVINGCOUNT(DISTINCTresource_owner_id)5;这类规则的价值在于即使授权代码有漏洞你也能在攻击者遍历到第 6 个 ID 时把他拦下来。日志字段建议至少包含timestamp、session_id、user_id、action、resource_type、resource_id、resource_owner_id、tenant_id、ip、user_agent、status_code。有了这些字段还可以做更多检测同一用户短时间内访问大量不同resource_id且status_code多为 403/404疑似枚举。同一 IP 使用多个账号登录且这些账号都在访问不属于自己的资源疑似撞库后提权。管理员账号在非工作时间从异常 IP 访问敏感接口疑似账号接管。导出接口的调用量突增且导出条件中的user_id与当前登录用户不一致。告警策略应该分级低风险记录日志中风险触发二次验证高风险直接阻断会话并通知安全团队。避免误报的关键是建立基线例如正常用户平均每天访问 20 个订单突然访问 200 个不同所有者的订单就值得关注。五、踩坑与优化建议5.1 五个常见误区误区一在 Controller 层做授权就够了。一旦有新接口绕过 Controller 直连 Service或内部服务之间互相调用授权就失效。授权应该下沉到数据访问层做成不可绕过的切面。误区二用 403 表达无权。对于对象级资源403 和 404 的差异会让攻击者精确判断哪些 ID 真实存在。对敏感资源统一返回 404 更安全。误区三把角色写死在 Token 里且长期有效。角色变更后旧 Token 仍然有效等于给攻击者留了后门。解决方案是 Token 只放sub角色每次从缓存/DB 读取并维护 Token 版本号以支持强制失效。误区四只做功能级限流不做对象级限流。功能级限流只能防止暴力破解和爬虫无法阻止攻击者用合法会话逐个遍历对象。对象级限流应针对同一用户访问不同资源所有者的频率、失败率、ID 分布进行限制。例如同一会话在 1 分钟内访问超过 20 个不同order_id且其中多数不属于自己应触发二次验证或封禁。误区五认为 UUID 不可枚举就不需要对象级授权。UUIDv4 确实难以预测但攻击者可以通过其他途径获取 UUID分享链接、邮件通知、导出文件、日志泄露、前端状态管理、第三方 SDK。UUID 只是增加了枚举难度不能替代授权校验。正确的做法是无论 ID 是否可预测每次访问都必须校验归属。误区六只在 API 网关做鉴权内部服务互相信任。很多微服务架构中网关校验 JWT 后把用户信息放在请求头内部服务直接信任这些头。一旦攻击者能访问内部网络或者某个服务存在 SSRF就可以伪造请求头绕过网关。内部服务之间也应使用 mTLS、服务网格授权或零信任策略并且对关键操作做二次校验。误区七忽视批量接口和 GraphQL 的越权风险。REST API 的越权容易测试但批量接口如/api/orders/batch?ids1,2,3和 GraphQL 查询如orders(ids: [1,2,3])往往只校验一次权限或者依赖前端传入的 ID 列表。攻击者可以构造包含他人 ID 的批量请求一次拿到大量数据。防御方法是对批量接口中的每个 ID 单独做对象级授权GraphQL 则应在 resolver 层逐条校验。5.2 优化建议构建可演进的授权体系默认拒绝所有接口默认不可访问显式声明所需权限。新增接口时如果没有配置权限应该返回 403 而不是放行。授权集中化使用策略引擎Casbin、OPA、Zanzibar 风格统一管理授权规则避免散落在业务代码中的if-else。数据层兜底在 ORM 层或数据库层增加强制过滤条件例如多租户系统使用 PostgreSQL RLS确保任何查询都自动带上tenant_id。响应裁剪使用 DTO/VO 显式定义返回字段禁止直接序列化 ORM 模型。对敏感字段做脱敏如手机号、邮箱、身份证号。审计与监控记录所有敏感操作的审计日志包括操作者、对象、时间、IP、结果。对跨对象访问、批量访问、异常时间访问做实时告警。安全测试左移在 CI/CD 中集成越权测试用例例如用两个测试账号互相访问对方资源断言返回 404。每次接口变更都自动运行。红蓝对抗定期组织内部攻防演练模拟攻击者从普通账号出发尝试水平越权、垂直越权、Token 篡改、Mass Assignment验证防御体系的有效性。5.3 常见问题FAQQ1普通用户账号被拿到后最应该先检查什么A优先检查该账号最近 24 小时的访问日志重点关注访问了哪些资源、是否出现大量 403/404、是否调用了管理员接口、是否修改了个人资料中的敏感字段邮箱、手机、MFA、是否创建了 API Key 或 Webhook。同时检查同 IP、同设备指纹是否登录过其他账号。Q2如何快速判断系统是否存在 IDORA用两个测试账号 A 和 B分别创建资源记录资源 ID。用 A 的会话访问 B 的资源 ID。如果返回 200 且包含 B 的数据说明存在 IDOR。再测试修改、删除、导出等操作。注意要测试所有带 ID 的接口包括嵌套资源、批量接口、GraphQL。Q3JWT 应该放哪些字段A只放必要的身份标识和元数据sub用户 ID、jtiToken 唯一 ID、iat签发时间、exp过期时间、iss签发者、aud受众。不要放角色、权限、余额、邮箱等可变信息。角色和权限每次从缓存或数据库读取并设置短过期时间。Q4多租户系统如何防止跨租户越权A三管齐下一是所有数据查询强制带上tenant_id最好在 ORM 层用全局过滤器或数据库 RLS二是缓存键必须包含tenant_id三是所有对外返回的 ID 使用租户内唯一的 UUID避免全局自增 ID 泄露业务规模。定期用自动化测试验证租户隔离。Q5Mass Assignment 如何彻底修复A使用 DTO 显式白名单只接收业务允许的字段。禁止直接把请求体绑定到 ORM 模型。对于敏感字段服务端强制覆盖或忽略。在框架层开启批量赋值保护例如 Rails 的 strong parameters、Django 的fields白名单、Spring 的InitBinder。代码审查时重点检查**payload、params.permit!、fields __all__等写法。Q6内部服务之间需要授权吗A需要。零信任原则下内部网络不等于可信网络。服务之间应使用 mTLS 双向认证每个请求携带服务身份和用户身份。关键操作如资金、权限变更应在服务端再次校验用户权限而不是信任上游传来的角色声明。使用服务网格如 Istio可以统一实施 mTLS 和授权策略。Q7如何平衡安全与用户体验A对高风险操作修改密码、绑定邮箱、退款、导出要求二次验证对低风险读操作尽量使用短过期 Token 和对象级授权减少用户感知。限流和告警应尽量精准避免误伤正常用户。安全设计应遵循“默认安全”而不是让用户选择。**Q8发现越权漏洞后应急响应应该怎么做更多硬核网安与AI工具包请扫码获取完整源码**A第一步确认漏洞范围和影响面
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进