Contents

pwnable.kr: crypto1 challenge

In the pwnable.kr challenge crypto1 in the rookies section, we are given the following two files client.py and server.py:

client.py

 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
#!/usr/bin/python
from Crypto.Cipher import AES
import base64
import os, sys
import xmlrpclib
rpc = xmlrpclib.ServerProxy("http://localhost:9100/")

BLOCK_SIZE = 16
PADDING = '\x00'
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * PADDING
EncodeAES = lambda c, s: c.encrypt(pad(s)).encode('hex')
DecodeAES = lambda c, e: c.decrypt(e.decode('hex'))

# server's secrets
key = 'erased. but there is something on the real source code'
iv = 'erased. but there is something on the real source code'
cookie = 'erased. but there is something on the real source code'

# guest / 8b465d23cb778d3636bf6c4c5e30d031675fd95cec7afea497d36146783fd3a1
def sanitize(arg):
	for c in arg:
		if c not in '1234567890abcdefghijklmnopqrstuvwxyz-_':
			return False
	return True

def AES128_CBC(msg):
	cipher = AES.new(key, AES.MODE_CBC, iv)
	return EncodeAES(cipher, msg)

def request_auth(id, pw):
	packet = '{0}-{1}-{2}'.format(id, pw, cookie)
	e_packet = AES128_CBC(packet)
	print 'sending encrypted data ({0})'.format(e_packet)
	sys.stdout.flush()
	return rpc.authenticate(e_packet)

if __name__ == '__main__':
	print '---------------------------------------------------'
	print '-       PWNABLE.KR secure RPC login system        -'
	print '---------------------------------------------------'
	print ''
	print 'Input your ID'
	sys.stdout.flush()
	id = raw_input()
	print 'Input your PW'
	sys.stdout.flush()
	pw = raw_input()

	if sanitize(id) == False or sanitize(pw) == False:
		print 'format error'
		sys.stdout.flush()
		os._exit(0)

	cred = request_auth(id, pw)

	if cred==0 :
		print 'you are not authenticated user'
		sys.stdout.flush()
		os._exit(0)
	if cred==1 :
		print 'hi guest, login as admin'
		sys.stdout.flush()
		os._exit(0)

	print 'hi admin, here is your flag'
	print open('flag').read()
	sys.stdout.flush()

server.py

 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
#!/usr/bin/python
import xmlrpclib, hashlib
from SimpleXMLRPCServer import SimpleXMLRPCServer
from Crypto.Cipher import AES
import os, sys

BLOCK_SIZE = 16
PADDING = '\x00'
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * PADDING
EncodeAES = lambda c, s: c.encrypt(pad(s)).encode('hex')
DecodeAES = lambda c, e: c.decrypt(e.decode('hex'))

# server's secrets
key = 'erased. but there is something on the real source code'
iv = 'erased. but there is something on the real source code'
cookie = 'erased. but there is something on the real source code'

def AES128_CBC(msg):
	cipher = AES.new(key, AES.MODE_CBC, iv)
	return DecodeAES(cipher, msg).rstrip(PADDING)

def authenticate(e_packet):
	packet = AES128_CBC(e_packet)

	id = packet.split('-')[0]
	pw = packet.split('-')[1]

	if packet.split('-')[2] != cookie:
		return 0	# request is not originated from expected server
	
	if hashlib.sha256(id+cookie).hexdigest() == pw and id == 'guest':
		return 1
        if hashlib.sha256(id+cookie).hexdigest() == pw and id == 'admin':
                return 2
	return 0

server = SimpleXMLRPCServer(("localhost", 9100))
print "Listening on port 9100..."
server.register_function(authenticate, "authenticate")
server.serve_forever()

Analysis

Furthermore, there is a running instance of client.py at pwnable.kr on port 9006. Our goal is to connect to this service and retrieve the flag.

We can infer from the two files the following facts:

  1. The only user-controlled inputs are the username and password strings.
  2. AES-128 (default) is used in CBC mode to encrypt the string "username-password-cookie".
  3. Before the plain text is processed by AES, it is padded with NULL values (\x00) 4. The function request_auth() will show us for every supplied username and password the corresponding cipher text.
  4. The password of a user is solely the SHA256 sum of the string "username"+"cookie" (+ denotes concatenation here). Thus, the password of a user is entirely dependent only on his username and the cookie value.
  5. The initialization vector is constant.
  6. We are given credentials for the user guest: guest / 8b465d23cb778d3636bf6c4c5e30d031675fd95cec7afea497d36146783fd3a1
  7. The flag will be read by client.py if and only if the return value retrieved from server.py is neither 0 or 1.
  8. The return value retrieved from server.py will only return a value different from 0 and 1 when the username is admin and the correct password is supplied for this user.
  9. As we know from (5), the password of admin can be computed once the secret cookie value is known.
  10. The username and password can only consist of values of the following character set:  1234567890abcdefghijklmnopqrstuvwxyz-_.

