CS/소프트웨어보안

8. Toyransome

arsenic-dev 2025. 12. 9. 01:16

경희대학교 장대희 교수님의 소프트웨어보안 수업을 기반으로 정리한 글입니다.

 

Ransomware 소개

Ransomware

  • Ransom(몸값)과 software(소프트웨어)의 합성어
  • 시스템을 잠그거나 데이터를 암호화해 사용할 수 없도록 하고 이를 인질로 금전을 요구하는 악성 프로그램
  • 신뢰할 수 없는 사이트, 스팸메일, 파일공유 사이트 등을 통해 유포됨 
  • 이렇게 특정한 경로로 피해자 PC에 접근을 한 후, 바이러스를 통해 피해자가 가지고 있는 파일들을 암호화 함

 

※ 실습과 달리, 현대의 암호화는 키 없이 복호화가 불가능하다.

 

랜섬웨어는 위와 같이 고유한 확장자와 함께 감염된 시스템 드라이브의 파일을 암호화한다. 암호화되면서 파일이 손상되어 정상적으로 실행하거나 읽을 수 없게 되고, 해커는 복호화 키에 대해 거액의 돈을 요구한다.

 

 

간단한 복호화 로직

xor 복호화

xor 연산은 비트 단위의 연산으로, 두 개의 비트를 비교하여 서로 다를 때 '1', 같을 때 '0'을 반환한다. 

  • A = 1010, B = 1100 (이진수)
  • C = A ^ B = 1010 ^ 1100 = 0110 (암호화 과정, key = B)
  • A = C ^ B = A ^ B ^ B = A ^ 0 = A (복호화  과정, key = B)

 

-> xor 연산은 대칭적이며 같은 키를 이용하여 암호화와 복호화를 모두 수행할 수 있음

 

다중 암호화와 복호화

암호화를 여러 단계로 적용하였을 때, 복호화 과정은 암호화 한 순서의 역순으로 해독해야 한다.

 

※ xor 암호화는 순서 바꿔도 상관 없다.

 

실습 

실습 목적

  • 암호화되어 있는 파일 복호화
  • 바이너리 파일을 분석하여 복호화 로직 구하여 평문 flag 획득

 

문제 접근 방식

  • toryransome2를 제외하고는 소스코드가 없음
  • 따라서 리버스 엔지니어링을 통해 바이너리를 분석 후 flag 획득

 

1. toyransome2

문제 설명

  • 주어진 소스코드 파일을 분석하여 암호화된 파일을 복호화하는 문제

 

파일 구성

  • flag.enc: 암호화된 flag 파일
  • toyransome2: 암호화 바이너리 파일
  • toyransome2.txt: 암호화 바이너리 파일의 소스코드 (C 언어)

 

ll -A: ls long all

 

target_file에 암호화 할 파일을 넣으면 됨

 

예시로 fake_flag.txt 파일을 만들어 암호화 한 모습

 

toyransome2.txt

 

toyransome2 바이너리에 타겟 파일을 넣으면 이 타겟 파일이 암호화되는 그런 로직이다.

  • 타겟 파일을 읽어와 버퍼에 저장을 하고, key로 xor 암호화

 

※ 아스키 'A' = 0x41 = 0100 0001

 

먼저, fread 함수를 통해 buf에 타겟 파일을 담는다.

이때, fread의 반환 값은 읽은 바이트 수이기 때문에, r에는 파일의 길이가 담긴다. 

  • r: flag_length

 

그리고 나서 flag_length만큼 for문이 돌면서 암호화를 진행한다.

 

※ main 함수의 인자

  • argc: 인자 개수
  • argv: 인자로 들어가는 내용
    • e.g., argv[0] = "./toyransome2", argv[1] = "fake_flag.txt"

 

fp = fopen(strncat(argv[1], ".enc", 4), "wb+");

▶ argv[1] 뒤에 .enc를 붙여서 그 이름으로 바이너리(b) 파일을 새로 만들고(w) 읽기/쓰기 가능(+)하게 여는 코드

 

