We are given the following code, a slight advance on the previous PhaseStream challenges as we are only given two ciphertexts, and no plaintext.
from Crypto.Cipher import AES
from Crypto.Util import Counter
import os
KEY = os.urandom(16)
def encrypt(plaintext):
cipher = AES.new(KEY, AES.MODE_CTR, counter=Counter.new(128))
ciphertext = cipher.encrypt(plaintext)
return ciphertext.hex()
with open('test_quote.txt', 'rb') as f:
test_quote = f.read().strip()
print(encrypt(test_quote))
with open('flag.txt', 'rb') as f:
flag = f.read().strip()
print(encrypt(flag))
The code encrypts two messages with the same key using AES in Counter mode. As we established in the previous PhaseStream challenges counter mode is bad! Because these two plaintexts are encrypted with the same key in counter mode, it essentially becomes an XOR...apparently.
Borrowing from someone who actually knows what they're talking about:
Because the CTR nonce wasn't randomized for each encryption, each ciphertext has been encrypted against the same keystream. This is very bad.
Understanding that, like most stream ciphers (including RC4, and obviously any block cipher run in CTR mode), the actual "encryption" of a byte of data boils down to a single XOR operation, it should be plain that:
CIPHERTEXT-BYTE XOR PLAINTEXT-BYTE = KEYSTREAM-BYTE
And since the keystream is the same for every ciphertext:
CIPHERTEXT-BYTE XOR KEYSTREAM-BYTE = PLAINTEXT-BYTE (ie, "you don't say!")
Attack this cryptosystem piecemeal: guess letters, use expected English language frequence to validate guesses, catch common English trigrams, and so on.
So, we have two unknown plaintexts, p1
and p2
that correspond to test_quote.txt
and flag.txt
, and two ciphertexts c1
and c2
which correspond to the encrypted versions of p1
and p2
, respectively. They share the keystream, k
. Given this, we can work out the key and thus plaintext with the following operation:
p1
xor k
== c1
and p2
xor k
== c2
Therefore,
c1
xor p1
== k
, and once we know k
we can easily get p2
by doing c2
xor k
.
ThisXOR(%7B'option':'UTF8','string':'%3CP1%20HERE%3E'%7D,'Standard',false)XOR(%7B'option':'Hex','string':'%3CC2%20HERE%3E'%7D,'Standard',false)&input=PFBVVCBDMSBIRVJFPg) CyberChef recipe demonstrates that, which we'll use later.
But how to we do this? We just, just, need to guess the plaintext, which will allow us to recover the key and hence the plaintext of both messages.
We will start from thisXOR(%7B'option':'UTF8','string':''%7D,'Standard',false)XOR(%7B'option':'Hex','string':'2d0fb3a56aa66e1e44cffc97f3a2e030feab144124e73c76d5d22f6ce01c46e73a50b0edc1a2bd243f9578b745438b00720870e3118194cbb438149e3cc9c0844d640ecdb1e71754c24bf43bf3fd0f9719f74c7179b6816e687fa576abad1955'%7D,'Standard',false)&input=Mjc2Nzg2OGI3ZWJiN2Y0YzQyY2ZmZmE2ZmZiZmIwM2JmM2I4MDk3OTM2YWUzYzc2ZWY4MDNkNzZlMTE1NDY5NDcxNTdiY2VhOTU5OWY4MjYzMzg4MDdiNTU2NTVhMDU2NjY0NDZkZjIwYzhlOTM4N2IwMDQxMjllMTBkMThlOWY1MjZmNzFjYWJjZjIxYjQ4OTY1YWUzNmZjZmVlMWU4MjBjZjEwNzZmNjU) recipe. ThisXOR(%7B'option':'UTF8','string':''%7D,'Standard',false)XOR(%7B'option':'Hex','string':'2767868b7ebb7f4c42cfffa6ffbfb03bf3b8097936ae3c76ef803d76e11546947157bcea9599f826338807b55655a05666446df20c8e9387b004129e10d18e9f526f71cabcf21b48965ae36fcfee1e820cf1076f65'%7D,'Standard',false)&input=MmQwZmIzYTU2YWE2NmUxZTQ0Y2ZmYzk3ZjNhMmUwMzBmZWFiMTQ0MTI0ZTczYzc2ZDVkMjJmNmNlMDFjNDZlNzNhNTBiMGVkYzFhMmJkMjQzZjk1NzhiNzQ1NDM4YjAwNzIwODcwZTMxMTgxOTRjYmI0MzgxNDllM2NjOWMwODQ0ZDY0MGVjZGIxZTcxNzU0YzI0YmY0M2JmM2ZkMGY5NzE5Zjc0YzcxNzliNjgxNmU2ODdmYTU3NmFiYWQxOTU1) recipe is the same, but swaps the plaintext we're guessing.
A decent starting point for guessing the plaintext is that the flag starts with CHTB{
, so we enter this into the recipe with the encrypted flag (c2
) as the input.
So, when we enter the key as CHTB{
, the output begins with I alo
(only the first 5 characters are relevant because we've only entered 5 characters of key!). We're looking for English words, and I alo
looks like English! The rest of this is going to continue making educated guesses about either plaintext. This is shown in thisXOR(%7B'option':'UTF8','string':'CHTB%7B'%7D,'Standard',false)XOR(%7B'option':'Hex','string':'2d0fb3a56aa66e1e44cffc97f3a2e030feab144124e73c76d5d22f6ce01c46e73a50b0edc1a2bd243f9578b745438b00720870e3118194cbb438149e3cc9c0844d640ecdb1e71754c24bf43bf3fd0f9719f74c7179b6816e687fa576abad1955'%7D,'Standard',false)&input=Mjc2Nzg2OGI3ZWJiN2Y0YzQyY2ZmZmE2ZmZiZmIwM2JmM2I4MDk3OTM2YWUzYzc2ZWY4MDNkNzZlMTE1NDY5NDcxNTdiY2VhOTU5OWY4MjYzMzg4MDdiNTU2NTVhMDU2NjY0NDZkZjIwYzhlOTM4N2IwMDQxMjllMTBkMThlOWY1MjZmNzFjYWJjZjIxYjQ4OTY1YWUzNmZjZmVlMWU4MjBjZjEwNzZmNjU) recipe.
(Also, given the filename of test_quote.txt
for the non-flag plaintext, it should definitely be English)
Now, given the output of us guessing the plaintext of the flag is the plaintext of the quote p1
, we can now guess the rest of the second word of this plaintext by copying the I alo
to the start of the key for this recipe. We can guess that the second word might be alone
, so let's put that in and see what happens! This might get confusing because we keep swapping between the plaintext we are guessing, but it's easy. This is shown hereXOR(%7B'option':'UTF8','string':'I%20alone%20'%7D,'Standard',false)XOR(%7B'option':'Hex','string':'2767868b7ebb7f4c42cfffa6ffbfb03bf3b8097936ae3c76ef803d76e11546947157bcea9599f826338807b55655a05666446df20c8e9387b004129e10d18e9f526f71cabcf21b48965ae36fcfee1e820cf1076f65'%7D,'Standard',false)&input=MmQwZmIzYTU2YWE2NmUxZTQ0Y2ZmYzk3ZjNhMmUwMzBmZWFiMTQ0MTI0ZTczYzc2ZDVkMjJmNmNlMDFjNDZlNzNhNTBiMGVkYzFhMmJkMjQzZjk1NzhiNzQ1NDM4YjAwNzIwODcwZTMxMTgxOTRjYmI0MzgxNDllM2NjOWMwODQ0ZDY0MGVjZGIxZTcxNzU0YzI0YmY0M2JmM2ZkMGY5NzE5Zjc0YzcxNzliNjgxNmU2ODdmYTU3NmFiYWQxOTU1), with a key (actually the plaintext, but ignore that) of I alone
, we get an output of CHTB{str
- which looks good. We make a sensible guess until we make a word, or part of a word in the plaintext output, then copy the key that got that into the other recipe and keep guessing.
This word might be stream
, given that that is the kind of cipher we're performing cryptoanalysis on! So we go back to the other recipe, use a key of CHTB{stream_
, and get an output for the quote of I alone cann
. This still looks good, could it be the word 'cannot'? Spoiler. It is! I'm not going to carry on showing the various stages with recipes, but the stages I went through of both plaintexts are below.
QUOTE: I alone cann
FLAG: CHTB{stream_
QUOTE: I alone cannot
FLAG: CHTB{stream_cip
QUOTE: I alone cannot chang
FLAG: CHTB{stream_ciphers_
QUOTE: I alone cannot change
FLAG: CHTB{stream_ciphers_wi
Okay, I actually just googled I alone cannot change
at this point and got the following full quote I alone cannot change the world, but I can cast a stone across the water to create many ripples
. Sticking this in gives CHTB{stream_ciphers_with_reused_keystreams_are_vulnerable_to_known_plaintext_attacks}
- which is the flag!
In summary, AES in Counter mode is bascially a stream cipher - for cryptograhy reasons this is bad. This is super bad if we've encrypted multiple messages with the same key! So, don't do that.
I did the math!