Description

Category: Crypto

Source: Insomni'Hack Teaser 2019

Points: 137

Author: Jisoon Park(js00n.park)

Description:

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.

Write-up

암호화된 음료 voucher에 대한 비밀번호를 알아내라고 한다.

flask를 이용한 python web server 코드가 주어졌으니 천천히 살펴보자.

couponCodes = {
    "water": "WATER_2019",
    "beer" : "�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽�댿뻽��" # REDACTED
}

물은 마음대로 마실 수 있지만 beer에 대한 쿠폰코드는 삭제되어 있다. 알아내라는 뜻이겠지..ㅜㅠ

voucher는 아래와 같이 만들어진다.

@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

이름과 어떤걸 마실지 json형태로 요청하면 (이름||쿠폰코드)를 쿠폰코드로 암호화 해서 voucher로 만들어 보내준다.

voucher는 PGP symmetric encrypted message 형태이다.

voucher를 사용하는 코드도 살펴보자.

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

    content = request.json
    (encryptedVoucher,passphrase) = (content['encryptedVoucher'],content['passphrase'])
    
    # Reluctantly go to the fridge...
    time.sleep(15)

    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{% raw %}{%s}{% endraw %}\n" % (recipientName, couponCode)
    else:
        abort(500)

voucher를 사용할 때는 voucher와 passphrase를 보내야 하는데, passphrase가 쿠폰코드이다.

(beer 쿠폰 코드는 모르니까)적당히 water에 대한 request를 만들어서 voucher를 요청해보면 암호화된 voucher를 받을 수 있다.

-----BEGIN DRINK VOUCHER-- jA0ECQMCAskQB8770R7/0kYBv/uvapVocJXq92CCLIC1EBmDu7NXS3Mt0RKz0PDi QQ3oDAPo4SalXyNSx98ZkRpaeS8Fvu+mbUst06MvQdTHqyAXMLmX =XhxQ -----END DRINK VOUCHER--

PGP는 데이터를 인코딩할 때 ASCII Armor를 사용한다고 한다.

ASCII Armor는 다음과 같이 구성된다.

  • An Armor Header Line, appropriate for the type of data
  • Armor Headers
  • A blank (zero-length, or containing only whitespace) line
  • The ASCII-Armored data
  • An Armor Checksum
  • The Armor Tail, which depends on the Armor Header Line

voucher에서는 Armor Headers는 생략된것 같고, =XhxQ는 Checksum이니 중간쯤의 base64 문자열이 데이터인가보다.
(정확히는 base64 인코딩된 문자열과 Checksum을 합친 Radix-64로 인코딩 되어있다.)

base64 문자열을 디코딩 해보면 raw byte 데이터를 얻을 수 있다. PGP 스펙을 들여다보고 바로 참조해도 되지만, PGP 데이터라는걸 알고 있으니 간단하게 pgpdump를 먼저 사용해보자.
(pgpdump를 사용하기 전에 voucher의 "DRINK VOUCHER" 문자열을 "PGP MESSAGE"로 바꿔줘야 한다.)

pgpdump를 이용해서 데이터를 해석해 보면, 두 개의 PGP 패킷으로 구성된 것을 알 수 있다.

먼저 Symmetric-Key Encrypted Session Key Packet 쪽을 살펴보자.

PGP 스펙5.3절을 살펴보면, 이 패킷을 이용해서 passphrase로부터 Session Key를 계산할 수 있는 방법이 적혀 있다.

pgpdump가 알려준 값들로 Iterated and slated s2k를 이용하면 되는데, 간단히 코드를 만들어 보면 아래와 같다.

def getKey(passphrase, salt, cnt, keyLen):
  iterate = salt + passphrase
  niter = cnt / len(iterate) + 1

  m = hashlib.sha1()
  m.update((iterate * niter)[:cnt])
  r = m.digest()
  if (len(r) < keyLen):
    m = hashlib.sha1()
    m.update('\x00' + (iterate * niter)[:cnt])
    r = r + m.digest()

  return r[:keyLen]

