ふるつき

v(*'='*)v かに

UIUCTF 2020 writeup

I participated in this year's UIUCTF as a member of zer0pts. We got 5th place! The competition was well-designed and had a lot of good challenges. The other, there were a few of a guess or boring challenges and unfair operations. I hope it will be fixed in the next competition.

f:id:Furutsuki:20200720142023p:plain

[web] nookstop

There was a quite simple web page. It seems easy to get a flag. Just launch a click event for the flag button by this script: $("#flag").click(). It set a cookie clicked_flag_button=true and reloaded the page.

<html>
    <head>
   <script src="/static/cookie.js"></script>
   <script src="/static/jquery.min.js"></script>
   </head>
<body>

<!-- im so sorry -->
<div id="main">

    <pre id="output" ></pre>

    <br><br>
    <div id="buttons">
        <button type="button" id="status">Network Status</button> 
        <div>
            <button type="button" id="flag" style="margin-left: 300px">Flag</button> 
        </div>
    </div>

    
        <pre>Not that easy c: but here's a crumb: uiuctf{w</pre>
        

        

</div>

<script>

async function print(m, ms) {
   $("#output").append("\n");
   for (var i=0; i < m.length; i++)
   {
       var sel = $("#output");
       sel.append(m[i]);
       await sleep(ms);
   }
   $("#output").append("\n");
}

var msg1 = "Welcome to the Nook Stop, a magical multimedia terminal from Nook Inc.\nPlease select one of the following services:\n1) Network status\n2) Flag";
var msg2 = "Ah, sorry!! Our banking systems run on FORTRAN and they're a bit \non the slow side today.............................................\nyou know how this game's netcode can be sometimes, ahahahahaha!\nAlthought, come to think of it, they did say they were rolling out \nsome kind of new system the other day.......anyway, seems like that flag button isn't working right now."

$(document).ready(function(){

   $("#flag").click(function(){
       console.log("TODO: シークレット_バックエンド_サービスを可能にする");
       document.cookie = "clicked_flag_button=true";
       location.reload();
   });

   f = $("#flag");

   f.mousemove(function(){
       var r = Math.random()*100;
       if (r < 25)
           f.css("margin-left", "+=100px");
       else if (r >= 25 && r < 50)
           f.css("margin-left", "-=30px");
       else if (r >= 50 && r < 75)
           f.css("margin-top", "+=50px");
       else if (r >= 75 && r < 97)
           f.css("margin-top", "-=50px");
       else
           f.remove();
   });


   $("#status").click(function(){
       print(msg2,100);
   });

   print(msg1,10);
});

</script>



</body>
</html>

Then the following line is appended to HTML.

Not that easy c: but here's a crumb: uiuctf{w

To get full of the flag, we should set additionally cookie secret_backend_service=true according to the log of the console. Then got a flag! uiuctf{wait_its_all_cookies?}

[crypto] isabelles_file_encryption

The following script was a challenge. Also blackmail_encrypted was given.

#!/usr/bin/python

# password-protect your files with this super powerful encryption!
def super_secret_encryption(file_name, password):
  with open(file_name, "rb") as f:
    plaintext = f.read()
  
  assert(len(password) == 8) # I heard 8 character long passwords are super strong!
  assert(password.decode("utf-8").isalpha()) # The numbers on my keyboard don't work...
  assert(b"Isabelle" in plaintext) # Only encrypt files Isabelle has been mentioned in
  add_spice = lambda b: 0xff & ((b << 1) | (b >> 7))
  ciphertext = bytearray(add_spice(c) ^ password[i % len(password)] for i, c in enumerate(plaintext))

  with open(file_name + "_encrypted", "wb") as f:
    f.write(ciphertext)

# use this to decrypt the file with the same password!
def super_secret_decryption(file_name, password):
  with open(file_name + "_encrypted", "rb") as f:
    ciphertext = f.read()
  
  remove_spice = lambda b: 0xff & ((b >> 1) | (b << 7))
  plaintext = bytearray(remove_spice(c ^ password[i % len(password)]) for i, c in enumerate(ciphertext))

  with open(file_name + "_decrypted", "wb") as f:
    f.write(plaintext)

with open("password", "rb") as f: # I got too lazy typing it in each time
    password = f.read()
    # Make sure to encrypt the text in the middle!!!
    super_secret_encryption("blackmail", password)
    super_secret_decryption("blackmail", password)

