Summary: Choosing the value of the prime modulus - 1 as the base in a pseudo Diffie Hellman key exchange scheme allows setting a shared value to 1. When this value is used as an exponent for a validation value and the construction of an AES key, it allows us to bypass verification and reduce the search space of AES decryption key.

Challenge Prompt

gipfel
by yyyyyyy
crypto baby
Difficulty estimate: easy - easy

Points: round(1000 · min(1, 10 / (9 + [109 solves]))) = 85 points

Description:
Hey, I heard you’re good with computers! So… Thing is, I forgot my password. Can you help??

Update: Due to popular request, we’ve reduced the proof-of-work difficulty a little bit.

Download:
gipfel-f1733d57c1257d22.tar.xz (2.1 KiB)

Connection (mirrors):
nc 65.108.176.66 1088

Attachment: challenge file

Solution

We are given the following script:

#!/usr/bin/env python3
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
import signal, random
random = random.SystemRandom()

q = 0x3a05ce0b044dade60c9a52fb6a3035fc9117b307ca21ae1b6577fef7acd651c1f1c9c06a644fd82955694af6cd4e88f540010f2e8fdf037c769135dbe29bf16a154b62e614bb441f318a82ccd1e493ffa565e5ffd5a708251a50d145f3159a5

def enc(a):
    f = {str: str.encode, int: int.__str__}.get(type(a))
    return enc(f(a)) if f else a

def H(*args):
    data = b'\0'.join(map(enc, args))
    return SHA256.new(data).digest()

def F(h, x):
    return pow(h, x, q)

################################################################

password = random.randrange(10**6)

def go():
    g = int(H(password).hex(), 16)

    privA = 40*random.randrange(2**999)
    pubA = F(g, privA)
    print(f'{pubA = :#x}')

    pubB = int(input(),0)
    if not 1 < pubB < q:
        exit('nope')

    shared = F(pubB, privA)

    verA = F(g, shared**3)
    print(f'{verA = :#x}')

    verB = int(input(),0)
    if verB == F(g, shared**5):
        key = H(password, shared)
        flag = open('flag.txt').read().strip()
        aes = AES.new(key, AES.MODE_CTR, nonce=b'')
        print(f'flag:', aes.encrypt(flag.encode()).hex())
    else:
        print(f'nope! {shared:#x}')

# three shots, three opportunities
# to seize everything you ever wanted
# would you capture? or just let it slip?
signal.alarm(2021)
go()
go()
go()

The value of q is prime and the body of the go() function appears to implement a variant of the Diffie-Hellman key exchange scheme in which the shared base is randomly generated and not given to us. We are free to choose our own value of pubB as long as it is between 1 and q non-inclusive.

We can modify the script to print shared and F(g, shared**5) to debug this dynamically with some test values. On the first run, we try a random value (‘123’) for pubB, the values of shared looks unpredictable and so it’s not usable.

python gipfel/test.py
pubA = 0x10e3e39fb5da49959753ea84c2fdba763b91527bf1f5d3f21159363c103f5651ec774c6edddd69137afd9e86e84f40d03954aa44112303640c727b2c3c8927603a36f0198f647b8ef58401453764d9898ebae8b239ead0e45a187ff4c839b22
123
shared = 0x33163a2e4566d237dce2b4cbf8e5a94a585a2d16fea44b907a2880552760635f46966af68b2da59ff4611e67706b4a9e3a1c04792a7204c1cdaa35c99bf297841cbe3b85b208d79c133aa81ce2b2d1e0e07ca21215953291ed665f40195386c
verA = 0x4dc1a32b7e5a8232b632de5e25c683bea0fbad8a724324c641a2dcb74446131bd93af5d461a6a8e59d5ca7cf83843fdd6762e9060ae810d6e8fee78854e94f90021f5fb068c13b38ec1c8daa26ee90c4507bf53923ee47c92180d6377a417f
F(g, shared**5) = 0x1f1f7b72cf099141f0eefa19a73113d1e7fdd530be1deb5cc7f963fa887963f5880bd83df5a0ca40c9348f3efed412c504922f67054d339533ad178dc06127cefbb0fb4092de6f5ece1f15d29e81926631b03061dbba09e5c8ff592ff57e717

However, if we try q - 1 as the value for pubB, the shared value will always be computed to be 0x1. This means that the value of F(g, shared**5) will simply be computed to be the value of verA and we can pass verification to get the encrypted flag.