PGP 스펙에는 구현에 필요한 부분들이 명료하게 나와있지 않아서, 자세한 설명과 함께 test vector가 있는 사이트를 참조했다.

pgpdump가 session key는 AES256 알고리즘용 key라고 알려줬기 때문에 여기서 keyLen은 32를 주면 된다.

자 이제 session key로 Symmetrically Encrypted and MDC Packet을 까보자.

이 패킷에 대한 내용은 PGP 스펙5.14절에 나와있지만, 마찬가지로 구현에 충분한 내용은 아니다.
마찬가지로, 인터넷을 열심히 뒤져 이 문서를 찾아내었다.

주저리주저리 설명 보다는 아래 그림과 함께 이 문서의 2.2절을 한번 읽어보면 금방 이해할 수 있다.

이제 패킷 구조는 알았으니, session key를 이용해서 복호화를 시도해보자.

PGP는 symmetric 암호화를 할 때, 기본적으로 null-iv와 함께 CFB 모드를 사용한다고 한다. CFB 모드는 CTR 모드와 유사하게 stream cipher처럼 동작할 수 있어서 별도의 padding이 필요하지 않다.

AES256인 것도 알고, Key, IV, 운용모드까지 알았으니 간단하게 복호화를 시도해보면... 실패한다. PGP 스펙을 비롯한 온갖 문서에 따르면 decryption 했을 때 처음의 16byte는 랜덤 벡터이고, 다음 두 byte는 랜덤 벡터의 마지막 두 바이트의 반복이라고 하는데 그런 데이터가 나오지 않았다.

이 부분을 넘어가기 위해 스펙을 다시 보고, 놓친 곳이 없나 살펴보며 8~9시간 정도를 소요했다. passphrase에서 key를 유도하는 부분을 설명하는 사이트를 한 곳밖에 찾지 못해서 S2K 구현체를 찾아 그 부분을 다시 확인하는데 특히 많은 시간을 소모했으나 다행히(?) 아니었다.

한땀한땀 구현체와 데이터를 비교해보니, 문제는 늘 그렇듯 생각도 못했던 AES256-CFB decryption에 있었는데 python의 Crypto.Cipher에서 제공하는 CFB는 일반적인 CFB가 아니라 CFB8이라는 알고리즘이기 때문이었다.

Crypto.Cipher의 CFB가 일반적인 CFB처럼 동작하기 위해서는 segment_size를 조정해줘야 한다고 하는데, 그렇게 되면 AES block size에 맞는 padding이 필수라고 하여 decryption 함수를 간단히 구현하였다.

def decrypt(key, ct, iv):
  padlen = 16 - (len(ct) % 16)
  aes = AES.new(key, AES.MODE_CFB, iv, segment_size=128)
  return aes.decrypt(ct + "#"*padlen)[:len(ct)]

이제 이걸로 복호화 해보면 PGP 복호화가 완료되어 (이름||쿠폰코드)가 보이면 좋겠으나.. ASCII 문자열이 안보인다.
복호화된 데이터는 [16 byte random][2 byte 반복][plain packet][2 byte 헤더][SHA1 hash]의 모양인데, 다시 PGP 스펙을 이용해서 plain packet의 첫 byte를 한 bit씩 해석해 보면 Compressed Data Packet인 것을 알 수 있다.

PGP의 Compression은 0(Uncompressed), 1(ZIP), 2(ZLIB), 3(BZip2) 등을 지원하는데, 이 패킷은 ZIP을 사용한다. ZLIB이나 PKZIP과는 다르게 RFC1951에 정의된 DEFLATE를 이용해 압축을 풀으란 뜻이란다.

검색결과, python에서는 이렇게 하면 된다:

m = zlib.decompress(compressed, -zlib.MAX_WBITS)