XOR로 암호화된 것을 복원하려면?

 

XOR로 암호화 된 것은 XOR로 복원하면 된다. 

XOR 연산은 "A ^ B ^ B = A ^ 0 = A" 이렇게 상쇄되는 특징이 있기에, 이를 이용해 복호화하면 된다.

 

with open("flag.enc","rb") as f_ptr:
    flag_data =f_ptr.read()

flag_key="8C9031DB7A03F189"

flag_key=bytes.fromhex(flag_key)

plain_flag=b''

for i in range(len(flag_data)):
    plain_flag += bytes([flag_key[i%len(flag_key)]^flag_data[i]])

print(plain_flag)

 

16진수 표기에서 A~F 는 대소문자를 가리지 않으므로 복호화할 때 대문자로 써도 결과 바이트 값이 완전히 동일하게 나온다.

 

복호화 결과 1

 

혹은 한 번 더 xor, 즉 한 번 더 암호화 해주는 방식으로도 복호화가 가능하다.

복호화 결과 2

 

2. toyransome3

문제 설명

  • 주어진 바이너리 파일을 분석하여 암호화된 파일을 복호화하는 문제

 

파일 구성

  • flag.enc: 암호화된 flag 파일
  • toyransome3: 암호화 바이너리 파일 (64bit ELF 파일 형식)

 

문제 풀이 팁

  • 소스 코드 제공 X -> 리버스 엔지니어링을 통해 어셈블리 분석하는 기술 필요
  • IDA와 같은 리버싱 툴을 이용하여 어셈블리를 분석

 

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char v3; // r12
  char v4; // r12
  char *v5; // rdx
  int i; // [rsp+10h] [rbp-1040h]
  int j; // [rsp+10h] [rbp-1040h]
  int v10; // [rsp+14h] [rbp-103Ch]
  int v11; // [rsp+14h] [rbp-103Ch]
  FILE *stream; // [rsp+18h] [rbp-1038h]
  FILE *streama; // [rsp+18h] [rbp-1038h]
  char ptr[16]; // [rsp+30h] [rbp-1020h] BYREF
  unsigned __int64 v15; // [rsp+1038h] [rbp-18h]

  v15 = __readfsqword(0x28u);
  if ( argc == 2 )
  {
    stream = fopen(argv[1], "rb");
    if ( stream )
    {
      v10 = fread(ptr, 1u, 0x1000u, stream);
      printf("%d bytes read\n", v10);
      for ( i = 0; i < v10; ++i )
      {
        v3 = ptr[i];
        ptr[i] = byte_204C[i % strlen(byte_204C)] ^ v3;
      }
      puts(ptr);
      for ( j = 0; j < v10; ++j )
      {
        v4 = ptr[j];
        ptr[j] = byte_2055[j % strlen(byte_2055)] ^ v4;
      }
      fclose(stream);
      v5 = (char *)argv[1];
      strcat(v5, ".enc");
      streama = fopen(v5, "wb+");
      v11 = fwrite(ptr, 1u, j, streama);
      printf("%d bytes encrypted\n", v11);
    }
    else
    {
      printf("can't open %s\n", argv[1]);
    }
  }
  else
  {
    puts("usage: ./toyransome3 [target_file]");
  }
  return __readfsqword(0x28u) ^ v15;
}

▶ IDA로 디컴파일 한 결과

 

toyransome3 vs toyransome4

 

toyransome2와 비교해 보면 가운데 for문만 다르다는 것을 알 수 있다.

toyransome2는 for문이 한 번 있고, toyransome3는 for문이 두 번 있어 암호화가 두 번 된 것이다.

 

key 값

 

첫 번째 XOR 연산의 key 값은 "8c9031db7a03f189"이고, 두 번째 XOR 연산의 key 값은 "c9330180f7"이다.

  • '1'의 아스키 코드 = 0x31
  • 'z'의 아스키 코드 = 0x7a
  • '3'의 아스키 코드 = 0x33

 

