从一道 CTF 题了解密码学中的 Meet-in-the-middle 攻击

发表于 2019 年 3 月 25 日

本文首发于先知

在家里无聊打了 nullcon, 其中有一题用到了 Meet-in-the-middle 这种攻击方式,
在这里分享给大家.

题目

124 bit key space is brute forceable so how about 48 bit key space? flag is hackim19{decrypted flag}
2
316 bit plaintext: b'0467a52afa8f15cfb8f0ea40365a6692' flag: b'04b34e5af4a1f5260f6043b8b9abb4f8'
 1from hashlib import md5
 2from binascii import hexlify, unhexlify
 3from secret import key, flag
 4BLOCK_LENGTH = 16
 5KEY_LENGTH = 3
 6ROUND_COUNT = 16
 7
 8sbox = [210, 213, 115, 178, 122, 4, 94, 164, 199, 230, 237, 248, 54,
 9        217, 156, 202, 212, 177, 132, 36, 245, 31, 163, 49, 68, 107,
10        91, 251, 134, 242, 59, 46, 37, 124, 185, 25, 41, 184, 221,
11        63, 10, 42, 28, 104, 56, 155, 43, 250, 161, 22, 92, 81,
12        201, 229, 183, 214, 208, 66, 128, 162, 172, 147, 1, 74, 15,
13        151, 227, 247, 114, 47, 53, 203, 170, 228, 226, 239, 44, 119,
14        123, 67, 11, 175, 240, 13, 52, 255, 143, 88, 219, 188, 99,
15        82, 158, 14, 241, 78, 33, 108, 198, 85, 72, 192, 236, 129,
16        131, 220, 96, 71, 98, 75, 127, 3, 120, 243, 109, 23, 48,
17        97, 234, 187, 244, 12, 139, 18, 101, 126, 38, 216, 90, 125,
18        106, 24, 235, 207, 186, 190, 84, 171, 113, 232, 2, 105, 200,
19        70, 137, 152, 165, 19, 166, 154, 112, 142, 180, 167, 57, 153,
20        174, 8, 146, 194, 26, 150, 206, 141, 39, 60, 102, 9, 65,
21        176, 79, 61, 62, 110, 111, 30, 218, 197, 140, 168, 196, 83,
22        223, 144, 55, 58, 157, 173, 133, 191, 145, 27, 103, 40, 246,
23        169, 73, 179, 160, 253, 225, 51, 32, 224, 29, 34, 77, 117,
24        100, 233, 181, 76, 21, 5, 149, 204, 182, 138, 211, 16, 231,
25        0, 238, 254, 252, 6, 195, 89, 69, 136, 87, 209, 118, 222,
26        20, 249, 64, 130, 35, 86, 116, 193, 7, 121, 135, 189, 215,
27        50, 148, 159, 93, 80, 45, 17, 205, 95]
28p = [3, 9, 0, 1, 8, 7, 15, 2, 5, 6, 13, 10, 4, 12, 11, 14]
29
30
31def xor(a, b):
32    return bytearray(map(lambda s: s[0] ^ s[1], zip(a, b)))
33
34
35def fun(key, pt):
36    assert len(pt) == BLOCK_LENGTH
37    assert len(key) == KEY_LENGTH
38    key = bytearray(unhexlify(md5(key).hexdigest()))
39    ct = bytearray(pt)
40    for _ in range(ROUND_COUNT):
41        ct = xor(ct, key)
42        for i in range(BLOCK_LENGTH):
43            ct[i] = sbox[ct[i]]
44        nct = bytearray(BLOCK_LENGTH)
45        for i in range(BLOCK_LENGTH):
46            nct[i] = ct[p[i]]
47        ct = nct
48    return hexlify(ct)
49
50
51def toofun(key, pt):
52    assert len(key) == 2 * KEY_LENGTH
53    key1 = key[:KEY_LENGTH]
54    key2 = key[KEY_LENGTH:]
55
56    ct1 = unhexlify(fun(key1, pt))
57    ct2 = fun(key2, ct1)
58
59    return ct2
60
61print("16 bit plaintext: %s" % toofun(key, b"16 bit plaintext"))
62print("flag: %s" % toofun(key, flag))

解题思路

