[Rev] scriptCTF 2025 - plastic-shield 1, 2 write-up

TL;DR

두 문제는 리버싱 단골인 AES 문제이다. 쉬운 문제이니 깊은 설명 없이 풀 것이다.

 

plastic-shield: 비밀번호 전체가 아닌 특정 지점의 1바이트를 Blake2b 해시하여 AES-256-CBC /IV 파생하고 복호화에 사용한다.

= 게싱으로 풀린다.

 

plastic-shield 2: 마찬가지로 1바이트 게싱 -> 3바이트 게싱으로 올라간다.

 

plastic-shield

매우 많은 함수들이 있었지만, 결국 Pure한 AES와 BLAKE2b였다. 커스텀 아님

 

바이너리 실행흐름

int __fastcall main(int argc, const char **argv, const char **envp)
{
  _BYTE v4[256]; // [rsp+0h] [rbp-340h] BYREF
  _BYTE v5[16]; // [rsp+100h] [rbp-240h] BYREF
  _BYTE v6[32]; // [rsp+110h] [rbp-230h] BYREF
  char v7[64]; // [rsp+130h] [rbp-210h] BYREF
  _BYTE v8[79]; // [rsp+170h] [rbp-1D0h] BYREF
  char v9; // [rsp+1BFh] [rbp-181h] BYREF
  char v10[64]; // [rsp+1C0h] [rbp-180h] BYREF
  char s[263]; // [rsp+200h] [rbp-140h] BYREF
  unsigned __int8 v12; // [rsp+307h] [rbp-39h]
  void *ptr; // [rsp+308h] [rbp-38h]
  char *v14; // [rsp+310h] [rbp-30h]
  unsigned __int64 v15; // [rsp+318h] [rbp-28h]
  size_t v16; // [rsp+320h] [rbp-20h]
  size_t j; // [rsp+328h] [rbp-18h]
  size_t size; // [rsp+330h] [rbp-10h]
  unsigned __int64 i; // [rsp+338h] [rbp-8h]

  printf("Please enter the password: ");
  __isoc99_scanf("%255s", s);
  v16 = strlen(s);
  v15 = 60 * v16 / 0x64;
  v9 = s[v15];
  crypto_blake2b(v10, 64LL, &v9, 1LL);
  for ( i = 0LL; i <= 0x3F; ++i )
    sprintf(&v7[2 * i], "%02x", (unsigned __int8)v10[i]);
  v8[64] = 0;
  v14 = "713d7f2c0f502f485a8af0c284bd3f1e7b03d27204a616a8340beaae23f130edf65401c1f99fe99f63486a385ccea217";
  hex_to_bytes(v7, v6, 32LL);
  hex_to_bytes(v8, v5, 16LL);
  size = strlen(v14) >> 1;
  ptr = malloc(size);
  hex_to_bytes(v14, ptr, size);
  AES_init_ctx_iv(v4, v6, v5);
  AES_CBC_decrypt_buffer(v4, ptr, size);
  v12 = *((_BYTE *)ptr + size - 1);
  if ( v12 <= 0x10u && v12 )
    size -= v12;
  printf("Decrypted text: ");
  for ( j = 0LL; j < size; ++j )
    putchar(*((unsigned __int8 *)ptr + j));
  putchar(10);
  free(ptr);
  return 0;
}

최대 255바이트 비밀번호 s 입력 $\rightarrow$ 패딩이 제거된 복호화된 평문 출력인 간단한 입출력 구조를 갖고 있다.

 

1. 인덱싱 트릭

v15 = 60 * strlen(s) / 0x64;  // 0x64 == 100
v9 = s[v15];                  // 딱 이 바이트만 해시

길이가 1이면 idx=0; 길이가 10이면 idx=6 등 원하는 위치를 잡으려면 길이를 조절하면 된다.

 

2. 해시 $\rightarrow$ 키/IV 파생