python gipfel/test.py
pubA = 0x3150f41b8a94bab0e11ee4c3a9e03f099fa4430814c4d7636376b20f599264c211d1042380c766b2fcd55ac3bddd3eb1a5033d840020a4706772fb5a559be27397584b7a2c337b27680ddf85f2c2f54eec674e1425b8b20405bacd9f7799189
0x3a05ce0b044dade60c9a52fb6a3035fc9117b307ca21ae1b6577fef7acd651c1f1c9c06a644fd82955694af6cd4e88f540010f2e8fdf037c769135dbe29bf16a154b62e614bb441f318a82ccd1e493ffa565e5ffd5a708251a50d145f3159a4
shared = 0x1
verA = 0x3ea37cda503d1d63aee5728f36225109431f193d5d6dd290a8f3e0280d83f355
F(g, shared**5) = 0x3ea37cda503d1d63aee5728f36225109431f193d5d6dd290a8f3e0280d83f355

The encryption key for the flag will be a random number between 0 and 1000000 will be prepended to the byte \x01. Thus, we can search for the right flag pattern hxp{ after obtaining the encrypted data locally.

A proof-of-work solver was provided so we simply utilised it in the exploit:

#!/usr/bin/env python

from pwn import *
import re

from Crypto.Hash import SHA256
from Crypto.Cipher import AES

# context.log_level = 'debug'

def enc(a):
    f = {str: str.encode, int: int.__str__}.get(type(a))
    return enc(f(a)) if f else a

def H(*args):
    data = b'\0'.join(map(enc, args))
    return SHA256.new(data).digest()

q = 0x3a05ce0b044dade60c9a52fb6a3035fc9117b307ca21ae1b6577fef7acd651c1f1c9c06a644fd82955694af6cd4e88f540010f2e8fdf037c769135dbe29bf16a154b62e614bb441f318a82ccd1e493ffa565e5ffd5a708251a50d145f3159a5


def brute_flag(fb):
    for password in range(10**6):
        key = H(password, 1)
        aes = AES.new(key, AES.MODE_CTR, nonce=b'')
        decrypt = aes.decrypt(fb)
        if b'hxp{' in decrypt:
            return decrypt
        if password % 100000 == 0 and password > 0:
            log.info('{}%'.format((password / 10**6) * 100))

pow_re = re.compile(r'^please give S such that sha256\(unhex\("(\w+)" \+ S\)\) ends with (\d+) zero bits \(see pow-solver\.cpp\)\.$')


def solve_pow(p):
    line = p.recvline().decode().strip()
    m = pow_re.match(line)
    prefix, bits = m.groups()

    log.info("Solving pow for prefix {} and {} bits.".format(prefix, bits))
    solver = process(["./gipfel/pow-solver", bits, prefix])
    suffix = solver.recvall().strip()
    log.success("Got suffix: {}".format(suffix))
    solver.close()
    p.sendline(suffix)


def main():
    # p = process(["python", "gipfel/vuln.py"])
    p = remote("65.108.176.66", 1088)
    solve_pow(p)

    pubA_line = p.recvline().decode()
    log.info(pubA_line)
    p.sendline(hex(q - 1).encode())

    verA_line = p.recvline().decode()
    verA = verA_line.split("=")[1].strip()
    log.info(verA_line)
    p.sendline(verA.encode())

    flag_line = p.recvline().decode()
    flag_bytes = bytes.fromhex(flag_line.split(":")[1].strip())
    log.success(flag_line)

    log.info("Bruteforcing key...")
    flag = brute_flag(flag_bytes)
    log.success("Flag: {}".format(flag.decode()))

    p.close()


if __name__ == '__main__':
    main()

Running the exploit gives us the flag after awhile. The longest time bottlenecks were the proof-of-work computation and the search for the right key.

vagrant@ubuntu-xenial:/vagrant/hxp/gipfel$ python exploit.py
[+] Opening connection to 65.108.176.66 on port 1088: Done
[*] Solving pow for prefix 37f3d460d77c3dd6 and 32 bits.
[+] Starting local process './gipfel/pow-solver': pid 3117
[+] Receiving all data: Done (17B)
[*] Process './gipfel/pow-solver' stopped with exit code 0 (pid 3117)
[+] Got suffix: b'0200000003f2a41a'
[*] pubA = 0x74498994c446fb5673b5a941f0cda711ef22c8b228eeaec6c7a1c315c948aeebfd8cadd1c3352c05e751be139807389430649f1db4a3bc75edecb91706df5427719544344108ab2dbea513cf202fe70d8dae2636d4709b126aa727b4997d98
[*] verA = 0x18eeaff89770d1771299a47e45562162f0ebb60a0297a1f3c3ce4eb431e3cb70
[+] flag: 929bc92d8abb3528be57d2c237aab1ab241c91df6a48a65e79cc509fdabf771d3a07e9a48dacbfa5
[*] Bruteforcing key...
[+] Flag: hxp{ju5T_k1ddIn9_w3_aLl_kn0w_iT's_12345}
[*] Closed connection to 65.108.176.66 port 1088
vagrant@ubuntu-xenial:/vagrant/hxp/gipfel$

Flag: hxp{ju5T_k1ddIn9_w3_aLl_kn0w_iT's_12345}

Leave a Comment