我们的目标就是通过给出的明文 16 bit plaintext 和对应的密文 0467a52afa8f15cfb8f0ea40365a6692
算出 key, 从而解密 flag 密文 04b34e5af4a1f5260f6043b8b9abb4f8

根据加密代码和题目描述, 很显然方法只有爆破一种, 但是爆破 6 位 key 的 2**48(281,474,976,710,656) 种可能是不现实的.
但正如题目中所说, 爆破 2**24(16,777,216) 还是非常简单的.

现在我们的问题变成如何简化这个爆破过程, 这里就要用到一开始说的 Meet-in-the-middle 攻击,
加密主要是这两个函数,

 1def fun(key, pt):
 2    assert len(pt) == BLOCK_LENGTH
 3    assert len(key) == KEY_LENGTH
 4    key = bytearray(unhexlify(md5(key).hexdigest()))
 5    ct = bytearray(pt)
 6    for _ in range(ROUND_COUNT):
 7        ct = xor(ct, key)
 8        for i in range(BLOCK_LENGTH):
 9            ct[i] = sbox[ct[i]]
10        nct = bytearray(BLOCK_LENGTH)
11        for i in range(BLOCK_LENGTH):
12            nct[i] = ct[p[i]]
13        ct = nct
14    return hexlify(ct)
15
16
17def toofun(key, pt):
18    assert len(key) == 2 * KEY_LENGTH
19    key1 = key[:KEY_LENGTH]
20    key2 = key[KEY_LENGTH:]
21
22    ct1 = unhexlify(fun(key1, pt))
23    ct2 = fun(key2, ct1)
24
25    return ct2

发现其实真正的加密函数是 fun, 在 toofun 中调用了两次
而且这里并没有将 6 位长的 key 一次性使用, 而是将其从中间分开, 先用前 3 位加密一次, 再用后 3 位加密一次.
也就是说实际的加密过程是用两个 3 位的 key 连续加密二次.
这就让我们的攻击有了可能性.

虽然也是爆破, 但是与一开始的最先能想到的爆破 6 位 key 不同的是
我们先遍历所有 3 位长的 key, 算出 16 bit plaintext 对应的所有第一次加密结果, 将其保存起来,
然后再遍历一次 key, 将密文解密, 如果能在第一次加密的结果中找到, 那么对应的两个 3 位 key 拼起来就是一开始的 6 位 key.
然后用这个 key 就可以解密出 flag 了~
现在能感觉到这个攻击的名字取的还是非常切题的, 一边加密, 一边解密, 在中间碰头得到秘钥.

与一开始的解法相比, 我们成功的将 2**48 降到 2*2**24, 不过对应的是需要一个哈希表来保存所有第一次加密的结果,
相当于用空间换时间了, 好在现在内存都比较大, 应付起来很轻松.

题解