자 이제 진짜 끝일거라고 생각했는데, 압축을 푼 데이터를 보면 (이름||쿠폰코드) 앞에 뭔가가 많이 붙어 있다. 찝찝하니 다시 첫 byte부터 확인해보면 Literal Data Packet이란걸 알 수 있다. (제발 이제 그만 ㅜㅠ)

다행히 앞쪽에 붙은건 header와 date 정도의 잡다한 데이터이고, payload 쪽을 추출해보면 비로소 깔끔한 모양의 원본 데이터를 만날 수 있다.

머나먼 여행을 통해 PGP 대칭키 암호화가 어떻게 진행되는지 알았으니, 이제 문제를 풀어보자. (...)

지금까지 온 과정을 돌이켜 보면, PGP 스펙에 충실하게 따라왔고 별달리 passpharse나 session key가 노출될만한 취약점은 없었던 것 같다. (있었으면 CTF는 중단하고 PGP 취약점 보고서를 작성했겠지)

beer의 쿠폰 코드를 모르기 때문에 beer voucher의 암호화를 풀 수 없는 상황이니, 가진 걸 일단 정리해보자.

  • 암호화되는 평문 데이터는 [내가 넣은 값||알아내야 할 값]이 압축된 모양이다.
  • 복호화는 일부에 대해서라도 할 수 없다.
  • voucher 검증 API에 대한 반복 시도 방법은 아니다.(서버 코드에 sleep(15)가 있으므로)
  • 암호화는 계속 시도해볼 수 있으나, 난수가 들어가므로 동일한 입력에도 매번 결과값이 바뀐다.
  • CFB 모드를 이용하므로, compressed data의 대한 길이만 알 수 있다.

첫번째와 마지막 항목을 보면 공격 포인트가 compression임을 알 수 있다.

작년 9월의 CSAW CTF에서 compression을 공격하는 유사한 flatcrypto 문제가 있었기 때문에 금방 알 수 있었다.

압축을 할 때 동일한 문자열이 반복적으로 나타나면 압축 결과의 길이가 짧아지는 특성을 이용해서 공격하면 된다.

flatcrypto 문제의 writeup을 찾아서 exploit을 적용시켜 보았다.

후후훗 "G1MME_B33RY_TH1RSTY"라는 그렇듯한 flag를 얻었다.

..하지만 인증에는 실패했다.

알고리즘을 좀 살펴본 결과, 의도치 않은 pattern으로 인해 길이가 짧아지는 부분을 대충 무시해서 그런 것 같았다. 다른 writeup을 찾아서 exploit을 적용시켜 보았다. 이 exploit은 압축 후 길이가 짧아지는 것들을 candidate으로 계속 모으는 알고리즘을 사용했다.

이 알고리즘도 flag를 명확하게 찾아주지는 못했지만, 아까의 exploit이 놓쳤던 후보 문자열들을 많이 찾아내 주었다. 이 후보 문자열들을 적당히 조합한 결과, 아래의 문자열을 얻을 수 있었다.
(FLAG가 대문자와 숫자, 언더스코어로만 구성되어있다는 힌트가 있었으면 더 예쁜 출력이 나왔겠지만, 어쨌든 찾았다.)

G1MME_B33R_PLZ_1M_S0_V3RY_TH1RSTY

이 값을 쿠폰코드로 하여 서버에 voucher 인증을 요청한 결과, 옳은 값임을 확인 할 수 있었다.

Flag : INS{G1MME_B33R_PLZ_1M_S0_V3RY_TH1RSTY}

PGP 메세지를 parsing 하면서 작성한 메모복호화 코드, 그리고 exploit을 resource에 첨부해 두었다.

'writeups > Crypto' 카테고리의 다른 글

:)  (0) 2019.11.25
Revolutional Secure Angou  (0) 2019.11.25
RSAaaay  (0) 2019.11.23
Very Smooth  (0) 2019.11.23
Simon and Speck Block Ciphers  (0) 2019.11.23

+ Recent posts