GoogleCTF18-Dogestore

类型:Crypto
翻译自:https://github.com/p4-team/ctf/tree/master/2018-06-23-google-ctf/crypto_dogestore
考察知识点:CTR mode,birthday attack

这道题目给了我们服务器的代码,加密过的flag和一个服务。尽管服务器是用rust写的,但逻辑比较简单,还是很容易看懂的。服务器的操作如下:

  1. 从用户处读取输入
  2. 使用AES-CTR 解密用户的输入
  3. 将解密结果反序列化,具体操作是将相邻两个字节变为一组,如[1,2,3,4,5,6]变为[(1,2),(3,4),(5,6)]
  4. 将反序列化的结果做解码,具体是对于每一组(Char,d) 转化为Char*(d+1),如('A',3)变为'AAAA',再把每组的结果拼接起来
  5. 计算结果的sha3_256
  6. 将hash值返回给用户

在这个过程中,有问题的点在于:

    iv = get_iv();
    openssl::symm::decrypt(
        openssl::symm::Cipher::aes_256_ctr(),
        &key,
        Some(&iv),
        data
    )

对CTR(Counter)模式有所了解的话都知道,在AES CTR模式中,使用的是一个计数器而不是IV;而且计数器不应当以可预测的方式出现,更不能是常量。这是因为AES-CTR模式是一种流式加密,他会根据输入的Counter和key通过AES加密 生成一个密钥流。给定相同的key和counter,每一次都会生成相同的密钥流。然后密钥流或和输入进行异或得到输出。


CTR mode

因此实际上这道题的加密过程可以认为是我们输入的数据和一个常量密钥流做异或加密。也就是说只要我们能够泄露服务器上的密钥流,就可以解密flag。
这里的第二个漏洞在于我们可以利用birthday attack的原理,通过构造相同的哈希来泄露密钥流。假设我们只考虑前四个字节,后面全设为0。假设前四个字节为AxBy,A,B为字符,x,y为数字。我们把最终计算出的哈希值记下。
然后我们字节翻转x,y得到新的v,z,使x+y==v+z 。如果初始时我们就足够幸运,使得让A==B,那么我们就能够得到一次冲突。
换言之,当冲突发生时,我们可以知道A和B是相同的字母,也就有

payload[0]^KEY[0] == payload[2]^KEY[2]

由于payload是我们可控的,那么就得到了key的信息

KEY[0]^KEY[2] == payload[0]^payload[2]

以此类推,可以通过寻找冲突得到 KEY[2]^KEY[4]等等得值,然后再通过爆破KEY[0]来恢复所有偶数位的key
具体的冲突搜索算符如下:

def find(key_byte_number, get_result_fun=get_result):
    payload = [0] * 110
    attempts = 0
    while True:
        attempts += 1
        if attempts % 5 == key_byte_number % 5:
            print(key_byte_number, attempts)
        payload[key_byte_number] = 0
        payload[key_byte_number + 1] = random.randint(0, 255)
        payload[key_byte_number + 2] = random.randint(0, 255)
        payload[key_byte_number + 3] = random.randint(0, 255)
        res = get_result_fun(payload)
        for i in range(4):
            pay2 = payload[:]
            pay2[key_byte_number + 1] ^= 1 << i
            pay2[key_byte_number + 3] ^= 1 << i
            r2 = get_result_fun(pay2)
            if res == r2:
                print("KEY[%d] ^ KEY[%d] = %d" % (key_byte_number, key_byte_number + 2, payload[key_byte_number + 2]))
                print(res, r2, payload, pay2)
                return payload[key_byte_number + 2]

我们可以离线的验证这个算法是否真的有效:

def sanity_test():
    secret = "alamakotaa"

    def decrypt(data):
        return xor_string(secret, data)

    def deserialize(decrypted):
        return chunk(decrypted, 2)

    def decode(secret):
        return "".join([a * (ord(b) + 1) for a, b in secret])

    def mimick_server(data):
        import sha3
        decrypted = decrypt(data)
        secret = deserialize(decrypted)
        expanded = decode(secret)
        return sha3.sha3_256(expanded).digest()

    def fake_get_result(data):
        payload_bytes = "".join(map(chr, data))
        return base64.b64encode(mimick_server(payload_bytes))

    flag = xor_string("CTF{XXXXX}", secret)
    found = []
    for i in range(0, len(secret) - 2, 2):
        found.append(find(i, fake_get_result))
    print(found)

在验证过程中,我们把题目中的AES_CTR算法 等价为了一个简单的XOR加密。我们找到的冲突,计算出的结果为[0,0,14,14] ,这与 'a'^'a'==0 ,'a'^'o'==14的事实相符。
然后我们把实验中的服务器代码替换为真实的与服务器交互的代码,就可以恢复奇数位key的关系:

from crypto_commons.netcat.netcat_commons import nc, send


def get_result(payload):
    url = "dogestore.ctfcompetition.com"
    port = 1337
    while True:
        try:
            s = nc(url, port)
            payload_bytes = "".join(map(chr, payload))
            send(s, payload_bytes)
            result = s.recv(9999)
            return result
        except:
            pass

得到的结果是 [191, 119, 132, 188, 171, 242, 33, 15, 50, 0, 32, 130, 110, 51, 57, 36, 108, 223, 132, 48, 58, 47, 190, 144, 54, 115, 250, 91, 13, 16, 25, 193, 178, 26, 115, 140, 231, 65, 99, 180, 221, 121, 92, 206, 16, 64, 152, 181, 231, 228, 136, 149, 177, 237, 0]
接下来我们需要推测key的第一个字节的值。可以采用穷举法来爆破,然后用推测的key来解密密文,只有解密的结果大部分都是可见字符的时候,才是可能的key。通过这种方式可以很快的判断key[0]的每一个可能值是否合理。