因为 python 不太适合这种大量数据的爆破, 所以就用 C++ 写了, 写的比较烂请见谅

  1#include "md5.cpp"
  2#include <iostream>
  3#include <unordered_map>
  4#include <string>
  5
  6const int BLOCK_LENGTH = 16;
  7const int ROUND_COUNT = 16;
  8typedef unsigned char uchar;
  9
 10uchar sbox[]  = {210, 213, 115, 178, 122, 4, 94, 164, 199, 230, 237, 248, 54,
 11                 217, 156, 202, 212, 177, 132, 36, 245, 31, 163, 49, 68, 107,
 12                 91, 251, 134, 242, 59, 46, 37, 124, 185, 25, 41, 184, 221,
 13                 63, 10, 42, 28, 104, 56, 155, 43, 250, 161, 22, 92, 81,
 14                 201, 229, 183, 214, 208, 66, 128, 162, 172, 147, 1, 74, 15,
 15                 151, 227, 247, 114, 47, 53, 203, 170, 228, 226, 239, 44, 119,
 16                 123, 67, 11, 175, 240, 13, 52, 255, 143, 88, 219, 188, 99,
 17                 82, 158, 14, 241, 78, 33, 108, 198, 85, 72, 192, 236, 129,
 18                 131, 220, 96, 71, 98, 75, 127, 3, 120, 243, 109, 23, 48,
 19                 97, 234, 187, 244, 12, 139, 18, 101, 126, 38, 216, 90, 125,
 20                 106, 24, 235, 207, 186, 190, 84, 171, 113, 232, 2, 105, 200,
 21                 70, 137, 152, 165, 19, 166, 154, 112, 142, 180, 167, 57, 153,
 22                 174, 8, 146, 194, 26, 150, 206, 141, 39, 60, 102, 9, 65,
 23                 176, 79, 61, 62, 110, 111, 30, 218, 197, 140, 168, 196, 83,
 24                 223, 144, 55, 58, 157, 173, 133, 191, 145, 27, 103, 40, 246,
 25                 169, 73, 179, 160, 253, 225, 51, 32, 224, 29, 34, 77, 117,
 26                 100, 233, 181, 76, 21, 5, 149, 204, 182, 138, 211, 16, 231,
 27                 0, 238, 254, 252, 6, 195, 89, 69, 136, 87, 209, 118, 222,
 28                 20, 249, 64, 130, 35, 86, 116, 193, 7, 121, 135, 189, 215,
 29                 50, 148, 159, 93, 80, 45, 17, 205, 95
 30                };
 31
 32int p[] = {3, 9, 0, 1, 8, 7, 15, 2, 5, 6, 13, 10, 4, 12, 11, 14};
 33
 34uchar dbox[] = {221, 62, 140, 111, 5, 213, 225, 242, 157, 167, 40, 80, 121,
 35                83, 93, 64, 219, 253, 123, 147, 234, 212, 49, 115, 131, 35,
 36                160, 191, 42, 204, 175, 21, 202, 96, 205, 238, 19, 32, 126,
 37                164, 193, 36, 41, 46, 76, 252, 31, 69, 116, 23, 247, 201, 84,
 38                70, 12, 184, 44, 154, 185, 30, 165, 171, 172, 39, 236, 168,
 39                57, 79, 24, 228, 143, 107, 100, 196, 63, 109, 211, 206, 95,
 40                170, 251, 51, 91, 181, 136, 99, 239, 230, 87, 227, 128, 26,
 41                50, 250, 6, 255, 106, 117, 108, 90, 208, 124, 166, 192, 43,
 42                141, 130, 25, 97, 114, 173, 174, 150, 138, 68, 2, 240, 207,
 43                232, 77, 112, 243, 4, 78, 33, 129, 125, 110, 58, 103, 237,
 44                104, 18, 188, 28, 244, 229, 144, 217, 122, 178, 163, 151,
 45                86, 183, 190, 158, 61, 248, 214, 161, 65, 145, 155, 149, 45,
 46                14, 186, 92, 249, 198, 48, 59, 22, 7, 146, 148, 153, 179, 195,
 47                72, 137, 60, 187, 156, 81, 169, 17, 3, 197, 152, 210, 216, 54,
 48                37, 34, 134, 119, 89, 245, 135, 189, 101, 241, 159, 226,
 49                180, 177, 98, 8, 142, 52, 15, 71, 215, 254, 162, 133, 56,
 50                231, 0, 218, 16, 1, 55, 246, 127, 13, 176, 88, 105, 38, 233,
 51                182, 203, 200, 74, 66, 73, 53, 9, 220, 139, 209, 118, 132,
 52                102, 10, 222, 75, 82, 94, 29, 113, 120, 20, 194, 67, 11,
 53                235, 47, 27, 224, 199, 223, 85
 54               };
 55
 56int q[] = {2, 3, 7, 0, 12, 8, 9, 5, 4, 1, 11, 14, 13, 10, 15, 6};
 57
 58inline void mxor(uchar* a, uchar* b, uchar* tmp) {
 59    for(int i = 0; i < BLOCK_LENGTH; i++) {
 60        tmp[i] = a[i] ^ b[i];
 61    }
 62}
 63
 64inline void copy(uchar* from, uchar* to) {
 65    for(int i = 0; i < BLOCK_LENGTH; i++) {
 66        to[i] = from[i];
 67    }
 68}
 69
 70void fun(uchar* key, uchar* ct, uchar* tmp) {
 71    for(int i = 0; i < ROUND_COUNT; i++) {
 72        mxor(ct, key, tmp);
 73        copy(tmp, ct);
 74        for(int i = 0; i < BLOCK_LENGTH; i++) {
 75            ct[i] = sbox[ct[i]];
 76        }
 77        uchar nct[BLOCK_LENGTH];
 78        for(int i = 0; i < BLOCK_LENGTH; i++) {
 79            nct[i] = ct[p[i]];
 80        }
 81        copy(nct, ct);
 82    }
 83}
 84
 85void dec(uchar* key, uchar* ct, uchar* tmp) {
 86    for(int i = 0; i < ROUND_COUNT; i++) {
 87        uchar nct[BLOCK_LENGTH];
 88        for(int i = 0; i < BLOCK_LENGTH; i++) {
 89            nct[i] = ct[q[i]];
 90        }
 91        copy(nct, ct);
 92
 93        for(int i = 0; i < BLOCK_LENGTH; i++) {
 94            ct[i] = dbox[ct[i]];
 95        }
 96        mxor(ct, key, tmp);
 97        copy(tmp, ct);
 98    }
 99}