crypto_blake2b(v10, 64, &v9, 1);    // 1바이트 입력
// v10(64바이트)을 128 hex로 변환해 v7에 씀 → v8까지 넘침
hex_to_bytes(v7, v6, 32);           // 키: 해시 앞 32바이트
hex_to_bytes(v8, v5, 16);           // IV: 해시 다음 16바이트

Intended Stack Overflow로 해시 문자열 두 버퍼(v7, v8)로 분할

결과적으로 BLAKE2b 결과의 [0:31] 바이트가 키, [32:47] 바이트가 IV가 된다.

 

3. 복호화 및 패딩 제거

AES_init_ctx_iv(ctx, key(32B), iv(16B));
AES_CBC_decrypt_buffer(ctx, ciphertext, size);
pad = last_byte;
if (1 <= pad <= 0x10) size -= pad;  // PKCS#7

 

Exploitation

길이는 임의로 정해 idx 위치를 통제할 수 있다.

후보 바이트 $c \in [0x00:0xff]$에 대해 H = BLAKE2b([c], 64) $\rightarrow$ key = H[0:32], iv = H[32:48] $\rightarrow$ 복호화, 패딩 제거 이후 알 수도 있다.

 

AES-CBC-256이지만, BLAKE2b에 들어가는 실질적인 데이터는 1바이트밖에 없다. 즉, 1바이트 게싱만 하면 된다.

 

from hashlib import blake2b
from Crypto.Cipher import AES
from binascii import unhexlify

C_hex = "713d7f2c0f502f485a8af0c284bd3f1e7b03d27204a616a8340beaae23f130edf65401c1f99fe99f63486a385ccea217"
C = unhexlify(C_hex)

def try_byte(c):
    H = blake2b(bytes([c]), digest_size=64).digest()
    key = H[:32]
    iv  = H[32:48]
    pt  = AES.new(key, AES.MODE_CBC, iv).decrypt(C)
    pad = pt[-1]
    if 1 <= pad <= 16 and all(x == pad for x in pt[-pad:]):
        pt = pt[:-pad]
    return pt

for c in range(256):
    pt = try_byte(c)
    if b"scriptCTF{" in pt:
        print(c, chr(c), pt.decode(errors="ignore"))
        break

 

