3. Slide attack

Phá mã vi sai (differential cryptanalysis) và phá mã tuyến tính (linear cryptanalysis) dựa trên các phân bổ xác suất khi sử dụng S-box. Một ý tưởng đơn giản để chống lại phân tích xác suất là tăng số vòng lên, khi đó chúng ta cần nhiều cặp bản rõ-bản mã hơn để tìm liên hệ giữa các bit của khóa. Rõ ràng nếu số lượng cặp bản rõ-bản mã quá nhiều thì rất khó để tính toán và lưu trữ nên có thể nói cách tiếp cận này hợp lý.

Tuy nhiên slide attack ra đời và đã chứng minh được rằng số vòng nhiều không đồng nghĩa với an toàn hơn.

Tương tự với hai phần trước, mình vẫn dùng TinyDES làm ví dụ cho slide attack.

3.1. Slide attack

Slide attack là kỹ thuật tấn công mã khối dạng know-plaintext hoặc chosen-plaintext.

Gọi \(F\) là một hoặc hợp của nhiều phép biến đổi trong thuật toán. Giả sử bản rõ ban đầu là \(P = P_0\), sau khi đi qua hàm \(F\) sẽ trở thành \(P_1 = F(P_0)\). Tương tự, \(P_1\) đi qua hàm \(F\) sẽ trở thành \(P_2 = F(P_1)\). Cứ như vậy tới khi nhận được bản rõ ở cuối thuật toán, giả sử là sau \(n\) lần biến đổi, \(C = P_n\).

Thông thường, mỗi lần thực hiện phép biến đổi \(F\) cũng sẽ đi kèm một hoặc nhiều khóa con. Khi khóa con được sử dụng lặp lại, gọi là \(K\), thì ta có sơ đồ

\[P = P_0 \xrightarrow{F_K} P_1 \xrightarrow{F_K} P_2 \xrightarrow{F_K} \cdots \xrightarrow{F_K} P_n = C.\]

Mục tiêu của slide attack là tìm một cặp bản rõ-bản mã \((P, C)\)\((P', C')\) mà chúng ta gọi là slid pair.

Định nghĩa 3.20 (Slid pair)

Xét một phép biến đổi \(F_K\) với \(K\) là khóa được sử dụng lặp lại cho mỗi lần thực hiện hàm \(F\). Để mã hóa bản rõ \(P\) thành bản mã \(C\) giả sử ta thực hiện theo thứ tự

\[P = P_0 \xrightarrow{F_K} P_1 \xrightarrow{F_K} P_2 \xrightarrow{F_K} \cdots \xrightarrow{F_K} P_n = C.\]

Tương tự, để mã hóa bản rõ \(P'\) thành bản mã \(C'\) giả sử ta thực hiện theo thứ tự

\[P' = P_0' \xrightarrow{F_K} P_1' \xrightarrow{F_K} P_2' \xrightarrow{F_K} \cdots \xrightarrow{F_K} P_n' = C'.\]

Khi đó, cặp bản rõ-bản mã \((P, C)\)\((P', C')\) được gọi là slid pair nếu \(F_K(P) = P'\)\(F_K(C) = C'\).

../../_images/slide_attack.jpg

Hình 3.17 Sơ đồ mô tả slid pair

Nếu chúng ta có một cặp bản rõ-bản mã là slid pair thì chúng ta có thể trích xuất khóa \(K\) từ hai phương trình. Điểm quan trọng của slide attack là chúng ta chỉ quan tâm hai điều kiện \(F_K(P) = P'\)\(F_K(C) = C'\), còn việc hàm \(F\) thực hiện bao nhiêu lần không quan trọng. Đây chính là ý nghĩa mình nói ở đầu bài, tăng số vòng không đồng nghĩa với an toàn hơn.

Sau đây mình sẽ ví dụ đơn giản về slide attack. Giống như hai bài trước, mình vẫn dùng TinyDES nhưng ở đây sẽ có hai thay đổi nhỏ.

3.2. Slide attack trên mô hình Feistel

3.2.1. Slide attack với TinyDES

TinyDES là một phiên bản thu nhỏ của chuẩn mã hóa DES. TinyDES là mã hóa khối theo mô hình Feistel, kích thước khối là \(8\) bit, kích thước khóa cũng là \(8\) bit. Mỗi vòng khóa con có độ dài \(6\) bit. Trong phần slide attack này chúng ta sẽ thay đổi một vài điểm so với TinyDES ở hai bài trước.