100
101int main() {
102    std::unordered_map<std::string, std::string> map;
103    uchar* md5key = new uchar[BLOCK_LENGTH];
104    uchar tmp[BLOCK_LENGTH];
105    uchar hexkey[] = {0, 0, 0};
106
107    for(int i = 0; i < 256; i++) { // 爆破明文所有 key 加密的结果
108        for(int j = 0; j < 256; j++) {
109            for(int k = 0; k < 256; k++) {
110                uchar pt[] = "16 bit plaintext";
111                copy(MD5((char*)hexkey, 3).digest, md5key);
112                fun(md5key, pt, tmp);
113
114                std::string s1((char*)pt, 16);
115                std::string s2((char*)hexkey, 3);
116
117                map[s1] = s2;
118                hexkey[2] += 1;
119            }
120            hexkey[1] += 1;
121        }
122        std::cout << i << std::endl;
123        hexkey[0] += 1;
124    }
125
126    for(int i = 0; i < 256; i++) { // 爆破密文所有 key 解密的结果, 如果在 map 中找到一样的, 用这两个 key 拿来解密 flag
127        for(int j = 0; j < 256; j++) {
128            for(int k = 0; k < 256; k++) {
129                uchar pt[] = "\x04g\xa5*\xfa\x8f\x15\xcf\xb8\xf0\xea@6Zf\x92"; // "0467a52afa8f15cfb8f0ea40365a6692".decode('hex')
130                copy(MD5((char*)hexkey, 3).digest, md5key);
131                dec(md5key, pt, tmp);
132
133                std::string s1((char*)pt);
134                auto ptr = map.find(s1);
135
136                if(ptr != map.end()) {
137                    std::cout << "Key found" << std::endl;
138                    uchar key[3];
139                    uchar flag[] = "\x04\xb3NZ\xf4\xa1\xf5&\x0f`C\xb8\xb9\xab\xb4\xf8"; // "04b34e5af4a1f5260f6043b8b9abb4f8".decode('hex')
140                    key[0] = (*ptr).second.c_str()[0];
141                    key[1] = (*ptr).second.c_str()[1];
142                    key[2] = (*ptr).second.c_str()[2];
143                    copy(MD5((char*)hexkey, 3).digest, md5key);
144                    dec(md5key, flag, tmp);
145                    copy(MD5((char*)key, 3).digest, md5key);
146                    dec(md5key, flag, tmp);
147                    std::cout << std::string((char*)flag, 16) << std::endl;
148                    return 0;
149                }
150                hexkey[2] += 1;
151            }
152            hexkey[1] += 1;
153        }
154        std::cout << i << std::endl;
155        hexkey[0] += 1;
156    }
157    return 0;
158}

其中 dboxq 可以这样生成

1dbox = [0 for i in range(len(sbox))]
2q = [0 for i in range(len(p))]
3for i in range(len(sbox)):
4    dbox[sbox[i]] = i
5for i in range(len(p)):
6    q[p[i]] = i

大约需要 3g 内存和几分钟时间得到 flag
编译的时候别忘记带上 -O3

1Key found
21337_1n_m1ddl38f

总结

密码学博大精深, Meet-in-the-middle 只是其中一种攻击技术, 但其中的思路还是非常值得学习的,
有时候爆破不可避免, 我们能做的是如何让爆破最简化, 至少不那么暴力 233.

想要了解更多关于 Meet-in-the-middle 的知识可以去看对应的 WIKI