This weekend I played my first CTF of 2023, LACTF. Organized by ACM Cyber at UCLA. It featured plenty of challenges to keep me busy for the time I was able to play across pwn, reversing, crypto, misc, and web categories. I played with a team of friends and colleagues this time and focused mostly on Crypto, Pwn and Reversing categories.
These are my solutions for the challenges rev/ctfd_plus
and crypto/guess_the_bit
.
ctfd-plus - reversing - 397 points
This challenge comes with 1 file, the binary itself. The challenge reads:
CTFd is too insufferably slow. You know why? Because they use an
SQL database that's bogged down by JOINs instead of a web scale
database like MongoDB. MongoDB is web scale. You turn it on and
it scales right up. You know what's more web scale though?
Nothing. That's right, the throughput of /dev/null is off the
charts. Behold, CTFd+, the first databaseless CTF platform.
Can you get the flag for the only challenge?
When executing the binary :
$ ./ctfd_plus
Welcome to CTFd+!
So far, we only have one challenge, which is one more than the number of databases we have.
Very Doable Pwn - 500 points, 0 solves
Can you help me pwn this program?
#include <stdio.h>
int main(void) {
puts("Bye!");
return 0;
}
Enter the flag:
lactf{flag}
Incorrect flag.
The binary itself was stripped but we’re quickly able to find the main()
function and look at the logic in Ghidra:
undefined8 main(void)
{
char cVar1;
size_t sVar2;
long index;
undefined4 *puVar3;
char userinput [256];
puts("Welcome to CTFd+!");
puts(
"So far, we only have one challenge, which is one more than the number of databases we have.\n "
);
puts("Very Doable Pwn - 500 points, 0 solves");
puts("Can you help me pwn this program?");
puts("#include <stdio.h>\nint main(void) {\n puts(\"Bye!\");\n return 0;\n}\n");
puts("Enter the flag:");
fgets(userinput,0x100,stdin);
sVar2 = strcspn(userinput,"\n");
index = 0;
puVar3 = &ciphertext;
userinput[sVar2] = '\0';
do {
cVar1 = encryptbyte(puVar3[index]);
if (cVar1 != userinput[index]) {
puts("Incorrect flag.");
return 0;
}
index = index + 1;
} while (index != 0x2f);
puts("You got the flag! Unfortunately we don\'t exactly have a database to store the solve in...")
;
return 0;
}
So it seems like the program does:
- Asks the user for the flag
- Iterate over bytes at a constant location in memory encoding them byte by byte
- Comparing the resulting byte to the user input to see if its correct.
Due to the way this happens, we should be able to inspect memory at each comparison to see what the expected byte value is. We already know the first few bytes will be lactf{
since that is the flag format. We can see in the disassembly that this comparison happens at address 0x10b
.
We can use GDB to check our assumption is true next:
$ gdb ./ctfd_plus
gdb-peda$ br *0x55555555510b
Breakpoint 1 at 0x55555555510b
gdb-peda$ r
Starting program: /root/ctf/lactf/rev/ctfd/ctfd_plus
Welcome to CTFd+!
So far, we only have one challenge, which is one more than the number of databases we have.
Very Doable Pwn - 500 points, 0 solves
Can you help me pwn this program?
#include <stdio.h>
int main(void) {
puts("Bye!");
return 0;
}
Enter the flag:
lactf{
Breakpoint 1, 0x000055555555510b in ?? ()
gdb-peda$ p $al
$1 = 0x6c
At 0x55555555510b
we break and print the value of the register al
and it is 0x6c
which is what we expect, the ASCII code for lowercase l
.
We continue through, breaking at each letter and inspecting $al
until we error out:
Breakpoint 1, 0x000055555555510b in ?? ()
gdb-peda$ p $al
$7 = 0x6d
gdb-peda$ c
Continuing.
Incorrect flag
But we already learned something, right before we errored out we learned 1 new byte of the flag, 0x6d
which is ASCII code for lowercase m
. Now we just need to add that to the input we know and repeat this process to leak each flag byte. I wrote some code to automate this:
from pwn import *
# start with just the known bytes of the flag.
attempt = 'lactf{'
# turn peda color off
open('x.gdb','w').write('peda set option ansicolor off')
while True:
pos = 0
log.warn(f"progress: {attempt}")
with context.local(log_level = 'warn'):
p = process('gdb --command=x.gdb ./ctfd_plus' , shell=True)
p.sendlineafter(b'peda$ ', b'br *0x55555555510b') # 0x55555555510b = cmp
p.sendlineafter(b'peda$ ', b'r')
p.sendlineafter(b'Enter the flag:\n', attempt.encode())
while True:
# AL register holds what the letter should be in this pos.
p.sendlineafter(b'peda$ ', b'p $al')
res = p.recvline().decode().split(' = ')[1]
res = chr(int(res,16))
if pos >= len(attempt):
attempt += res
if res == "}":
log.warn(f"Flag: {attempt}")
quit()
break
pos += 1
p.sendlineafter(b'peda$ ', b'c')
Which runs and spits out the flag for us:
$ ./solve.py
[!] progress: lactf{
[!] progress: lactf{m
[!] progress: lactf{m4
[!] progress: lactf{m4y
[!] progress: lactf{m4yb
...
[!] progress: lactf{m4yb3_th3r3_1s_s0m3_m3r1t_t0_us1ng_4_db
[!] Flag: lactf{m4yb3_th3r3_1s_s0m3_m3r1t_t0_us1ng_4_db}
Nice. I always prefer to automate GDB rather than reverse an algorithm when its not necessary.
guess-the-bit - crypto - 369 points
This challenge comes with 1 file and a network service which holds the flag. It reads:
I'm trying out for this new game show, but it doesn't seem that hard
since there are only two choices? Regardless, I heard someone name
Pollard could help me out with it?
chall.py
The script chall.py
has the source of the service running online:
import random
from Crypto.Util.number import getPrime
n = 43799663339063312211273714468571591746940179019655418145595314556164983756585900662541462573429625012257141409310387298658375836921310691578072985664621716240663221443527506757539532339372290041884633435626429390371850645743643273836882575180662344402698999778971350763364891217650903860191529913028504029597794358613653479290767790778510701279503128925407744958108039428298936189375732992781717888915493080336718221632665984609704015735266455668556495869437668868103607888809570667555794011994982530936046877122373871458757189204379101886886020141036227219889443327932080080504040633414853351599120601270071913534530651
a = 6
print("n = ", n)
print("a = ", 6)
for i in range(150):
bit = random.randrange(0,2)
c = random.randrange(0, n)
print(f"orig c = {c}")
c = c**2
if bit == 1:
c *= a
print("c = ", c)
guess = int(input("What is your guess? "))
if guess != bit:
print("Better luck next time!")
exit()
print("Congrats! Here's your flag: ")
flag = open("flag.txt", "r").readline().strip()
print(flag)
exit(0)
The service does the following:
- Chooses a random bit
0
, or1
- Chooses a large random integer
c
<n
- Squares
c
- Conditionally, if the bit it chose was
1
then it multipliesc
by6
- Prints
c
to the user and asks the user to guess the value of the bit correctly. - The user must guess the bit 150 times to get the flag.
My approach to solving this relies on the tell tale sign that, if the bit is 0
then the server will NOT mutiply c * 6
and therefore the c
should be a perfect square. Solving this involved some simple client code:
from pwn import *
from math import isqrt
def is_perfect_square(number):
return pow(isqrt(number),2) == number
p = remote("lac.tf", 31190)
p.recvlines(2) # Skip 2 lines
res = p.recvline()
l = log.progress('iteration')
m = log.progress('current guess')
count = 0
while True:
try:
c = int(res.split(b" = ")[1].decode())
except IndexError:
# We're probably at the end.
p.interactive()
l.status(f"{count}")
bit = "1"
if is_perfect_square(c):
bit = "0"
m.status(f"{bit}")
p.sendlineafter(b"What is your guess? ", bit.encode())
res = p.recvline()
if b"Better luck next time!" in res:
p.close()
quit()
count += 1
Which when we run it, gave us the flag on the first try!
$ ./solve.py
[+] Opening connection to lac.tf on port 31190: Done
[p] iteration: 150
[o] current guess: 0
[*] Switching to interactive mode
lactf{sm4ll_pla1nt3xt_sp4ac3s_ar3n't_al4ways_e4sy}
[*] Got EOF while reading in interactive
Overall I had fun dipping my toes back into CTFing and solved some good challenges. Great job UCLA team!