../../_images/tinydes.jpg

Hình 3.18 Một vòng TinyDES

Theo mô hình Feistel, khối đầu vào \(8\) bit được chia thành hai nửa trái phải \(4\) bit. Nửa phải sẽ đi qua các hàm Expand, SBox và PBox, sau đó XOR với nửa trái để được nửa phải mới. Còn nửa trái mới là nửa phải cũ. Tóm lại công thức mô hình Feistel là:

\[L_{i+1} = R_i, \quad R_{i+1} = L_i \oplus F(R_i, K)\]

với \(i = 1, 2, \ldots, 100\) với đầu vào của khối là \((L_0, R_0)\). Ở đây chúng ta lưu ý hai thay đổi so với TinyDES ở hai bài trước:

  • hiện tại chúng ta sử dụng \(100\) vòng thay vì \(3\) như hai bài trước;

  • cả \(100\) vòng sử dụng duy nhất một khóa con là \(K\).

Chúng ta vẫn dùng các động tác sau:

  1. Expand: mở rộng và hoán vị \(R_i\) từ \(4\) bits lên \(6\) bits. Giả sử \(4\) bits của \(R_i\)\(b_0 b_1 b_2 b_3\) thì kết quả sau khi Expand là \(b_2 b_3 b_1 b_2 b_1 b_0\).

  2. SBox: gọi \(6\) bits đầu vào là \(b_0 b_1 b_2 b_3 b_4 b_5\). Khi đó ta tra cứu theo bảng SBox với \(b_0 b_5\) chỉ số hàng, \(b_1 b_2 b_3 b_4\) chỉ số cột. Nói cách khác bảng SBox có \(4\) hàng, \(16\) cột. Kết quả của SBox là một số \(4\) bits.

  3. PBox: là hàm hoán vị \(4\) bit \(b_0 b_1 b_2 b_3\) thành \(b_2 b_0 b_3 b_1\).

Như vậy, hàm \(F\) của mô hình Feistel đối với mã TinyDES là:

\[F(R_i, K) = \mathsf{PBox}(\mathsf{SBox}(\mathsf{Expand}(R_i) \oplus K)).\]

Để sinh khóa con cho \(100\) vòng, khóa ban đầu được chia thành hai nửa trái phải lần lượt là \(KL_0\)\(KR_0\). TinyDES thực hiện như sau:

  • \(KL_0\)\(KR_0\) được dịch vòng trái \(1\) bit để được \(KL_1\)\(KR_1\);

  • khóa \(K\) dùng chung cho \(100\) vòng là hoán vị và nén \(8\) bits của \(KL_1 \Vert KR_1\). Đặt \(8\) bits khi ghép \(KL_1 \Vert KR_1\)\(k_0 k_1 k_2 k_3 k_4 k_5 k_6 k_7\). Khi đó khóa \(K\)\(6\) bits \(k_5 k_1 k_3 k_2 k_7 k_0\).

Ở đây chúng ta thấy mô hình mã hóa sẽ diễn ra như sau. Gọi \((L_0, R_0)\) là hai nửa trái phải của bản rõ ban đầu \(P\). Khi đó, ở mỗi vòng biến đổi sẽ sử dụng chung khóa con \(K\) theo mô hình

\[P = (L_0, R_0) \xrightarrow{F_K} (L_1, R_1) \xrightarrow{F_K} (L_2, R_2) \xrightarrow{F_K} (L_3, R_3) = C.\]

Theo mô hình Feistel thì

\[L_1 = R_0, \quad R_1 = L_0 \oplus f(R_0, K)\]

với \(f\) là round function tương ứng với thuật toán TinyDES. Nói cách khác thì \(F_k\)

\[F_K(L_i, R_i) = (R_i, L_i \oplus f(R_i, K)).\]

Lúc này slid pair có dạng

\[\begin{split}\begin{cases} F_K(P) = P' \\ F_K(C) = C' \end{cases} \Longleftrightarrow \begin{cases} F_K(L_0, R_0) = (L_0', R_0') \\ F_K(L_3, R_3) = (L_3', R_3') \end{cases}\end{split}\]

hay tương đương với

