Todays challenge called “Easy Peasy” from picoCTF is about breaking an one-time pad encrypted flag.
If implemented correctly, a one-time pad encryption is impossible to break without the right key.
From the challenge description we get a server address we can connect to via nc
and a file with the python code which is running on the server. So the only way to get the flag is that the implementation of the one-time pad encryption has a flaw.
How does one-time pad encryption work?
For one-time pad encryption you need a cleartext to encrypt and a key which is at least as long as the clear text.
Imagine you have the cleartext “This is a secret message” which has a length of 24 characters. To encrypt this message, you need a key with at least 24 characters. Let’s choose “My super secret otp key!” as key.
In order to encrypt the text, you have to XOR every character of the cleartext with the character of the key at the same position. Because of the fact that you can only XOR numbers and not characters, you can XOR the ASCII representation of each character.
To decrypt the message, you simply XOR every number again with the corresponding ASCII value of the key.
Here a short python script which does the encryption and decryption:
cleartext = "This is a secret message"
key = "My super secret otp key!"
if ( len(key) <= len(cleartext) ) :
cipher = []
# Encryption
for i in range(len(cleartext)) :
cipher.append(ord(cleartext[i]) ^ ord(key[i]))
print("Chipher as array of integers:", cipher)
# Decryption
output = []
for i in range(len(cipher)) :
output.append(chr(cipher[i] ^ ord(key[i])))
print("Back to the original message:", "".join(output))
The essential of one-time pad is, that the key is secret and – as the name says – is only used once. If an attacker is able to re-use the key a second time with a known cleartext the resulting cipher can be XORed with the known cleartext to get the key.
Inspecting the source
Let’s have a look on the given sourcecode:
#!/usr/bin/python3 -u
import os.path
KEY_FILE = "key"
KEY_LEN = 50000
FLAG_FILE = "flag"
def startup(key_location):
flag = open(FLAG_FILE).read()
kf = open(KEY_FILE, "rb").read()
start = key_location
stop = key_location + len(flag)
key = kf[start:stop]
key_location = stop
result = list(map(lambda p, k: "{:02x}".format(ord(p) ^ k), flag, key))
print("This is the encrypted flag!\n{}\n".format("".join(result)))
return key_location
def encrypt(key_location):
ui = input("What data would you like to encrypt? ").rstrip()
if len(ui) == 0 or len(ui) > KEY_LEN:
return -1
start = key_location
stop = key_location + len(ui)
kf = open(KEY_FILE, "rb").read()
if stop >= KEY_LEN:
stop = stop % KEY_LEN
key = kf[start:] + kf[:stop]
else:
key = kf[start:stop]
key_location = stop
result = list(map(lambda p, k: "{:02x}".format(ord(p) ^ k), ui, key))
print("Here ya go!\n{}\n".format("".join(result)))
return key_location
print("******************Welcome to our OTP implementation!******************")
c = startup(0)
while c >= 0:
c = encrypt(c)
The program reads the flag and the key from files on the server at startup. The key has a size of 50000 bytes.
After reading the flag and the key, the flag is encrypted with the first bytes of the key and the encrypted flag is reflected to us. Now we can encrypt our own messages.
But what happans if we try to encrypt more text than the lenght of the key?
Exploiting picoCTF easy peasy
On challenges like this I like to use Pwntools because it makes the communication with the server a lot easier.
It also have many advantages if you are dealing with binaries, but for now we don’t need this functionality.
If you haven’t installed Pwntools yet, here you get a detailed instruction on how to do it.
I’ve commented every major step of the python script so that you are able to follow the steps that are nessessary to exploit the server and get the flag.
from pwn import *
import re
key_len = 50000
conn = remote('mercury.picoctf.net', 20266)
# Receive the encrypted flag
conn.recvuntil(b'This is the encrypted flag!\n')
flag_xor = conn.recvline().decode('utf-8').strip()
flag_len = len(flag_xor)//2
print(f"Flag encrypted: {flag_xor}")
print(f"Length of flag: {flag_len}")
# Send key_len - flag_len bytes to return to the start of the key file
conn.recvuntil(b'What data would you like to encrypt?')
conn.send(b'a'*(key_len - flag_len) + b'\n')
# Send flag_len nullbytes to get the key
conn.recvuntil(b'What data would you like to encrypt?')
conn.send(b'\x00'*(flag_len)+b'\n')
conn.recvuntil(b'Here ya go!\n')
key_string = conn.recvline().decode('utf-8').strip()
print(f"Key: {key_string}")
# Split the key into a list where each item has two characters
key_parts = re.findall('..', key_string)
# Split the xored flag also into a list like the key before
flag_parts_xor = re.findall('..', flag_xor)
# xor flag with key
flag = ""
for i in range(flag_len):
flag += chr(int(flag_parts_xor[i], 16) ^ int(key_parts[i], 16))
# wrap the flag
flag = "picoctf{" + flag + "}"
print(f"Flag decrypted: {flag}")
Now you know, why it is not a good idea to use a one-time pad twice.
Please leave a comment how you liked my write-up on the picoCTF easy peasy challenge.