It must be included in the plaintext that the spice added (= bit rotated) Isabelle and the key must be alphabetical 8 bytes. So using like the following script, we can enumerate list of (some rotation of) the key. And easily decrypt the blackmail by the given super_secret_decryption function.

from ptrlib import xor


add_spice = lambda b: 0xff & ((b << 1) | (b >> 7))
plaintext = bytes(add_spice(b) for b in b"Isabelle")

ciphertext = open("blackmail_encrypted", "rb").read()
for i in range(0, len(ciphertext) - 8):
    key = xor(plaintext, ciphertext[i:])
    try:
        if key.decode().isalpha():
            print(repr(key))
    except UnicodeDecodeError:
        pass

The key was iSaBelLE and the flag was in the mail: uiuctf{winner_winner_raccoon_dinner}

[crypto] coelacanth_vault

coelacanth_vault.py

#!/usr/bin/python
import random
from Crypto.Util import number
from functools import reduce

TOTAL = 15
THRESHOLD = 10
MAX_COELACANTH = 9

NUM_LOCKS = 5
NUM_TRIES = 250

# substitute for math.prod
prod = lambda n: reduce(lambda x, y: x*y, n)

def create_key(t, n, size=8):
    while True:
        seq = sorted([number.getPrime(size) for _ in range(TOTAL)])
        if len(set(seq)) != len(seq):
            continue

        alpha = prod(seq[:t])
        beta = prod(seq[-t + 1:])
        if alpha > beta:
            secret = random.randint(beta, alpha)
            shares = [(secret % num, num) for num in seq]
            return secret, shares