def brute_first():
    found = [191, 119, 132, 188, 171, 242, 33, 15, 50, 0, 32, 130, 110, 51, 57, 36, 108, 223, 132, 48, 58, 47, 190, 144, 54, 115, 250, 91, 13, 16, 25, 193, 178,26,115, 140, 231, 65, 99, 180, 221, 121, 92, 206, 16, 64, 152, 181, 231, 228, 136, 149, 177, 237, 0]
    with codecs.open("encrypted_secret") as flag_file:
        flag = flag_file.read()
        for first in range(256):
            real_even_keystream = [chr(first)]
            for c in found:
                real_even_keystream.append(chr(ord(real_even_keystream[-1]) ^ c))
            with_zeros = reduce(lambda x, y: x + y, map(list, zip(real_even_keystream, ['0'] * len(found))))
            xored = xor_string(flag, "".join(with_zeros))
            even_chars = "".join([xored[i] for i in range(0, len(xored), 2)])
            print(first, even_chars)

brute_first()

最后得到了一个很合理的解密结果:

(174, 'HFHFHDHDHDSAaACTF{SADASDSDCTF{L_E_R_OY_JENKINS}ASDCTF{\n')

现在已经恢复出了key的所有奇数位,下一步需要恢复偶数位。想法也很简单:

  1. 提前计算字符串A,AA,AAA...直到可能的最长字符串(长度为55*256)的sha3_256,并保存下来,得到一张彩虹表。
  2. 我们希望所有的字母位都相同,由于我们已经知道了所有的奇数位key,我们可以构造payload[i]='A' ^KEY[i] 来做到这一点
  3. 我们任意填写计数位,然后进行哈希,根据哈希结果可以知道A的总数,即异或之后,计数位的和。
  4. 然后我们翻转第一个计数位的最低一个比特(异或 1),计算新的哈希,根据哈希知道新的串中计数位的和。如果这个和比原来小,说明我们使该比特从1变为了0,否则就是比特从0变为了1,这样我们就知道了和key异或之后该比特的真实值。然后可以用相同的方法求该计数位的次低比特位,以此类推。
  5. 求完一个计数位之后可以接着求下一个计数位。
  6. 得到所有异或之后的计数位之后,和异或前的计数位做异或,就可以得到所有偶数位的KEY。
def recover_counters(keybytes, get_result_fun=get_result):
    hashes = []
    with codecs.open("hashes", 'r') as hashes_file:
        for line in hashes_file:
            hashes.append(line[:-1])
    # prepare payload with 'A' on even positions
    payload = [0] * (len(keybytes) * 2)
    for i in range(0, len(keybytes) * 2, 2):
        payload[i] = ord(xor_string(keybytes[i / 2], 'A'))
    counter_bytes = []
    for counter in range(1, len(keybytes) * 2, 2):
        print('recovering counter', counter)
        reference_hash = get_result_fun(payload)
        reference_number_of_A = hashes.index(reference_hash)
        bits = []
        for bit in range(8):
            new_payload = payload[:]
            new_payload[counter] ^= 1 << bit
            new_hash = get_result_fun(new_payload)
            new_A_number = hashes.index(new_hash)
            if new_A_number > reference_number_of_A:  # we set a bit to 1 so it was 0
                bits.append('0')
            else:
                bits.append('1')
        original_counter = int("".join(bits[::-1]), 2)
        print('original counter', original_counter)
        counter_bytes.append(original_counter)
    return map(chr, counter_bytes)

原有的验证代码可以扩展位:

def sanity_test():
    secret = "alamakotaa"

    def decrypt(data):
        return xor_string(secret, data)

    def deserialize(decrypted):
        return chunk(decrypted, 2)

    def decode(secret):
        return "".join([a * (ord(b) + 1) for a, b in secret])

    def mimick_server(data):
        import sha3
        decrypted = decrypt(data)
        secret = deserialize(decrypted)
        expanded = decode(secret)
        return sha3.sha3_256(expanded).digest()

    def fake_get_result(data):
        payload_bytes = "".join(map(chr, data))
        return mimick_server(payload_bytes).encode("base64")

    flag = xor_string("CTF{XXXXX}", secret)
    found = []
    for i in range(0, len(secret) - 2, 2):
        found.append(find(i, fake_get_result))
    print(found)

    real_found = [chr(ord(flag[0]) ^ ord('C'))]
    for c in found:
        real_found.append(chr(ord(real_found[-1]) ^ c))
    print(real_found)
    counters = recover_counters(real_found, fake_get_result)
    print(counters)
    print(reduce(lambda x, y: x + y, map(lambda x: x[0] + x[1], zip(real_found, counters))))

最后把代买改写为同真实服务器交互的版本

def recover_from_letters():
    found = [191, 119, 132, 188, 171, 242, 33, 15, 50, 0, 32, 130, 110, 51, 57, 36, 108, 223, 132, 48, 58, 47, 190, 144, 54, 115, 250, 91, 13, 16, 25, 193, 178,
             26, 115, 140, 231, 65, 99, 180, 221, 121, 92, 206, 16, 64, 152, 181, 231, 228, 136, 149, 177, 237, 0]
    with codecs.open("encrypted_secret") as flag_file:
        flag = flag_file.read()
        real_found = [chr(174)]
        for c in found:
            real_found.append(chr(ord(real_found[-1]) ^ c))
        print(real_found)
        counters = recover_counters(real_found)
        print(counters)
        keystream = reduce(lambda x, y: x + y, map(lambda x: x[0] + x[1], zip(real_found, counters)))
        print(keystream)
        print(decode(deserialize(xor_string(flag, keystream))))


recover_from_letters()

得到完整的密钥流之后,就可以通过decode deserialize得到flag

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容