So there are numerous ways of approaching this challenge and it is definitely helpful to have a good understanding of common cryptographic engineering problems and pitfalls.

I initially thought about whether it would be possible to somehow infer information about the cookie from the provided credentials of the user guest.

However, the key to the challenge is to utilize the cipher text oracle and perform a chosen-plaintext attack. Hence, we have to understand which parts of the input change the corresponding parts of the output. 

AES is a block cipher and operates on a block size of 16 bytes. Thus it is advisable to divide the cipher text into chunks of this size and choose different input parameters. Furthermore, we have to recall how our input is transformed BEFORE it is supplied as a plain text to the encryption routine.

The username is the first content of the plain text, followed by a single "-", the password, another "-" character and lastly the cookie.

When we choose for the username an input having at least 16 bytes, we note that the first 16 bytes of cipher text entirely depend on the username. For example, a username beginning with 16 "a" characters,  i.e. "aaaaaaaaaaaaaaaa", will always cause the first block of cipher text to be equal to "166827d3124ce5db2b36e803b9115a49", irrespective of the other characters of the username or the password.

When we choose exactly 15 times the character "a" as the username, we know that the first 16 bytes of the actual plain text will be  "aaaaaaaaaaaaaaa-", since the encryption routine will append a trailing "-" as a delimiter between the username and password.

Similarly, when we choose exactly 14 times the character "a" and an empty password, we know that the first 16 bytes of the actual plain text fed into AES will be "aaaaaaaaaaaaaa--". This is due to the fact that the password is empty and an additional "-" will be appended by the function request_auth as a delimiter between the password and the cookie.

Now we arrive at the point where the actual magic happens. What if we choose exactly 13 times the character "a" as the username and an empty password? As you correctly guessed, the function request_auth will append two "-" characters. Furthermore, it will append the first byte of the secret cookie value! 

Yet, as we only can see the cipher text, we do not know immediately the corresponding plain text. But we can save the 16 bytes of cipher text as a reference block and compute cipher text blocks for all possible values of the first cookie byte. As soon as the computed cipher text block is equal to the reference block, we know we have correctly guessed the cookie byte value! This whole procedure is also known as one-byte-at-a-time decryption. 

The details of the actual attack are a little bit more intricate, since we cannot directly compute cipher texts but have to rely on the request_auth function, which will append "-" characters. 

Concerning the guessing of the first cookie byte value, for the reference block we have to choose 13 times the character “-”. For the subsequent  guesses, we will have to choose a username consisting of 15 times the character "-" and the 16th character will be the guessed cookie byte value.

You are highly encouraged to work out a fully working decryption routine yourself. Once you have completed it, try a variant of the attack by keeping the username value constant and alter instead the password value.

For the sake of completeness, here you have my proposed solution:

Solution

 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
from pwn import *
import re


def sendreq(user,pw):

    conn = remote("pwnable.kr",9006)
    o = conn.recv()
    conn.sendline(user)
    o = conn.recv()
    conn.sendline(pw)
    o = conn.recv()
    conn.close()
    y = re.match(".*\((.*)\).*",o,re.DOTALL)
    block = y.group(1)
    return block

context.log_level = "WARNING"
alphabet = '1234567890abcdefghijklmnopqrstuvwxyz-_'

total_len = 4 * 16 + 13
pw = ""
cookie = ""


for i in range(total_len,-1,-1):


    referenceblocks = sendreq(i*"-", "")

    for c in alphabet:
        print("[*] Trying character: "+c)
        resp = sendreq(i*"-" + "--" + cookie + c, pw)
        if (referenceblocks[:158] == resp[:158]):
            print("hooray, the character is {0}!".format(c))
            cookie += c
            break


    print("fetched so far: "+cookie)
    if (hashlib.sha256("guest"+cookie).hexdigest() == "8b465d23cb778d3636bf6c4c5e30d031675fd95cec7afea497d36146783fd3a1"):
        print("[!!!] We found the cookie: [{0}]".format(cookie))
        break