def construct_key(shares):
    glue = lambda A, n, s=1, t=0, N=0: (n < 2 and t % N or glue(n, A % n, t, s - A//n * t, N or n), -1)[n < 1]
    mod = prod([m for s, m in shares])
    secret = sum([s * glue(mod//m, m) * mod//m for s, m in shares]) % mod
    return secret

if __name__ == "__main__":
    print("Hi, and welcome to the virtual Animal Crossing: New Horizon coelacanth vault!")
    print("There are {} different cryptolocks that must be unlocked in order to open the vault.".format(NUM_LOCKS))
    print("You get one portion of each code for each coelacanth you have caught, and will need to use them to reconstruct the key for each lock.")
    print("Unfortunately, it appears you have not caught enough coelacanths, and thus will need to find another way into the vault.")
    print("Be warned, these locks have protection against brute force; too many wrong attempts and you will have to start over!")
    print("")

    num_shares = abs(int(input("How many coelacanth have you caught? ")))
    if num_shares > MAX_COELACANTH:
        print("Don't be silly! You definitely haven't caught more than {} coelacanth!".format(MAX_COELACANTH))
        print("Come back when you decide to stop lying.")
        quit()

    for lock_num in range(NUM_LOCKS):
        print("Generating key for lock {}, please wait...".format(lock_num))
        secret, shares = create_key(THRESHOLD, TOTAL)
        r_secret = construct_key(random.sample(shares, THRESHOLD))
        assert(secret == r_secret)
        print("Generated!")
        
        print("Here are your key portions:")
        print(random.sample(shares, num_shares))

        for num_attempts in range(NUM_TRIES):
            n_secret = abs(int(input("Please input the key: ")))
            if secret == n_secret:
                print("Lock {} unlocked with {} failed attempts!".format(lock_num, num_attempts))
                break
            else:
                print("Key incorrect. You have {} tries remaining for this lock.".format(NUM_TRIES - num_attempts))        
        else:
            print("BRUTE FORCE DETECTED. LOCKS SHUT DOWN.")
            print("Get out. You don't deserve what's in this vault.")
            quit()
    
    print("Opening vault...")
    with open("flag.txt", "r") as f:
        print(f.read())

It makes some of the shares by getting the remainder by the prime modulus of the secret. So it is recoverable by the Chinese Remainder Theorem. It seems impossible for the lack of shares, however, the 8-bit primes is at most 251 and we have 250 times to challenge. Thus we can pick a one of the large (and not in the given share) prime and bruteforce to hit the correct remainder.

The solution script:

from ptrlib import Socket, crt

NUM_LOCKS = 5
NUM_TRIES = 250

sock = Socket("chal.uiuc.tf", 2004)
sock.sendlineafter("caught? ", "9")

primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251][::-1]

for i in range(NUM_LOCKS):
    print(f"stage {i+1}")
    shares = eval(sock.recvlineafter("portions:\n").decode())

    for p in primes:
        flag = True
        for s in shares:
            if s[1] == p:
                flag = False
                break
        if flag:
            prime = p
            break

    for x in range(251):
        v, _ = crt(shares + [(x, prime)])
        sock.sendlineafter("key: ", str(v))
        if b"unlocked" in sock.recvline():
            break
sock.interactive()

the flag was uiuctf{small_oysters_expire_quick}

[rev] Redd's Art

We're given the 64bit ELF binary. The point was sub_A5A. It does xor bytes in address 973 by the key: the result of sub_91A which was sum of string uiuctf{v3Ry_r341_@rTT}. However the modification of .text causes a segmentation fault. So I wrote the modification script

data = [0x4B,0x56,0x97,0xFB,0x4D,0x56,0x9D,0xF2,0x36,0x56,0xD9,0x5B,0xF6,0x17,0x1E,0x1E,0x1E,0x56,0x95,0x5B,0xF6,0x11,0xA8,0x1E,0x56,0x11,0xA0,0xDE,0x56,0x1F,0x5B,0xF6,0x56,0x95,0x5B,0xF6,0x11,0xA8,0x1E,0x96,0x5B,0xC5,0xD9,0x5B,0xC2,0x1E,0x1E,0x1E,0x1E,0xF5,0x2C,0x56,0x95,0x0B,0x65,0x08,0x3E,0x1E,0x95,0x5B,0xC2,0x56,0x86,0x56,0x1F,0xCE,0x11,0xA8,0x1E,0x97,0xDC,0x11,0xA8,0x5B,0xC5,0x93,0x12,0x1C,0x56,0x95,0x0B,0x7E,0x08,0x3E,0x1E,0x95,0x5B,0xC2,0x56,0x86,0x56,0x1F,0xCE,0x97,0xD4,0x96,0x0E,0x9D,0x5B,0xC2,0x1F,0x95,0x5B,0xC2,0x56,0x7D,0xC6,0x56,0x95,0x1B,0x5D,0x08,0x3E,0x1E,0x56,0x97,0xD9,0xF6,0xAD,0xE3,0xE1,0xE1,0x56,0x27,0xDD,0x6C,0xAA,0xA6,0x1E,0x1E,0x1E,0x1E,0xF6,0x00,0xE1,0xE1,0xE1,0x97,0x5B,0xFA,0xD9,0x5B,0xFE,0x1E,0x1E,0x1E,0x1E,0xF5,0x2E,0x56,0x95,0x0B,0x07,0x08,0x3E,0x1E,0x95,0x5B,0xFE,0x56,0x86,0x56,0x1F,0xCE,0x11,0xA8,0x16,0x95,0x5B,0xFA,0x97,0xD8,0x56,0x95,0x0B,0x1C,0x08,0x3E,0x1E,0x95,0x5B,0xFE,0x56,0x86,0x56,0x1F,0xCE,0x2F,0xEF,0x97,0xD4,0x96,0x0E,0x9D,0x5B,0xFE,0x1F,0x95,0x5B,0xFE,0x56,0x7D,0xC6,0x56,0x95,0x1B,0xFD,0x0B,0x3E,0x1E,0x56,0x97,0xD9,0xF6,0x4D,0xE3,0xE1,0xE1,0x56,0x27,0xDD,0x6C,0xA8,0x8E,0x56,0x9D,0xDA,0x36,0x45,0x43]

key = sum(ord(c) for c in "uiuctf{v3Ry_r341_@rTT}")
xored = [(d^key)&0xff for d in data]


data = bytearray(open("ReddsArt", "rb").read())

data = data[:0x973] + bytes(xored) + data[0x973 + len(xored):]
open("modified", "wb").write(data)

Then It tuned out that the sub_973 does add some constant x to the each byte of string at 0x202028, and after that, xor them by the key which calculated by sub_91A.

The unknown x was only 1 byte. So I brute-forced all candidates.

key = sum(ord(c) for c in "uiuctf{v3Ry_r341_@rTT}")
xs = [ord(c) for c in "hthzgubI>*ww7>z+Ha,m>W,7z+hmG`"]

for k in range(256):
    ys = "".join(chr((((k+x) % 256) ^ key) & 0xff) for x in xs)
    print(repr(ys))

the flag was: uiuctf{R_3dd$_c0Uz1n_D1$c0unT}