다중 암호화의 경우 역순으로 해독해야 한다. 물론, xor의 특성상 교환 법칙이 성립하여 현재 코드에선 순서가 중요하지 않다.

toyransome2의 복호화 코드를 이용해 toyransome3의 복호화 코드를 짜면 다음과 같다.

with open("flag.enc","rb") as f_ptr:
    flag_data =f_ptr.read()

flag_key="c9330180f7"

flag_key=bytes.fromhex(flag_key)

plain_flag=b''

for i in range(len(flag_data)):
    plain_flag += bytes([flag_key[i%len(flag_key)]^flag_data[i]])

flag_key="8c9031db7a03f189"

flag_key=bytes.fromhex(flag_key)

for i in range(len(plain_flag)):
    plain_flag += bytes([flag_key[i%len(flag_key)]^plain_flag[i]])

print(plain_flag)

 

복호화 결과 1

 

xor 연산은 순서가 중요하지 않기에 다음과 같이 암호화를 그냥 한 번 더 하는 방식으로도 복호화가 가능하다.

복호화 결과 2

 

3. toyransome4

문제 설명

  • 주어진 바이너리 파일을 분석하여 암호화된 파일을 복호화하는 문제

 

파일 구성

  • program.bin.enc: 암호화된 바이너리 파일 (Hint. 암호화 전 바이너리는 elf 파일)

IDA 상에서 바이너리를 열었을 때의 화면

 

 

⭐ 4. toyransome5

문제 설명

  • 주어진 바이너리 파일을 분석하여 암호화된 파일을 복호화하는 문제

 

파일 구성

  • flag.enc: 암호화된 flag 파일
  • toyransome5: 암호화 바이너리 파일 
    • 안티리버싱이 적용되어 있어 어셈블리 언어로만 분석해야 함 (C 코드로 복원 불가)

 

문제 풀이 팁

  • IDA 분석 시도
    • 함수 초반의 반복문으로 인해 스택 프레임이 커져서 IDA 헥스레이 기능 이용 불가능 (안티리버싱)
    • 복호화된 코드가 아닌 어셈블리 분석 필요

 

※ 이렇게 안티리버싱과 같이 디컴파일 기능을 방해하는 이슈도 존재한다.

 

fopen, fread 그리고 fclose 사이 loc_1361과 loc_131F가 암호화하는 반복문임

 

핵심이 되는 basic block

 

instruction 설명

  • mov: 오른쪽에 있는 값을 왼쪽으로 넣음 (해당 코드에선 초기값 0)
  • cdqe: 32bits -> 64bits extension (알 필요 X)
  • movzx: 'fread'의 ptr이므로, ptr을 통해 파일 데이터를 0번부터 가져오는 것을 알 수 있음
  • add: 1을 더하는 것으로 보아, 반복문 한 번 끝날 때 마다 인덱스 하나가 증가할 때의 그 인덱스임을 알 수 있음 -> 네이밍

 

어셈블리 명령어를 해석하면 오른쪽으로 1비트만큼 시프트 한 값과 왼쪽으로 7번 시프트 한 값을 or 하는 게 로테이션 로직임을 알 수 있다. 결국, 이를 해석하면 총 8비트에서 맨 오른쪽 하위 비트가 젤 상위 비트로 올라오고 나머지 비트는 한 비트씩 밀리는, 로테이션식의 손실 없는 암호화 로직임을 알 수 있다.

 

그리고 그 값을 'dl' 레지스터를 이용해 다시 ptr로 넣는 것을 확인 할 수 있다.

 

 
 

어셈블리 명령어 정리

 

