SecretVault强网杯2025 Web题解:从JWT绕过到HTTP头注入的实战剖析
1. 初探SecretVault一个看似简单的Web应用最近在复盘强网杯2025的一道Web题目叫SecretVault。这道题挺有意思的它表面上是一个密码保险箱应用你可以登录、注册然后把你的各种账号密码加密存进去。题目环境一打开就是一个很清爽的登录页面功能看起来也不复杂。但玩过CTF的朋友都知道越是这种看起来人畜无害的应用底下藏着的“惊喜”可能就越大。我拿到题目习惯性地先看了看前端又随手点了几个功能没发现什么明显的注入点或者文件上传漏洞。这时候就得去琢磨它的整体架构了。从题目给的源码和网络请求来看我发现它其实是一个**“Go反向代理 Flask后端应用”**的组合。什么意思呢就是你浏览器直接访问的那个服务其实是一个用Go语言写的网关或者叫代理服务器它并不真正处理你的登录、查询数据这些业务逻辑。它只干一件事检查你的身份然后把你的请求转发给后面真正干活的Flask应用。Flask应用跑在本地的一个端口上比如5000只对前面的Go代理服务开放我们用户是直接接触不到的。这种架构在实际开发中挺常见的特别是微服务流行起来以后。Go负责网关层做统一的鉴权、限流、日志PythonFlask负责实现具体的业务功能分工明确。但正是这种“分工”如果两个组件之间对某些规则的理解不一致或者通信协议有模糊地带就很容易被我们钻空子。SecretVault这道题就是一个教科书级别的例子它暴露的问题就出在Go代理和Flask应用对HTTP协议中一个头部的处理逻辑差异上。我们先不急着说漏洞你得先理解这个“用户-Go-Flask”的请求流转过程这是所有后续攻击思路的基础。2. 核心目标找到获取Flag的路径既然要解题我们得先明确目标Flag藏在哪里我通常喜欢直接在源码里搜索“flag”这个关键词这往往是最快的方法。在题目提供的app.pyFlask应用的主文件里我找到了这么一段初始化代码if not User.query.first(): salt secrets.token_bytes(16) password secrets.token_bytes(32).hex() password_hash hash_password(password, salt) user User( id0, usernameadmin, password_hashpassword_hash, saltbase64.b64encode(salt).decode(utf-8), ) db.session.add(user) db.session.commit() flag open(/flag).read().strip() flagEntry VaultEntry( user_iduser.id, labelflag, loginflag, password_encryptedfernet.encrypt(flag.encode(utf-8)).decode(utf-8), notesThis is the flag entry., ) db.session.add(flagEntry) db.session.commit()这段代码逻辑很清晰。当系统第一次启动数据库里没有任何用户User.query.first()返回None的时候它会自动创建一个管理员用户。关键点来了这个管理员的id被硬编码为0。同时它会读取服务器上的一个文件/flag把里面的内容也就是我们梦寐以求的Flag加密后作为一条保险箱记录VaultEntry存进去而这条记录的user_id自然也是0。所以Flag就安静地躺在那个vault_entries数据库表里属于id0的用户。那么我们怎么才能看到这条记录呢应用提供了一个/dashboard路由也就是用户登录后的主面板。我们看看它的代码app.route(/dashboard) login_required def dashboard(): user g.current_user entries [ { id: entry.id, label: entry.label, login: entry.login, password: fernet.decrypt(entry.password_encrypted.encode(utf-8)).decode(utf-8), notes: entry.notes, created_at: entry.created_at, } for entry in user.vault_entries ] return render_template(dashboard.html, usernameuser.username, entriesentries)这个视图函数做了两件事第一它用了一个叫login_required的装饰器来确保访问者必须登录。第二它从当前登录用户g.current_user对象中取出该用户所有的保险箱条目user.vault_entries然后对每一条记录中的password_encrypted字段进行解密最后把解密后的明文密码注意这里就包括Flag和其他信息一起渲染到dashboard.html页面上。目标变得极其明确我们只需要以id0的用户身份成功访问/dashboard页面就能在页面上直接看到解密后的Flag。那么接下来的所有攻击尝试都将围绕“如何成为id0的用户”这个核心问题展开。难点在于我们如何绕过前端的各种检查让Flask应用认为我们就是那个管理员3. 第一道防线Flask的登录检查与JWT我们先来看Flask应用自己是怎么判断用户身份的。关键就在那个login_required装饰器里。我把它摘出来仔细看看def login_required(view_func): wraps(view_func) def wrapped(*args, **kwargs): uid request.headers.get(X-User, 0) print(uid) if uid anonymous: flash(Please sign in first., warning) return redirect(url_for(login)) try: uid_int int(uid) except (TypeError, ValueError): flash(Invalid session. Please sign in again., warning) return redirect(url_for(login)) user User.query.filter_by(iduid_int).first() if not user: flash(User not found. Please sign in again., warning) return redirect(url_for(login)) g.current_user user return view_func(*args, **kwargs) return wrapped这段代码是理解整个漏洞链的起点我带你一步步分析。首先它没有像传统Web应用那样去检查session或者cookie。它检查的是一个叫做X-User的HTTP请求头。request.headers.get(X-User, 0)这行代码的意思是从当前HTTP请求的头信息里找X-User这个字段。如果找到了就用它的值如果没找到就使用默认值0。这里就出现了第一个非常非常关键的信息默认值是0。也就是说如果一个请求到达Flask时它的HTTP头里根本没有X-User这一项那么Flask就会认为这个用户的uid是0也就是我们想要的管理员ID逻辑继续往下走。它拿到uid后先判断是不是anonymous如果是就踢去登录页。然后尝试把uid转换成整数转换失败也踢走。接着用这个整数uid去数据库里查询用户查不到也踢走。如果一切顺利它就把查到的user对象存到全局变量g.current_user里然后放行执行真正的dashboard视图函数。所以从Flask的视角看身份认证非常简单粗暴完全信任X-User这个头。谁在控制这个头的内容呢不是我们用户直接控制的是前面的Go反向代理服务设置的。那么我们用户是如何被识别的呢这就引出了JWT。在正常的登录流程里用户先通过用户名密码登录Go服务验证成功后会生成一个JWTJSON Web Token返回给用户的浏览器。JWT里就编码了用户的真实ID比如你注册的账号ID是5JWT里就是5。之后浏览器每次发请求都会在Authorization头里带上这个JWT。Go代理收到请求后会从JWT里把用户ID解析出来然后注意了它会把这个ID设置到X-User这个头里再转发给后端的Flask。所以一个正常请求的链条是这样的用户浏览器 - (请求带JWT) - Go代理Go代理 - (解析JWT得到uid5设置X-User: 5) - Flask应用Flask应用 - (看到X-User: 5查询id5的用户) - 返回用户5的dashboard我们的目标就是要打破这个链条让Flask看到的X-User头是空的从而触发默认值0。4. 关键的中间人Go反向代理的逻辑现在压力给到了Go代理这边。我们得看看它是怎么处理请求特别是怎么设置这个要命的X-User头的。题目的Go代理核心代码大概长这样func main() { authorizer : httputil.ReverseProxy{ Director: func(req *http.Request) { req.URL.Scheme http req.URL.Host 127.0.0.1:5000 uid : GetUIDFromRequest(req) // 从JWT中提取用户ID log.Printf(Request UID: %s, URL: %s, uid, req.URL.String()) // 删除一些可能干扰的头 req.Header.Del(Authorization) req.Header.Del(X-User) req.Header.Del(X-Forwarded-For) req.Header.Del(Cookie) if uid { req.Header.Set(X-User, anonymous) } else { req.Header.Set(X-User, uid) // 关键行重新设置X-User } }} ... }Director函数是Go反向代理在转发请求前对请求进行“导演”修改的地方。它的逻辑很清晰GetUIDFromRequest(req)这个函数会从请求的Authorization头中取出JWT验证签名并解析出载荷Payload里面的用户ID。这是真实的、我们登录后的ID。req.Header.Del(X-User)无论请求原始带没带X-User头先把它删掉。这一步很关键它杜绝了我们直接从浏览器伪造X-User头的可能性。根据解析出的uid重新设置X-User头。如果uid为空比如没登录就设为anonymous如果有值比如5就设为5。看到这里似乎路被堵死了。Go代理牢牢掌控着X-User头的生杀大权我们无法直接干预它传给Flask的值。它一定会设置一个X-User头要么是anonymous要么是我们真实的ID。我们好像没办法让它传一个空的X-User头过去。但是计算机系统里充满了各种“但是”。Go语言的标准库net/http/httputil在实现反向代理时为了严格遵守HTTP协议有一个特殊的行为。这个行为就是整个漏洞的突破口。5. 协议层的魔法HTTP逐跳头Hop-by-Hop HeadersHTTP协议里有些头部字段是专门针对单次传输连接的叫做“逐跳头”Hop-by-Hop Headers。顾名思义这些头部的信息只对当前这一“跳”比如从浏览器到代理有意义代理在转发请求给下一跳比如后端服务器时应该把它们移除不应该继续传递。常见的逐跳头包括Connection,Keep-Alive,Proxy-Authenticate,Upgrade等。而RFC标准中规定Connection头本身的内容就是用来声明哪些头是逐跳头、需要被移除的。例如一个请求头是Connection: close, X-My-Custom-Header它的意思是1) 本次传输后关闭连接close2)X-My-Custom-Header是一个逐跳头。Go标准库的ReverseProxy在转发请求前会调用一个内部函数removeHopByHopHeaders来处理这些头。这个函数的逻辑简化后如下func removeHopByHopHeaders(h http.Header) { // 1. 处理Connection头移除Connection头里列出的所有头部 for _, f : range h[Connection] { for _, sf : range strings.Split(f, ,) { sf textproto.TrimString(sf) if sf ! { h.Del(sf) // 删除被声明的逐跳头 } } } // 2. 为了兼容旧标准再移除一组已知的逐跳头列表 for _, f : range hopHeaders { h.Del(f) } }这个函数是自动执行的是ReverseProxy工作流程的一部分。它的操作顺序非常重要它首先查看请求中的Connection头。将Connection头的值按逗号分割。对分割后的每一个字段去除空白字符后将其视为一个需要被删除的头部名称然后执行h.Del(头部名称)。注意这个删除操作发生在Go代理代码的Director函数执行之后这是整个漏洞链条中最精妙的时间差。我们来梳理一下完整的时间线用户请求到达Go代理。Go执行Director函数解析JWT得到uid5删除旧的X-User头然后设置新的X-User: 5头。Go准备转发请求在最终发出前自动调用removeHopByHopHeaders函数。如果此时请求的Connection头里包含了X-User那么在这个函数里X-User头会被再次删除请求被发往后端Flask此时X-User头已经不存在了。于是我们找到了让X-User头“消失”的方法构造一个特殊的Connection头让它把X-User声明为逐跳头。6. 构造Payload让漏洞链闭合理解了原理构造Payload就水到渠成了。我们需要发送一个HTTP请求这个请求需要满足几个条件携带有效的JWT这样Go代理才能解析出uid否则会被设为anonymous而anonymous在Flask那里是通不过检查的。在Connection头里声明X-User为逐跳头。使用curl命令可以很方便地构造这样的请求。假设题目提供的访问地址是http://target.com我们登录后拿到的JWT放在Authorization头里。一个直观的尝试是curl -H Authorization: Bearer your_jwt_token_here -H Connection: X-User http://target.com/dashboard这个请求的意图是Connection: X-User告诉Go代理“X-User是个逐跳头你转发时把它删掉”。按照我们上面的分析Go代理的Director函数会先设置X-User然后removeHopByHopHeaders函数又会因为它出现在Connection头里而把它删掉。最终Flask收到一个没有X-User头的请求于是使用默认值0我们就看到了管理员的Dashboard。在实际解题时我用的Payload和这个略有不同是curl -H Connection: close,X-User http://target.com/dashboard这里Connection头的值是close,X-User。它传达了两个指令close关闭连接和X-User删除X-User头。close是Connection头本身的标准指令之一加上它完全符合协议规范不会引起任何怀疑。实际上只写Connection: X-User在很多情况下也是可行的但加上close更像一个正常的请求兼容性可能更好。这里有一个非常重要的细节我们的请求不需要手动添加Authorization头吗在真实的解题环境中你的浏览器已经通过登录获得了JWT并且JWT通常会以Cookie的形式保存。当你用curl时如果你不手动指定Authorization头curl默认不会发送浏览器的Cookie。所以上面简化的命令可能需要加上-H Authorization: Bearer ...或者使用-b参数携带Cookie具体取决于题目对JWT的校验方式。在有些题目设置中可能直接访问/dashboard路径时Go代理的GetUIDFromRequest函数无法从请求中提取出有效的uid因为没带JWT那么它就会设置X-User: anonymous导致Flask返回重定向登录。因此确保你的请求能让Go代理解析出一个非空的uid是成功的前提。在实际操作中我通常会用浏览器开发者工具复制出登录后的完整请求包括Cookie然后用curl的-H参数一个个头地复制过去或者直接用--cookie参数。7. 漏洞的深层原因与修复建议这道题完美地展示了一个“安全边界不一致”导致的漏洞。我们来回味一下整个链条Flask应用它假设X-User头是可信的并且来自前置的代理。如果头不存在它有一个“默认降级”逻辑赋值为0这本意可能是为了容错但却成了致命的后门。Go代理它负责身份验证和设置X-User头。它的代码逻辑看起来没问题解析JWT - 删除旧头 - 设置新头。但它忽略或者说无法控制标准库底层对HTTP协议的处理。Go标准库它严格遵循RFC根据Connection头来清理逐跳头。这个行为是自动的、透明的。漏洞的根源在于Flask的“默认值降级”策略与Go代理/标准库的“协议合规性清理”行为发生了不可预料的交互。Go代理认为它已经设置了正确的头但标准库在最后一刻又把它删了。Flask看到头没了就启动了降级策略给了攻击者最高权限。从防御角度可以有几个层面的修复Flask层面最直接绝对不要对关键的身份标识头使用“默认值”策略尤其是默认到一个高权限ID。如果X-User头缺失或无效应该直接拒绝请求返回401或403或者重定向到登录页而不是赋予一个默认身份。通信协议层面在微服务内部通信中使用自定义头传递敏感信息时要意识到它们可能被代理软件按照标准协议处理。可以考虑使用不会被误判为逐跳头的头部名称或者将身份信息放在HTTP Body或URL Path中需权衡其他风险。架构层面确保所有组件对协议边缘情况的理解一致。可以在Go代理的Director函数中在最后阶段显式地重新检查并确保X-User头存在或者在使用反向代理时关闭或重写其对Connection头的某些处理逻辑如果允许的话。8. 实战演练与技巧延伸光说不练假把式。在实际的CTF比赛或者渗透测试中遇到类似架构我们应该怎么系统性地思考和测试呢我分享一下我的思路。首先信息收集阶段就要判断目标是否是“反向代理后端应用”的架构。查看HTTP响应头里的Server字段、X-Powered-By字段或者观察不同路径的报错信息都可能发现线索。比如一个路径返回Go语言的错误页面另一个路径返回Python Flask的调试信息这就很典型。其次一旦怀疑存在这种架构并且前后端通过自定义头尤其是X-开头的头传递信息就要立刻想到“逐跳头”这个攻击面。测试方法很简单就是用Burp Suite或者curl在请求中尝试添加各种Connection头。一个经典的测试Payload序列可以是Connection: X-Forwarded-For测试能否删除或伪造IPConnection: X-Real-IPConnection: X-Auth-User-IDConnection: X-User本题的利用点甚至尝试Connection: Authorization如果JWT在Authorization头里尝试删除它会不会导致认证绕过测试时要对比添加Connection头前后后端应用返回的数据有何不同。比如本题中正常请求/dashboard返回的是自己账户的数据注入Connection: X-User成功后返回的就是管理员id0的数据了。另外关于JWT的绕过本题并没有直接去破解JWT的签名那是另一类题目了而是利用了代理对JWT解析结果的处理逻辑。这给我们一个启示在分析JWT认证流程时不要只盯着JWT本身的伪造如弱密钥、算法置空none还要关注整个认证链条——谁生成JWT、谁验证JWT、验证后如何把信息传递给后端、传递的通道是否安全。最后这种漏洞的利用往往需要精确的条件。在实战中你可能需要反复尝试结合源代码审计如果有的话来确认逻辑。没有源码的时候就要靠黑盒测试和根据响应行为的差异来推测内部逻辑。这道SecretVault题把漏洞原理、协议细节和实战利用结合得非常紧密是一次很好的Web安全综合能力训练。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2411479.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!