(3.5)\[\begin{split}\begin{cases} (R_0, L_0 \oplus f(R_0, K)) = (L_0', R_0') \\ (R_3, L_3 \oplus f(R_3, K)) = (L_3', R_3') \end{cases} \Longleftrightarrow \begin{cases} R_0 = L_0' \\ L_0 \oplus f(R_0, K) = L_0' \\ R_3 = L_3' \\ L_3 \oplus f(R_3, K) = R_3'. \end{cases}\end{split}\]

Như vậy:

nếu chúng ta có \((P, C)\)\((P', C')\) thỏa các điều kiện ở (3.5) thì chúng ta có slid pair.

Câu hỏi đặt ra là nếu chúng ta không biết khóa con \(K\) thì làm sao kiểm tra được các điều kiện trên?

Câu trả lời (mà cũng là cách chúng ta thực hiện trên thực tế) là chúng ta giả sử đã tìm được slid pair. Như vậy điều kiện đầu và điều kiện thứ ba phải thỏa mãn trước. Sau đó từ điều kiện thứ hai và thứ tư chúng ta tìm ngược lại \(K\). Cuối cùng chúng ta thử mã hóa \(P\) với \(K\) đã tìm được. Nếu chúng ta thu được chính xác \(C\) thì \(K\) là khóa con cần tìm, ngược lại thì chúng ta thử với slid pair khác.

Ở đoạn code trên mình bruteforce khóa con \(K\) vì SBox của TinyDES (và cũng là của DES) nhận đầu vào \(6\) bit nhưng đầu ra giảm còn \(4\) bit (chứ không phải do mình lười đâu hiuhiu). Do đó có thể có nhiều trường hợp của khóa con \(K\) có thể thỏa mãn điều kiện của slid pair. Chúng ta cũng có thể tạo lookup table và thực hiện ngược lại round function để tìm các khả năng của khóa con \(K\).