기본 데이터 이동 계열

  • mov – MOVe: 오른쪽 값(레지스터/메모리)을 왼쪽에 복사
  • movzx – MOV with Zero eXtend: 작은 크기 값을 큰 레지스터로 옮기면서 상위 비트를 0으로 채움
  • movsx – MOV with Sign eXtend: 작은 크기 값을 큰 레지스터로 옮기면서 부호비트를 유지해서 채움
  • cdqe – Convert Doubleword to Quadword Extended: EAX(32비트)를 RAX(64비트)로 부호 확장해서 변환 (32bit int → 64bit, 캐스팅(형 변환 느낌)

 

비트 연산/시프트 계열

  • sar – Shift Arithmetic Right: 오른쪽으로 n비트 쉬프트, 왼쪽 끝은 부호비트 그대로 유지 (나누기 2^n)
  • shl – SHift Left: 왼쪽으로 n비트 쉬프트, 오른쪽은 0으로 채움 (곱하기 2^n)
  • or – bitwise OR: 두 값의 비트를 OR 연산. 하나라도 1이면 1
  • xor – bitwise XOR (eXclusive OR): 두 비트가 다를 때만 1

 

산술/비교/분기 기본

  • add – ADD: 왼쪽 피연산자에 오른쪽 값을 더함
  • sub – SUBtract: 왼쪽 피연산자에서 오른쪽 값을 뺌
  • cmp – CoMPare: "왼쪽 - 오른쪽"을 계산해서 결과만 플래그에 기록하여 다음에 오는 점프의 조건을 결정
  • jmp – JuMP: 무조건 해당 라벨/주소로 점프
  • je / jne – Jump if Equal / Jump if Not Equal: 바로 전에 cmp한 결과가 같으면/다르면 점프
  • jg / jge – Jump if Greater / Jump if Greater or Equal: 비교 결과가 크다/크거나 같다이면 점프 (signed 기준)
  • jl / jle – Jump if Less / Jump if Less or Equal: 비교 결과가 작다/작거나 같다면 점프 (signed 기준)

 

※ RAX 계열 레지스터 크기별 이름

  • RAX: 전체 64비트
  • EAX: RAX의 하위 32비트
  • AX: RAX의 하위 16비트
  • AH: AX의 상위 8비트
  • AL: AX의 하위 8비트

 

복호화 코드는 다음과 같다.

with open("flag.enc", "rb") as f_ptr:
    enc_data = f_ptr.read()

plain = b''

for i in range(len(enc_data)):
    y = enc_data[i]       

    x = ((y << 1) & 0xFF) | (y >> 7) #0xFF과 & 하는 것은 하위 8비트만 남기고 나머지 비트는 전부 버리기 위함임

    plain += bytes([x])

print(plain)

 

오른쪽으로 쉬프트 연산을 해서 암호화 하였으면 왼쪽으로 쉬프트 연산을,

왼쪽으로 쉬프트 연산을 해서 암호화 하였으면 오른쪽으로 쉬프트 연산을 하여 복호화 한다.

 

복호화 결과

 

혹은, 순환형 구조이기 때문에 flag.enc를 7번 더 암호화하여 원래대로 돌려 놓는 식으로 복호화할 수도 있다.

  • '8비트 회전의 주기 = 8'을 이용한 복호화

 

 
 

레지스터 용도 정리

 

범용 레지스터: RAX/RBX/RCX/RDX/RSI/RDI/R8~R15

리턴값 및 시스템콜 번호 레지스터: RAX

RDI: destination index 레지스터 (1번째 인자)

RSI: source index 레지스터 (2번째 인자)

RBP: base pointer 레지스터 (현재 함수 스택 프레임 기준점)

RSP: stack pointer 레지스터 (스택의 최상단)

RIP: 프로그램 카운터 (현재 명령어 위치)

 

※ 현재는 64 bit 기준이기에 R~ 인 것이고, 32 bit 기준으로 하면 E~ 로 변경하면 된다.

 

5. toyransome6

문제 설명

  • 주어진 바이너리 파일을 분석하여 암호화된 파일을 복호화하는 문제

 

파일 구성

  • flag.enc: 암호화된 flag 파일
  • toyransome6: 암호화 바이너리 파일

Hint. 핵심 basic block

 

instruction 설명

  • 현재 인덱스를 eax로 가지고 옴 -> 해당 인덱스의 값을 edx에 담음
  • "eax - 1"을 통해 이전 인덱스를 eax로 가지고 옴 -> 이전 인덱스의 값을 eax에 담음
  • edx와 eax끼리 xor 연산을 함 -> 연산 결과를 edx에 저장
  • eax에 다시 현재 인덱스를 가지고 옴 -> 해당 값으로 현재 인덱스의 값을 업데이트 (암호화)

-> 암호화 로직: enc[n] = flag[n] ^ enc[n-1]

 

※ dl: edx의 하위 1바이트 (dl = edx & 0xFF)

 

여기까지 해석하면 현재 인덱스가 0일 때 이전 인덱스가 -1이 되어 OOB(Out Of Bound)가 발생한다는 것을 알 수 있다.

그래서 이 문제의 경우 핵심만 보는 것이 아닌 앞의 부분도 같이 봐야 한다.

 

 

이렇게 앞 부분을 확인해 보면 인덱스가 0이 아닌 1부터 시작하는 것을 알 수 있다.

그리고 인덱스 0의 경우엔 따로 암호화가 되어 있음을 확인할 수 있다.

 

enc[0] instruction 설명

  • 인덱스를 0으로 초기화
  • [rbp+var_3E8034]에서 4바이트만큼 eax로 가지고 옴 -> eax 중 하위 1 바이트(al)만큼 key로 가지고 옴
  • 0번째 인덱스의 값을 eax에 가지고 옴 ->  key와 xor 연산을 함
  • xor 연산 결과를 eax에 저장 -> 해당 값으로 현재 인덱스의 값을 업데이트 (암호화)

-> enc[0] 암호화 로직: enc[0] = flag[0] ^ [rbp+var_3E8034]

 

그렇다면 [rbp+var_3E8034] 값은 어디서 왔을까?

 

 

[rbp+var_3E8034] 값은 edi에서 가져온 값으로, 이는 main 함수의 첫 번째 인자인 argc(인자 개수)를 의미이다.

즉, 첫 번째 인덱스의 flag를 암호화하는 key 값은 프로그램을 실행할 당시의 argc의 하위 1바이트가 된다.

 

실제로 제공된 flag.enc를 분석해보면 첫 바이트 복호화를 통해 이 값이 28(0x1c)임을 역으로 확인할 수 있으며, 이는 해당 flag.enc 파일을 만들 때 argc가 28이 되도록 여러 인자를 넣어 실행했음을 의미한다.

 

반대로 동일한 프로그램을 "./toyransome6 flag2.enc 1 2 3"처럼 실행하여 flag2.enc를 만들면 그 시점의 argc 값은 5이므로, 그 파일의 첫 번째 인덱스의 암호화 키는 0x05가 된다.

 

즉, 파일마다 xor 초기 키는 그 파일을 생성할 때의 argc에 의해 결정된다.

 

복호화의 경우엔, xor 연산의 특성상 "buf[i] ^ buf [i-1] ^ buf [i-1] = buf [i]" 이므로 이를 이용해 복호화 해주면 된다.

 

먼저, 0번째 인덱스의 값은 암호화 로직이 "enc[0] = flag[0] ^ 0x1c"이고,

그 외의 경우엔 암호화 로직이 "enc[n] = flag [n] ^ enc[n-1]" 임을 이용해 복호화 로직을 짜면 다음과 같다.

  • flag[0] = enc[0] ^ 0x1c = flag[0] ^ 0x1c ^ 0x1c = flag[0]
  • flag[1] = enc[1] ^ enc[0] = flag[1] ^ enc[0] ^ enc[0] = flag[1]
  • ...
  • flag[n] = enc[n] ^ enc[n-1] = flag[n] ^ enc[n-1] ^ enc[n-1] = flag[n]

 

복호화 코드는 다음과 같다.

with open("flag.enc", "rb") as f:
    enc = f.read()

plain = b''

plain += bytes([enc[0] ^ 0x1c])

for i in range(1, len(enc)):
    plain += bytes([enc[i] ^ enc[i - 1]])

print(plain)

 

복호화 결과

 

※ IV: Initialization Vector의 약자로, 첫 번째 데이터 블록을 암호화할 때 사용되는 임의의 값이다.

 

6. toyransome7

문제 설명

  • 주어진 바이너리 파일을 분석하여 암호화된 파일을 복호화하는 문제

 

파일 구성

  • program.bin.enc: 암호화된 flag를 출력하는 바이너리 파일
  • toyransome7: 암호화 바이너리 파일

Hint. 핵심 basic block

 

instruction 설명

  • mov: 인덱스 값을 eax에 저장
  • imul: eax * eax 연산 결과를 eax에 저장 (Integer Multiply) -> eax 제곱
  • add: eax + 31337 연산 결과를 eax에 저장
  • xor: eax ^ cafebabe 연산 결과를 eax에 저장

 

여기서 rax*4를 해주는데, 이는 index*4이며, index가 0부터 1 2 이렇게 있을 때 0 4 8 이렇게 저장한다는 의미이다.

즉, 4바이트로 패딩한 다음에 값이 저장되어, index가 0일 땐 0에, 1일 땐 4에 저장한다. (4바이트마다 1바이트씩 저장됨)

 

이 문제의 암호화 로직은 크게 두 가지 과정으로 이루어져 있다.

  • 값을 연산하는 단계: 곱하기, 더하기, xor -> 새로운 값 생성
  • 연산된 값을 메모리에 저장하는 단계: 계산한 결과를 4바이트 단위로 저장

 

복호화를 하려면 이 과정을 역연산 해야 한다.

  • 저장된 4바이트 값을 가져오는 단계
  • 값을 연산하는 단계: xor, 빼기, 루트 연산 -> 1바이트로 출력

 

복호화 코드는 다음과 같다.

import math

with open("program.bin.enc", "rb") as f:
    enc_data = f.read()

plain = b''

for i in range(0, len(enc_data), 4):
    chunk = enc_data[i:i+4]
    if len(chunk) < 4:
        break

    val = int.from_bytes(chunk, "little")

    val ^= 0xCAFEBABE
    val -= 0x31337
    orig = math.isqrt(val)

    plain += bytes([orig])

with open("program.bin", "wb") as f:
    f.write(plain)

 

복호화한 프로그램 실행 결과

 

IDA 버전에 따라 다음과 같이 블록이 나뉘어 질 수도 있다. (로직은 동일)

Hint 핵심 basic block: 1314, 1357

 

해당 문제는 사실 다음과 같은 형태로 구성이 되어있다.

  • output[i] = table[input[i]

 

table은 loc_1314에서 만들어지고, 이후 loc_1357에서 해당 테이블이 포인터 형태로 참조되어

crypted[i] = table[plain[i]]] 형태로 사용된다.

 

다만, table을 참조하는 방식이 입력 바이트를 그대로 치환 테이블에서 lookup 하는 단순 치환 방식 (substitution) 으로,

추가적인 비트연산이나 혼합 과정 없이 정적 1:1 매핑으로만 구성되어 있다.

 

그렇기에 복호화 과정이 table을 만드는 로직을 역으로 한 것과 완전히 동치이기에 위와 같이 풀어도 되는 것이다.

'CS > 소프트웨어보안' 카테고리의 다른 글

7-2. BoF Exploit 2  (0) 2025.12.14
7-1. BoF Exploit 1  (0) 2025.12.13
6. Reversing  (0) 2025.12.06
5-2. 리눅스 사용  (0) 2025.10.17
5-1. 소프트웨어 보안을 위한 리눅스 기초  (0) 2025.10.17