Dreamhack: My-ARX-Cipher

Dreamhack: My-ARX-Cipher

in

The Challenge

The challenge hands you three things: an encryptor binary, a 12-byte key file, and flag.enc. The binary takes a key file, an input file, and an output file, and writes the encrypted result. There is no decryptor. The whole task is to reverse the cipher out of the binary and invert it.

How to use: ./encryptor <key_file> <input_file> <output_file>

So this is a pure reversing problem: understand the algorithm, then write the inverse.

Reading the Binary

main reads the 12-byte key, then encrypts the input in blocks. The key reader just pulls six 16-bit words out of the file:

// sub_1229 - read 6 * 2 = 12 bytes into the key buffer
fread(key_buffer, 2, 6, stream);

The encryption driver reads the input 4 bytes at a time and encrypts each block in place:

// sub_1381
for (s = 0; ; fwrite(&s, 2, 2, out)) {
    if (!fread(&s, 1, 4, in)) break;   // read one 4-byte block
    sub_129F(key, &s);                 // encrypt it
}

Two facts fall out of this immediately:

  • The block size is 4 bytes, split into two 16-bit words.
  • The key is six 16-bit words, enough for two words per round across three rounds.

The ARX Round

sub_129F is the heart of the cipher. It loads the block as two 16-bit halves v3, v4, then runs three rounds of Add-Rotate-XOR:

v3 = *a2;        // first half
v4 = a2[1];      // second half

for (i = 0; i <= 2; ++i) {         // 3 rounds
    v5 = key[2*i]     ^ (v4 + ROL(v3, 7));
    v3 = key[2*i + 1] ^  ROL(v4, 7);
    v4 = v5;
}

*a2   = v3;
a2[1] = v4;

Each round mixes the two halves with three reversible primitives:

  • Addition modulo 2^16
  • XOR with a round key word
  • ROL 7: a 7-bit left rotation on a 16-bit word

This is a Feistel-flavored ARX construction. The important property of ARX is that every operation is invertible, so as long as we know the key, we can walk the whole thing backwards.

Inverting Each Round

Take one encryption round with input (v3, v4) and round keys k0 = key[2*i], k1 = key[2*i+1]:

v5   = k0 ^ (v4 + ROL(v3, 7))
v3'  = k1 ^ ROL(v4, 7)
v4'  = v5

Given the output (v3', v4'), undo it step by step. Each ARX primitive has a clean inverse: addition becomes subtraction, and ROL 7 becomes ROR 7; XOR is its own inverse:

v4  = ROR(v3' ^ k1, 7)                 # recover the original v4
v3  = ROR((v4' ^ k0) - v4, 7)          # recover the original v3  (mod 2^16)

Run this for i = 2, 1, 0, the rounds in reverse order, and each 4-byte block decrypts cleanly.

The Decryptor

import struct

def ROR(val, r, bits=16):
    r %= bits
    return ((val & (2**bits - 1)) >> r) | (val << (bits - r) & (2**bits - 1))

def decrypt_block(key, block):
    v3, v4 = struct.unpack('<HH', block)
    for i in range(2, -1, -1):           # rounds in reverse
        k0, k1 = key[2*i], key[2*i + 1]
        v5 = v4
        v4 = ROR(v3 ^ k1, 7)             # ROL -> ROR
        v3 = ROR(((v5 ^ k0) - v4) & 0xFFFF, 7)   # add -> sub, then ROL -> ROR
    return struct.pack('<HH', v3, v4)

key = struct.unpack('<HHHHHH', open('key', 'rb').read())
enc = open('flag.enc', 'rb').read()

flag = b''.join(decrypt_block(key, enc[i:i+4]) for i in range(0, len(enc), 4))
print(flag.rstrip(b'\x00').decode(errors='ignore'))
DH{REDACTED}

Takeaways

The reversing here is not about a clever trick. It is about being careful. Everything comes down to reading the round exactly, respecting the 16-bit width (so subtraction wraps modulo 2^16), and remembering the little-endian word layout on both the key and the data.

ARX ciphers show up constantly because they are cheap and fast, and this challenge is a clean reminder of why they are also reversible by design: an attacker who recovers the key can run the addition, rotation, and XOR steps backward with nothing more than arithmetic.


문제

문제에서 주는 건 세 가지입니다. encryptor 바이너리, 12바이트짜리 key 파일, 그리고 flag.enc예요. 바이너리는 키 파일과 입력 파일, 출력 파일을 받아 암호화 결과를 씁니다. 복호화 도구는 없어요. 결국 이 문제의 전부는 바이너리에서 암호 알고리즘을 리버싱해서 역연산을 직접 만드는 것입니다.

How to use: ./encryptor <key_file> <input_file> <output_file>

그러니까 순수한 리버싱 문제죠. 알고리즘을 이해하고, 그 역을 구현하면 됩니다.

바이너리 읽기

main은 12바이트 키를 읽은 뒤, 입력을 블록 단위로 암호화합니다. 키 리더는 파일에서 16비트 워드 6개를 뽑아낼 뿐이에요.

// sub_1229 - 2 * 6 = 12바이트를 키 버퍼로 읽음
fread(key_buffer, 2, 6, stream);

암호화 드라이버는 입력을 4바이트씩 읽어서 각 블록을 제자리에서 암호화합니다.

// sub_1381
for (s = 0; ; fwrite(&s, 2, 2, out)) {
    if (!fread(&s, 1, 4, in)) break;   // 4바이트 블록 하나 읽기
    sub_129F(key, &s);                 // 암호화
}

여기서 두 가지가 바로 드러납니다.

  • 블록 크기는 4바이트, 즉 16비트 워드 두 개로 나뉩니다.
  • 키는 16비트 워드 6개입니다. 3라운드 동안 라운드당 두 워드씩 쓰기에 딱 맞죠.

ARX 라운드

sub_129F가 암호의 핵심입니다. 블록을 16비트 반쪽 v3, v4로 로드한 뒤, Add-Rotate-XOR 3라운드를 돌립니다.

v3 = *a2;        // 앞쪽 반
v4 = a2[1];      // 뒤쪽 반

for (i = 0; i <= 2; ++i) {         // 3라운드
    v5 = key[2*i]     ^ (v4 + ROL(v3, 7));
    v3 = key[2*i + 1] ^  ROL(v4, 7);
    v4 = v5;
}

*a2   = v3;
a2[1] = v4;

각 라운드는 두 반쪽을 세 가지 가역 연산으로 섞습니다.

  • 덧셈 (2^16 모듈러)
  • 라운드 키 워드와의 XOR
  • ROL 7: 16비트 워드에 대한 7비트 좌측 회전

Feistel 느낌의 ARX 구조예요. ARX의 핵심 성질은 모든 연산이 가역이라는 점입니다. 그래서 키만 알면 전체를 거꾸로 되짚어 갈 수 있죠.

각 라운드 뒤집기

입력 (v3, v4)와 라운드 키 k0 = key[2*i], k1 = key[2*i+1]로 이루어진 암호화 라운드 하나를 봅시다.

v5   = k0 ^ (v4 + ROL(v3, 7))
v3'  = k1 ^ ROL(v4, 7)
v4'  = v5

출력 (v3', v4')가 주어졌을 때 한 단계씩 되돌립니다. ARX의 각 연산은 깔끔한 역이 있어요. 덧셈은 뺄셈으로, ROL 7은 ROR 7로, XOR은 자기 자신이 역입니다.

v4  = ROR(v3' ^ k1, 7)                 # 원래 v4 복원
v3  = ROR((v4' ^ k0) - v4, 7)          # 원래 v3 복원 (2^16 모듈러)

이걸 i = 2, 1, 0 순서로(라운드를 역순으로) 돌리면 4바이트 블록이 깔끔하게 복호화됩니다.

복호화 스크립트

import struct

def ROR(val, r, bits=16):
    r %= bits
    return ((val & (2**bits - 1)) >> r) | (val << (bits - r) & (2**bits - 1))

def decrypt_block(key, block):
    v3, v4 = struct.unpack('<HH', block)
    for i in range(2, -1, -1):           # 라운드 역순
        k0, k1 = key[2*i], key[2*i + 1]
        v5 = v4
        v4 = ROR(v3 ^ k1, 7)             # ROL -> ROR
        v3 = ROR(((v5 ^ k0) - v4) & 0xFFFF, 7)   # add -> sub, ROL -> ROR
    return struct.pack('<HH', v3, v4)

key = struct.unpack('<HHHHHH', open('key', 'rb').read())
enc = open('flag.enc', 'rb').read()

flag = b''.join(decrypt_block(key, enc[i:i+4]) for i in range(0, len(enc), 4))
print(flag.rstrip(b'\x00').decode(errors='ignore'))
DH{REDACTED}

정리

여기서의 리버싱은 기발한 트릭이 아니라 꼼꼼함의 문제입니다. 핵심은 라운드를 정확히 읽는 것, 16비트 폭을 지키는 것(그래서 뺄셈이 2^16으로 wrap되도록), 그리고 키와 데이터 양쪽 모두 리틀 엔디언 워드 배치를 기억하는 것으로 귀결돼요.

ARX 암호는 값싸고 빨라서 정말 자주 등장하는데, 이 문제는 왜 ARX가 설계상 가역인지를 깔끔하게 보여줍니다. 키를 복구한 공격자는 덧셈, 회전, XOR 단계를 산수만으로 거꾸로 돌려버릴 수 있으니까요.