백틱문자(`)에서 평문이 정상적으로 출력된다. 또한, 입력 길이를 1로 두면 idx=0이므로 1글자로 충분하다.

pwn@meow:~/ctftemp/rev$ python3 -u "/home/pwn/ctftemp/rev/pl1.py"
96 ` scriptCTF{20_cau541i71e5_d3f3n5es_d0wn}
pwn@meow:~/ctftemp/rev$ ./plastic-shield 
Please enter the password: `
Decrypted text: scriptCTF{20_cau541i71e5_d3f3n5es_d0wn}

 

번외: fake flag인줄 알고 문제를 안 풀고 있었다.

 


plastic-shield 2

바이너리 실행흐름

plastic-shield와 비슷한 흐름이다.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  _BYTE v4[256]; // [rsp+0h] [rbp-350h] BYREF
  char v5[26]; // [rsp+100h] [rbp-250h] BYREF
  char nptr[2]; // [rsp+11Ah] [rbp-236h] BYREF
  char dest[4]; // [rsp+11Ch] [rbp-234h] BYREF
  _BYTE v8[157]; // [rsp+120h] [rbp-230h] BYREF
  char v9[19]; // [rsp+1BDh] [rbp-193h] BYREF
  char v10[64]; // [rsp+1D0h] [rbp-180h] BYREF
  char s[263]; // [rsp+210h] [rbp-140h] BYREF
  unsigned __int8 v12; // [rsp+317h] [rbp-39h]
  void *ptr; // [rsp+318h] [rbp-38h]
  char v14; // [rsp+327h] [rbp-29h]
  char *v15; // [rsp+328h] [rbp-28h]
  size_t v16; // [rsp+330h] [rbp-20h]
  size_t j; // [rsp+338h] [rbp-18h]
  size_t size; // [rsp+340h] [rbp-10h]
  unsigned __int64 i; // [rsp+348h] [rbp-8h]

  printf("Please enter the password: ");
  __isoc99_scanf("%255s", s);
  v16 = strlen(s);
  crypto_blake2b(v10, 64LL, s, v16);
  for ( i = 0LL; i <= 0x3F; ++i )
    sprintf(&v8[2 * i + 32], "%02x", (unsigned __int8)v10[i]);
  v9[3] = 0;
  v15 = "e2ea0d318af80079fb56db5674ca8c274c5fd0e92019acd01e89171bb889f6b1";
  memset(v8, 0, 0x20uLL);
  strncpy(dest, v9, 3uLL);
  dest[3] = 0;
  hex_to_bytes(dest, v8, 1LL);
  nptr[0] = v9[2];
  nptr[1] = 0;
  v14 = strtol(nptr, 0LL, 16);
  v8[1] = 16 * v14;
  memset(v5, 0, 0x10uLL);
  hex_to_bytes(dest, v5, 1LL);
  v5[1] = 16 * v14;
  size = strlen(v15) >> 1;
  ptr = malloc(size);
  hex_to_bytes(v15, ptr, size);
  AES_init_ctx_iv(v4, v8, v5);
  AES_CBC_decrypt_buffer(v4, ptr, size);
  v12 = *((_BYTE *)ptr + size - 1);
  if ( v12 <= 0x10u && v12 )
    size -= v12;
  printf("Decrypted text: ");
  for ( j = 0LL; j < size; ++j )
    putchar(*((unsigned __int8 *)ptr + j));
  putchar(10);
  free(ptr);
  return 0;
}

 

하지만 아까처럼 1바이트만 넣어서 해싱하진 않는다. 그치만 뭔가 삐리하다.

 

스택 변수만 빠르게 정리해보겠다.

rbp-0x140 : 입력 버퍼 s (scanf 대상)
rbp-0x180 : BLAKE2b 출력(바이너리 64B)
rbp-0x210 : 해시의 hex 문자열(128글자) 시작 주소
rbp-0x190 : 위 hex 문자열의 널 종료 지점(= rbp-0x210 + 0x80)
rbp-0x230 : IV 버퍼(처음 0x20 바이트를 0으로 초기화 후, IV[0], IV[1]만 채움)
rbp-0x250 : Key 버퍼(16바이트, Key[0], Key[1]만 채움)
rbp-0x234 : dest[4] (마지막 3글자 + ‘\0’)
rbp-0x236 : nptr[2] (한 글자 + ‘\0’, strtol용)
rbp-0x28 : .rdata 포인터
rbp-0x38 : malloc한 암호문 바이트 배열 포인터
rbp-0x10 : 암호문 길이(바이트) 및 패딩 제거 후 길이
rbp-0x350 : AES 컨텍스트

 

 

printf("Please enter the password: ");
scanf("%255s", s);
v16 = strlen(s);
crypto_blake2b(v10, 64, s, v16);
for (i=0; i<=0x3F; ++i)
  sprintf(&v8[2*i + 32], "%02x", (unsigned __int8)v10[i]);

 

v10[64]는 BLAKE2b 64바이트 다이제스트

v8은 0x20 바이트를 먼저 0으로 채워 인덱스 32부터 다이제스트를 16진수 문자열로 채운다.

 

= 결과적으로 &v8[32]부터 128글자짜리 hex string이 존재

 

  v9[3] = 0;
  v15 = "e2ea0d318af80079fb56db5674ca8c274c5fd0e92019acd01e89171bb889f6b1";
  memset(v8, 0, 0x20uLL);
  strncpy(dest, v9, 3uLL);
  dest[3] = 0;
  hex_to_bytes(dest, v8, 1LL);
  nptr[0] = v9[2];
  nptr[1] = 0;
  v14 = strtol(nptr, 0LL, 16);
  v8[1] = 16 * v14;

v9가 핵심이다. 해시 hex 문자열의 앞 3글자를 담는다.

코드 흐름상 dest에 3글자를 담고 hex_to_bytes(dest, v8, 1)로 앞 2글자를 1바이트로 변환한다. (v8[0])

세 번째 글자는 strtol로 한 글자만 << 4 (*16) 하여 상위 니블로 v8[1] 로 기록한다.

 

v5 또한 동일 작업을 수행한다.

  memset(v5, 0, 0x10uLL);
  hex_to_bytes(dest, v5, 1LL);
  v5[1] = 16 * v14;
  -asm-
lea     rax, [rbp+var_250]
mov     edx, 10h        ; n
mov     esi, 0          ; c
mov     rdi, rax        ; s
call    _memset

v5[0] = v8[0], v5[1]=v8[1], 나머지는 0

 

정리

IV = v8[0..15] = [hex0..1, hex2<<4, 0×14]

Key = v5[0..15] = [hex0..1, hex2<<4, 0×14]

 

size = strlen(v15) >> 1;
ptr = malloc(size);
hex_to_bytes(v15, ptr, size);
AES_init_ctx_iv(v4, v8, v5);
AES_CBC_decrypt_buffer(v4, ptr, size);

암호문 v15는 32바이트이며, v5를 AES의 KEY, v8을 IV로 세팅하고 AES-CBC 복호화를 수행한다.

 

v12 = *((_BYTE *)ptr + size - 1);
if ( v12 <= 0x10u && v12 ) size -= v12; // PKCS#7
for (j=0; j< size; ++j) putchar(ptr[j]);

패딩 제거 후 평문 출력

 

unsigned __int64 __fastcall AES_CBC_decrypt_buffer(__int64 a1, __int64 *a2, unsigned __int64 a3)
{
  unsigned __int64 result; // rax
  __int64 v6; // [rsp+20h] [rbp-20h]
  __int64 v7; // [rsp+28h] [rbp-18h]
  unsigned __int64 i; // [rsp+38h] [rbp-8h]

  for ( i = 0LL; ; i += 16LL )
  {
    result = i;
    if ( i >= a3 )
      break;
    v6 = *a2;
    v7 = a2[1];
    InvCipher(a2, a1);		// 현재 블록 백업
    XorWithIv(a2, a1 + 240);	// 복호화 라운드
    *(_QWORD *)(a1 + 240) = v6;	// IV와 XOR
    *(_QWORD *)(a1 + 248) = v7;	// 다음 라운드용 IV *현재 암호블록
    a2 += 2;
  }
  return result;
}

 

a1+240, +248은 내부 context IV 위치 보관

 

Exploitation

최종 다이제스트는 64바이트를 그대로 사용하지 않고, 배열에 앞 3글자만 키와 IV에 반영한다는 점이다.

k0 = int(hex[0..1], 16)
k1 = int(hex[2], 16) << 4
---
Key = [k0, k1, 0x00 ..]
IV = [k0, k1, 0x00 ..]

 

즉 12비트만 찾으면 된다.

 

from Crypto.Cipher import AES
from hashlib import blake2b
from binascii import unhexlify

C_hex = "713d7f2c0f502f485a8af0c284bd3f1e7b03d27204a616a8340beaae23f130edf65401c1f99fe99f63486a385ccea217"
C = unhexlify(C_hex)

def try_byte(c):
    H = blake2b(bytes([c]), digest_size=64).digest()
    key = H[:32]
    iv  = H[32:48]
    pt  = AES.new(key, AES.MODE_CBC, iv).decrypt(C)
    pad = pt[-1]
    if 1 <= pad <= 16 and all(x == pad for x in pt[-pad:]):
        pt = pt[:-pad]
    return pt

for c in range(256):
    pt = try_byte(c)
    if b"scriptCTF{" in pt:
        print(c, chr(c), pt.decode(errors="ignore"))
        break

 

 

pwn@meow:~/ctftemp$ python3 -u "/home/pwn/ctftemp/rev/pl2.py"
fef scriptCTF{00p513_n07_4641n!}

 

재밌는 문제, 패딩 주의할 것