Gọi \(\mathsf{PBox}^{-1}\) là phép biến đổi ngược của \(\mathsf{PBox}\). Khi đó từ điều kiện thứ hai \(L_0 \oplus f(R_0, K) = L_0'\) suy ra

\[L_0 \oplus L_0' = f(R_0, K) = \mathsf{PBox}(\mathsf{SBox}(\mathsf{Expand}(R_0) \oplus K)),\]

suy ra

\[\mathsf{PBox}^{-1}(L_0 \oplus L_0') = \mathsf{SBox}(\mathsf{Expand}(R_0) \oplus K).\]

Chúng ta có thể tính được \(\mathsf{PBox}^{-1}(L_0 \oplus L_0')\)\(\mathsf{Expand}(R_0)\) nên việc "đoán" \(K\) không phải vấn đề khó khăn. Thêm nữa điều kiện thứ tư cũng cho chúng ta các ứng cử viên cho khóa con \(K\). Kết hợp hai điều kiện này chúng ta có khóa con \(K\) và chúng ta sẽ thử mã hóa \(P\) thành \(C\).

3.2.2. Slide attack với DES

Ở olympiad mật mã học quốc tế NSUCRYPTO 2024 có một bài slide attack trên DES là bài 4 ở round 2 "Weak key schedule for DES". Bài này được giải bởi bạn Chương (vnc) đội mình. Ở phần sau mình sẽ trình bày lời giải cho bài này. Mình sẽ sử dụng code của bạn Chương trong lời giải. Xin cám ơn bạn Chương vì đã đóng góp :D :D :D

Trong bài này, thông tin ban đầu là file Book.txt và được mã hóa thành file Book_Cipher.txt.

Thuật toán được sử dụng để mã hóa là DES. Tuy nhiên trong bài này đặc biệt ở chỗ mỗi vòng đều dùng chung một khóa con (khóa con đầu tiên của thuật toán sinh khóa con).

Nhiệm vụ của chúng ta là tìm khóa con đó và giải mã thông điệp sau:

86991641D28259604412D6BA88A5C0A6471CA7222C52482BF2D0E841D4343DFB877DC8E0147F3D5F20FC18FF28CB5C4DA8A0F4694861AB5E98F37ADBC2D69B35779D9001BB4B648518FE6EBC00B2AB10

3.2.2.1. Cài đặt FAULTY_DES

Ở đây bạn Chương gọi thuật toán của đề bài là FAULTY_DES và bạn sẽ cài đặt thuật toán này cùng với một số hàm bổ trợ cho việc giải bài.

3.2.2.2. Thực hiện solve.py

Phần này mình sẽ viết solve.py để giải bài này từ jupyter notebook của bạn Chương.

Đầu tiên chúng ta gọi một số thư viện.

from faulty_des import DES_CONST, HELP, FAULTY_DES
from collections import defaultdict
from typing import List, Tuple
from itertools import product
from tqdm import tqdm

Tiếp theo, chúng ta cần thống nhất rằng mỗi khối trong thuật toán FAULTY_DES sẽ được biểu diễn bởi mảng gồm \(8\) phần tử (list[int]). Khi đó nửa trái (left-half) là \(4\) phần tử đầu của mảng và nửa phải (right-half) là \(4\) phần tử sau. Do đó chúng ta cần một số lambda để lấy nửa trái/phải. Sau bước cuối chúng ta ghép nửa phải cuối cùng với nửa trái cuối cùng nên cần thêm hàm Swap_f.

Left_f  = lambda block: block[:4]
Right_f = lambda block: block[4:]
Swap_f  = lambda block: Right_f(block) + Left_f(block)

Sau đó đọc bản rõ và bản mã từ file đề cho rồi tách chúng thành các khối \(8\) bytes.

# Known-Plaintext-Ciphertext Pairs
plaintext  = open("Book.txt", "rb").read()[:-1]     # độ dài bản rõ chia hết cho 8
ciphertext = open("Book_cipher.txt", "rb").read()

# Divide them into blocks
pts        = [plaintext[i:i+8] for i in range(0, len(plaintext), 8)]
cts        = [ciphertext[i:i+8] for i in range(0, len(ciphertext), 8)]

Tiếp theo chúng ta tìm các slid pair. Ở đây chúng ta cần xem lại cách hoạt động của thuật toán DES. Hình sau mình lấy từ [30] và chỉnh sửa lại.

../../_images/des-round.jpg

Hình 3.19 Round function của DES

Tóm tắt cách hoạt động thì DES cũng theo mô hình

\[L_{i+1} = R_i, \quad R_{i+1} = L_i \oplus \mathsf{PBox}(\mathsf{SBox}(\mathsf{Expand}(R_i) \oplus K_{i+1})),\]

trong đó

  • \(\mathsf{Expand}\) mở rộng nửa khối từ \(32\) bits lên \(48\) bits;

  • \(\mathsf{SBox}\): \(48\) bits sẽ được chia thành \(8\) đoạn, mỗi đoạn có \(6\) bits. Sau đó mỗi đoạn sẽ đi qua các S-Box và giảm từ \(6\) bits xuống \(4\) bits. Các kết quả được nối lại với nhau nên kết quả sau \(\mathsf{SBox}\)\(4 \cdot 8 = 32\) bits;

  • \(\mathsf{PBox}\): thực hiện hoán vị \(32\) bit sau \(\mathsf{SBox}\).

Ở đây slid pair cũng tương tự TinyDES ở trên. Giả sử slid pair là cặp \((P, C)\)\((P', C')\). Khi đó

\[F_K(P) = P', \quad F_K(C) = C'\]

với \(F_K\) là round function

\[(L_i, R_i) \xrightarrow{F_K} (L_{i+1}, R_{i+1}) \equiv (R_i, L_i \oplus \mathsf{PBox}(\mathsf{SBox}(\mathsf{Expand}(R_i) \oplus K_{i+1}))),\]

như trên.

# Before and After DES' rounds operations, there is a Block Permutation step!
def BlockPermutate(block: bytes, table: list):
    assert len(block) == 8
    block_bits = HELP.BLOCK_TO_BITS(block)
    block_bits = HELP.PERMUTATE(block_bits, table)
    block      = HELP.BITS_TO_BLOCK(block_bits, 8)

    return block

# Create Lookup Table
lookup = defaultdict(list)

for pi, ci in tqdm(zip(pts, cts), desc="[+] Creating Lookup Table..."):
    pi_initial = BlockPermutate(pi, DES_CONST.INITIAL_PERMUTATION)
    ci_preout  = BlockPermutate(ci, DES_CONST.INITIAL_PERMUTATION)
    # Why Left_f(Ci) -> Remember that the final round doesn't swap two-halfs
    lookup[Right_f(pi_initial) + Left_f(ci_preout)].append((pi_initial, ci_preout))

# Finding "slid pairs"
slid_pairs = []

for pj, cj in tqdm(zip(pts, cts), desc="[+] Finding slid pairs..."):
    pj_initial = BlockPermutate(pj, DES_CONST.INITIAL_PERMUTATION)
    cj_preout  = BlockPermutate(cj, DES_CONST.INITIAL_PERMUTATION)

    try:
        # Why Right_f(Cj) -> Remember that the final round doesn't swap two-halfs
        for pi_initial, ci_preout in lookup[Left_f(pj_initial) + Right_f(cj_preout)]:
            slid_pairs.append([
                # Now we swap to ensure that (P,C) and (P',C') is slid pair
                # <=> F(P) = P' and F(C) = C'
                (pi_initial, Swap_f(ci_preout)),
                (pj_initial, Swap_f(cj_preout)),
            ])
    except:
        continue

print(f"[!] Found {len(slid_pairs)} possible slid pairs!")

Các phép biến đổi ngược của các phép biến đổi trong DES nhằm tìm các "ứng cử viên" cho khóa. Quan trọng là S-Box vì chúng ta đã biết mỗi S-Box biến đổi \(6\) bits thành \(4\) bits nên với nhiều đầu vào cho cùng đầu ra. Khi đi ngược lại để tìm "ứng cử viên" thì ta phải xét tất cả trường hợp đầu vào S-Box cho cùng đầu ra mà ta đang có.

# Enumerate all possible candidates
def RevSubtitute(out: List[int], mapping: List[Tuple]):
    cands = [[] for _ in range(8)]
    out   = [out[i:i+4] for i in range(0, len(out), 4)]

    for idx in range(8):
        for piece in range(2**6):
            row = (piece & 1) | ((piece >> 4) & 0b10)
            column = (piece & 0b011110) >> 1
            if HELP.INT_TO_BITS(mapping[idx][row * 16 + column], 4) == out[idx]:
                cands[idx].append(HELP.INT_TO_BITS(piece, 6))

    for rk in product(*cands):
        yield sum(rk, [])

def RevFeistel(inp, out):
    out = HELP.PERMUTATE(out, DES_CONST.INV_ROUND_PERMUTATION)
    inp = HELP.EXPAND(inp, DES_CONST.EXPANSION)
    for rev_out in RevSubtitute(out, DES_CONST.SBOX):
        yield HELP.BITS_TO_BLOCK(HELP.XOR(rev_out, inp), 6)

def RevRound(state0: bytes, state1: bytes):
    L0, R0 = Left_f(state0), Right_f(state0)
    L1, R1 = Left_f(state1), Right_f(state1)
    return set(RevFeistel(
        inp=HELP.BLOCK_TO_BITS(R0),
        out=HELP.XOR(HELP.BLOCK_TO_BITS(R1), HELP.BLOCK_TO_BITS(L0))
    ))

Tìm các khóa con có thể và thử giải mã thông điệp đề cho với khóa con đó.

rk_cands = set()

for (pi, ci), (pj, cj) in tqdm(slid_pairs, desc="[+] Recovering Possible RoundKey..."):
    rks_1    = RevRound(pi, pj)
    rks_2    = RevRound(ci, cj)
    rk_cands = rk_cands.union(rks_1.intersection(rks_2))

print(f"[!] Found {len(rk_cands)} possible RoundKeys!")

# Try to decrypt intercepted ciphertext
intercepted_ciphertext = b"".join([
    bytes.fromhex("86991641D28259604412D6BA88A5C0A6471CA722"),
    bytes.fromhex("2C52482BF2D0E841D4343DFB877DC8E0147F3D5F"),
    bytes.fromhex("20FC18FF28CB5C4DA8A0F4694861AB5E98F37ADB"),
    bytes.fromhex("C2D69B35779D9001BB4B648518FE6EBC00B2AB10")
])

for rk in tqdm(rk_cands, desc="[+] Try to decrypt intercepted ciphertext..."):
    cipher = FAULTY_DES(rk)
    try:
        print("> Readable Message:", cipher.decrypt(intercepted_ciphertext).decode())
        print("> WRT RoundKey:", rk.hex(), end="\n\n")
    except:
        continue

# Verify again?
assert FAULTY_DES(bytes.fromhex("0c74fa6a642a")).encrypt(plaintext) == ciphertext