Security if God wills it

Drinks Writeup (Insomnihack Teaser 2019)

Drinks was the only cryptography challenge of Insomni'hack Teaser 2019 and ended up being solved by over 30 teams. Despite this apparent "easiness", it featured a clever side-channel attack I never had the chance to try out before.

Challenge description

Use this API to gift drink vouchers to yourself or your friends!
Vouchers are encrypted and you can only redeem them if you know the passphrase.
Because it is important to stay hydrated, here is the passphrase for water: WATER_2019.
Beers are for l33t h4x0rs only.

Along with the description came a link to a service, as well as its source code.

Code analysis

The provided code is the following:

from flask import Flask,request,abort
import gnupg
import time
app = Flask(__name__)
gpg = gnupg.GPG(gnupghome="/tmp/gpg")

couponCodes = {
    "water": "WATER_2019",
    "beer" : "█████████████████████████████████" # REDACTED

@app.route("/generateEncryptedVoucher", methods=['POST'])
def generateEncryptedVoucher():

    content = request.json
    (recipientName,drink) = (content['recipientName'],content['drink'])

    encryptedVoucher = str(gpg.encrypt(
        "%s||%s" % (recipientName,couponCodes[drink]),
        recipients  = None,
        symmetric   = True,
        passphrase  = couponCodes[drink]
    )).replace("PGP MESSAGE","DRINK VOUCHER")
    return encryptedVoucher

@app.route("/redeemEncryptedVoucher", methods=['POST'])
def redeemEncryptedVoucher():

    content = request.json
    (encryptedVoucher,passphrase) = (content['encryptedVoucher'],content['passphrase'])

    # Reluctantly go to the fridge...

    decryptedVoucher = str(gpg.decrypt(
        encryptedVoucher.replace("DRINK VOUCHER","PGP MESSAGE"),
        passphrase = passphrase
    (recipientName,couponCode) = decryptedVoucher.split("||")

    if couponCode == couponCodes["water"]:
        return "Here is some fresh water for %s\n" % recipientName
    elif couponCode == couponCodes["beer"]:
        return "Congrats %s! The flag is INS{%s}\n" % (recipientName, couponCode)

if __name__ == "__main__":'')

A quick glance at the code is enough to figure out the goal is to retrieve the value of couponCodes["beer"].

The service is a Flask app with two routes accepting POST requests containing JSON-encoded data: /generateEncryptedVoucher and /redeemEncryptedVoucher.

generateEncryptedVoucher basically creates a PGP encrypted message consisting of the string <recipientName>||<couponCode> where recipientName is a string or arbitrary length controlled by the attacker and couponCode is the string in couponCodes[drink], where drink is either "beer" or "water", and controlled by the attacker. The encryption is symmmetric, and couponCodes[drink] is also used as the passphrase for the encryption.

redeemEncryptedVoucher takes a PGP encrypted message and a passphrase, which it uses to decrypt the message. Then, if the <couponCode> part of the decrypted message is the same string as couponCodes["beer"], it is printed along with a congratulations message.

It is probably safe to assume that the length of couponCodes["beer"] within the provided code is not arbitrary, and therefore we assume that the flag is 33 characters long.

It's the first time I heard of the symmetric mode in PGP (with which I'm not overly familiar), I needed more context in order to understand how this could possibly be exploited.

The gnupg Python library

By following the trail of function calls within the GPG class, I ended up finding interesting information looking at the parameters of the _encrypt method in the parent class of gnupg.GPG, namely GPGBase.

def _encrypt(self, data, recipients,
:param str cipher_algo: The cipher algorithm to use. To see available
                        algorithms with your version of GnuPG, do:
                        :command:`$ gpg --with-colons --list-config
                        ciphername`. The default **cipher_algo**, if
                        unspecified, is ``'AES256'``.

:param bool symmetric: If True, encrypt the **data** to **recipients**
                       using a symmetric key. See the **passphrase**
                       parameter. Symmetric encryption and public key
                       encryption can be used simultaneously, and will
                       result in a ciphertext which is decryptable
                       with either the symmetric **passphrase** or one
                       of the corresponding private keys.

OK, now we know that AES256 is used by default when the symmetric mode is enabled. I am more familiar with AES than I am with PGP, but this is bad news: I am pretty sure there is no way to break AES here (as of today, at least), unless maybe ECB mode is used (which I doubt). Maybe we should parse and analyze what the service produces when generating an encrypted voucher.

Ciphertext analysis

We issue the following command:

~$ curl -Ns -H 'Content-Type: application/json' -X POST "" --data '{"recipientName":"xxxx","drink":"water"}' | sed 's/DRINK VOUCHER/PGP MESSAGE/g' > voucher

and obtain a file containing a PGP encrypted message. Using pgpdump on the file, we get the following output:

~$ pgpdump voucher
Old: Symmetric-Key Encrypted Session Key Packet(tag 3)(13 bytes)
    New version(4)
    Sym alg - AES with 256-bit key(sym 9)
    Iterated and salted string-to-key(s2k 3):
        Hash alg - SHA1(hash 2)
        Salt - 73 77 83 1a ef 53 e6 6e 
        Count - 65011712(coded count 255)
New: Symmetrically Encrypted and MDC Packet(tag 18)(67 bytes)
    Ver 1
    Encrypted data [sym alg is specified in sym-key encrypted session key]
        (plain text + MDC SHA1(20 bytes))

Wooow, lots of new things here.

Thanks to the great RFC 4880, we get more information about what's going on. Basically, we learn that:

  • AES256 is used in CFB mode;
  • the IV is constant but the key is salted and then hashed several times. Salt obviously changes at every run.

At that point, I have no clue about why this could be breakable. After spending a few hours spacing out on my couch trying to figure this out, it dawned on me. There is a parameter of the _encrypt method that I should have paid more attention to.

Side-channel attacks FTW

Yep, there is a compress_algo parameter in _encrypt whose default value is 'ZLIB'. The plaintext is compressed before being encrypted, which means that we might be able to leak the flag by choosing appropriate values for recipientName: if part or all of our recipientName is contained in the flag, we can expect the encrypted message to be smaller than if it isn't. To be perfectly honest, I was wondering if that was actually a new class of attacks. Quickly looking it up revealed that it was not, as CRIME and BREACH are actually based on this principle (and while the names are famous, I had never bothered actually looking them up).

To try out this attack, we first need to determine a 3 character string (limit I discovered experimenting with a few requests), which we can easily do since we know that the flag is prefixed by "||" in the plaintext string. And then, it's just a matter of running the following quick and dirty code, updating pw manually at every round:

# Was pretty tired, so that's the code I used to flag the challenge and I'm just too lazy to rewrite it :)
for c in `seq 1 33`; do
    for i in `cat charset`; do
        l=`curl -Ns -H 'Content-Type: application/json' -X POST "" --data "{\"recipientName\":\"$tmp\",\"drink\":\"beer\"}" | sed 's/DRINK VOUCHER/PGP MESSAGE/g' | pgpdump | grep 'New:'`;
        echo $i $l;
    read s;

And we get the flag, which once formatted as required is: INS{G1MME_B33R_PLZ_1M_S0_V3RY_TH1RSTY}


I learned a lot about PGP and this side-channel attack doing this challenge. Thanks a lot to the organizers for a great time this year again!

comments powered by Disqus

Receive Updates