개요
우연하게 삼국지를 주제로 한 DOS 게임 중 하나인 적벽대전을 다시 접하게 되었다. 당시에는 신경쓰지 않고 지나갔지만 해당 게임은 폭소시리즈와 삼국지 무장쟁패로 유명한 대만의 ‘Panda Entertaintment’에서 제작한 게임이다. 필자는 해당 게임을 구매하여 정품으로 보유하고 있었으나 현재에는 찾을 길이 없어 인터넷에서 다운로드한 대상으로 작업을 수행하였다. 대상 게임은 잘 알려져 있지 않아 국내에서는 거의 정보를 찾을 수 없지만 대만에서 1994년도에 발매된 게임으로 Wiki에서 확인된다. Panda Entertaintment는 현재 운영하지 않는걸로 보여지고 게임 또한 고전으로 실질적으로 저작권에 큰 영향을 받지 않는걸로 보여진다.
준비
- DOSBox-x : 도스 프로그램 구동과 디버깅을 위하여 사용
- Debugger 기능이 포함된 macOS용 바이너리를 사용하였다.
- IDA Pro : 디스어셈블러, 도스 프로그램의 역공학을 위하여 사용
- IDA를 사용하면 5분안에 분석이 완료될 정도로 너무나 간단하여 본문에서는 굳이 사용하지 않았다..
- Sourcer : DOS용 디스어셈블러, 인터넷 검색 후 다운로드 한 프로그램을 사용
- Ghidra : 오픈소스 공개 디스어셈블러, 도구 비교를 위해 사용
- 010 Editor : 파일 에디터
- 적벽대전 게임 (한글판)
- SANRPG.EXE
- filesize : 224476 bytes
- md5 : 654fc9ab9aee09692dbe33d075ee83ba
목표 확인
크랙이 전혀 이루어지지 않은 대상을 찾아보려 하였으나 찾지 못하였다. 인터넷에서 일부 코드만 패치된 게임을 다운로드할 수 있었으며 해당 파일을 대상으로 작업을 수행하였다.
게임을 구동시에 [게임시작], [불러오기], [환경설정]의 메뉴를 클릭하면 패스워드 검증 화면이 표시된다. 입수한 파일의 경우 [시작하기]로 게임을 시작하면 패스워드 입력 창이 표시되지 않았다. 그러나 [불러오기]나 [환경설정] 메뉴를 클릭하면 패스워드 입력 창이 표시된다. 또한 [시작하기]로 게임을 시작한 이후 메뉴로 다시 돌아와서 [불러오기]나 [환경설정] 메뉴를 클릭하면 패스워드 입력 창이 나오지 않았다.
패스워드 입력에 실패하면 도스 터미널에 ‘password error.’ 문자열이 출력되고 게임이 종료된다.
분석
코드 예상
코드 분석을 하기 이전에 기존에 알게된 정보를 토대로 아래와 같이 정리를 해보았다.
- 누군가 [게임시작] 메뉴 클릭시에 나타나는 패스워드 입력만 크랙하였다.
- [게임시작] 메뉴 클릭 후에 다시 [불러오기]나 [환경설정]을 실행시에는 패스워드를 묻지 않는다.
- 크랙이 완료된 [게임시작] 메뉴를 실행하면 어디인가에 저장된 패스워드 검증 완료 스위치가 변경되어 더 이상 패스워드를 묻지 않게 된다. (예상)
- 패스워드 입력이 실패하면 ‘password error.’ 문구 출력 후 게임이 종료되는 루틴이 실행된다.
에러 출력 로직 분석
쉬운 접근 방법인 코드 예상의 3번 항목을 분석하여 어떠한 과정에 의하여 에러가 출력되는지를 확인해보도록 한다.
IDA를 사용하면 5분안에 크랙이 가능한 쉬운 샘플로서 이번에는 DOS 시절의 고전 디스어셈블러인 ‘SOURCER’를 사용해보았다. DOS 실행파일 분석이 가능하다면 디스어셈블러 선정은 크게 중요하지 않다. SOURCER는 제작사에 의하여 현재 유지보수가 되지않으며 인터넷에서 크랙된 도스 버전을 다운로드할 수 있다. SOURCER의 다운로드 방법과 설치는 여기에서 다루지 않는다.
SR.EXE로 SOURCER를 실행하면 분석할 파일명을 입력받는데 여기에 대상 파일 (SANRPG.EXE)의 경로를 입력한다.
Loading이 완료되고 (옵션을 변경하지 않았다면) G 키를 누르면 SANRPG.LST 파일이 생성된다. 에디터로 SANRPG.LST를 열고 에러 문자열인 ‘password error’를 검색하면 아래와 같이 찾아진다.
50C7:0D90 70 61 db 70h, 61h 50C7:0D92 6E 64 61 31 36 2E db 'nda16.pcx', 0 50C7:0D98 70 63 78 00 50C7:0D9C 70 61 73 73 77 6F db 'password error.', 0 50C7:0DA2 72 64 20 65 72 72 50C7:0DA8 6F 72 2E 00 50C7:0DAC 70 61 73 73 77 6F db 'password error.' 50C7:0DB2 72 64 20 65 72 72 50C7:0DB8 6F 72 2E
동일한 에러문구가 인접한 곳에 두 곳에 존재한다. 안타깝게도 SOURCER가 해당 문자열을 참조하는 코드를 찾아주지 못하였다. 문자열 주소인 ‘0D 9C’ (파일 상의 값은 ‘9C 0D’이지만 SOURCER가 주소 표기를 위해 반전)를 직접 검색하여 코드 내에 참조하는 곳이 있는지 확인 한다. 다행히도 검색 결과는 아래 한 군데 뿐이다.
37A4:14A6 83 3E 0AD4 00 cmp word ptr ds:[0AD4h],0 ; (2923:0AD4=8BE0h) 37A4:14AB 74 09 je loc_1298 ; Jump if equal 37A4:14AD A1 0AD4 mov ax,word ptr ds:[0AD4h] ; (2923:0AD4=8BE0h) 37A4:14B0 3B 06 3A54 cmp ax,ds:data_141e ; (2923:3A54=2D38h) 37A4:14B4 74 18 je loc_1299 ; Jump if equal 37A4:14B6 loc_1298: ; xref 37A4:14AB 37A4:14B6 9A 4711:0421 call far ptr sub_447 ; (4711:0421) 37A4:14BB 3D 0001 cmp ax,1 37A4:14BE 74 0E je loc_1299 ; Jump if equal 37A4:14C0 1E push ds 37A4:14C1 68 9C 0D 6A 08 9A db 68h, 9Ch, 0Dh, 6Ah, 08h, 9Ah 37A4:14C7 02 00 db 02h, 00h 37A4:14C9 3BC3 dw seg_r 37A4:14CB 83 C4 06 db 83h,0C4h, 06h 37A4:14CE loc_1299: ; xref 37A4:14B4, 14BE 37A4:14CE A1 3A54 mov ax,ds:data_141e ; (2923:3A54=2D38h) 37A4:14D1 A3 0AD4 mov word ptr ds:[0AD4h],ax ; (2923:0AD4=8BE0h) 37A4:14D4 E8 EBC5 call sub_287 ; (009C) 37A4:14D7 B8 0013 mov ax,13h 37A4:14DA CD 10 int 10h ; Video display ah=functn 00h ; set display mode in al
37A4:14C1에 주소가 포함되어있지만 해당 구문이 SOURCER에 제대로 디스어셈블 되지 않았다. 0x68은 PUSH imm16 명령으로 해석하면 아래와 같다.
37A4:14C1 push 0D9C
뒤의 코드를 마저 해석하면 아래와 같다.
37A4:14C4 push 8 37A4:14C6 call seg_r:0002 ; 3BC3:0002 37A4:14CB add sp,6
16Bit DOS 프로그램은 Segment:Offset 형태의 메모리 주소가 사용된다. 시스템이 달라져도 Offset은 동일하지만 Segment는 환경에 따라 변경될 수 있다.
SOURCER를 기준으로 3BC3:0002에 위치하는 함수 코드는 에러 코드와 문자열을 인자로 전달 받고 에러 출력 후 프로그램을 종료하여 DOS로 복귀한다. 패스워드 입력을 실패하면 3BCD:0002 함수를 호출하여 ‘password error.’ 문자열을 출력 후 종료한다.
패스워드 검증 로직
에러 출력 구문으로 어떻게 도달하는지 위의 코드를 다시 확인한다.
37A4:14C0은 ‘password error.’를 호출하기 위한 코드이다. 해당 코드의 위에는 3개의 점프 구문이 존재한다. 37A4:14B4 je loc_1299 와 37A4:14BE je loc_1299는 에러 출력을 건너띈다. 해당 부분에서 무조건 점프 시키면 패스워드 에러가 출력되지 않고 게임도 종료되지 않는다. 패스워드 크랙이 가능한 부분이다.
37A4:14AB je loc_1298는 아래 37A4:14B4 점프 루틴 조건식을 건너띌지 여부를 결정한다. 37A4:14B4는 점프를 위한 조건으로 ds:[0AD4h]의 값이 0인지 비교한다. 해당 값이 0인 경우에는 패스워드 에러 출력를 건너띄고 loc_1299 대신 loc_1298을 실행한다.
37A4:14B6 loc_1298 은 4711:0421을 함수를 호출하는데 해당 함수의 결과가 1인 경우에는 loc_1299로 점프하고 1이 아닌 경우에는 ‘password error.’를 출력하고 게임이 종료된다. 호출되는 함수 4711:0421은 패스워드 입력과 관련된 함수로 추정할 수 있다. 실제로 해당 함수안에는 랜덤한 암호문 선택을 위한 것으로 추정되는 rand 함수와 암호문 데이터로 추정되는 code.pak 파일을 열람한다. 해당 함수를 분석하는 것도 흥미로워 보이지만 나중의 기회로 남겨두도록 한다.
검증로직은 아래와 같이 수행된다.
- ds:[0AD4h]를 검사하여
- 0이면 패스워드 검사 루틴(3번)으로 이동
- 0 이외의 값이면 아래의 2번 검증 루틴 수행
- ds[0AD4h]의 값이 ds:data_141e의 값과 일치하는지 확인
- 일치하면 3번의 패스워드 검증 루틴 스킵 (게임 구동 후 패스워드 검사를 한번이라도 통과한 경우에는 재검증하지 않음)
- 일치하지 않는 경우 패스워드 검증 루틴 수행
- 4711:0421 함수를 호출하여 패스워드 검증
- 함수 호출 후 결과 값(ax)이 1과 같다면 패스워드 검사 성공으로 게임 실행
- 결과 값(ax)이 1이 아닌 경우에는 ‘password error.’ 문자열 출력 후 게임 종료
- 패스워드 검사를 통과하면 게임 실행 중에 패스워드 검사가 다시 나타나지 않도록 ds[0AD4h]에 ds:data_141e의 값을 넣음
크랙 전략
다양한 크랙 방법이 존재한다. 대상 파일은 3번과 동일한 로직이 사용되는 패스워드 검증 결과 비교 코드인 37A4:179C의 코드를 검증이 실패 하는 경우에 점프하도록 ‘jne loc_1311’로 패치하였다. 크랙이 일부 완료된 샘플 바이너리는 [시작하기] 메뉴에서 표시되는 패스워드 검증에서 실패하는 경우에는 ‘jne loc_1311’ 코드에 의하여 ‘password error.’ 출력 없이 ‘loc_1311’로 점프하여 패스워드 검증을 우회하게 된다.
37A4:1784 83 3E 0AD4 00 cmp word ptr ds:[0AD4h],0 ; (2923:0AD4=8BE0h) 37A4:1789 74 09 je loc_1310 ; Jump if equal 37A4:178B A1 0AD4 mov ax,word ptr ds:[0AD4h] ; (2923:0AD4=8BE0h) 37A4:178E 3B 06 3A54 cmp ax,ds:data_141e ; (2923:3A54=2D38h) 37A4:1792 74 18 je loc_1311 ; Jump if equal 37A4:1794 loc_1310: ; xref 37A4:1789 37A4:1794 9A 4711:0421 call far ptr sub_447 ; (4711:0421) 37A4:1799 3D 0001 cmp ax,1 37A4:179C 75 0E jne loc_1311 ; Jump if not equal 37A4:179E 1E push ds 37A4:179F 68 AC 0D 6A 08 9A db 68h,0ACh, 0Dh, 6Ah, 08h, 9Ah 37A4:17A5 02 00 db 02h, 00h 37A4:17A7 3BC3 dw seg_r 37A4:17A9 83 C4 06 db 83h,0C4h, 06h 37A4:17AC loc_1311: ; xref 37A4:1792, 179C 37A4:17AC A1 3A54 mov ax,ds:data_141e ; (2923:3A54=2D38h) 37A4:17AF A3 0AD4 mov word ptr ds:[0AD4h],ax ; (2923:0AD4=8BE0h) 37A4:17B2 9A 32F8:0134 call far ptr sub_174 ; (32F8:0134) 37A4:17B7 9A 3E42:01CC call far ptr sub_355 ; (3E42:01CC)
[불러오기]나[환경설정]은 위의 코드가 사용되지 않고 첫 번째 예제코드가 사용되기 때문에 동일하게 패치하는 과정이 필요하다.
2번 검증 로직을 수정하여 패스워드 검증 화면 자체가 보이지 않도록 크랙할 수 있다. ‘je’ 점프 로직을 ‘jmp’로 수정하여 무조건 점프하도록 ‘je’의 OPCode ‘0x74’를 jmp 명령의 OPCode는 ‘0xeb’로 수정한다.
패치를 위한 코드를 찾기 위하여 파일 에디터에서 ‘3B 06 54 3A 74 18’을 검색하여 해당 부분을 ‘3B 06 54 3A EB 18’로 수정하면 크랙이 완료된다. 동일한 코드가 [시작하기]와 [불러오기], [환경설정]에서 사용되기 때문에 두 군데의 수정이 필요하다. 해당 코드를 수정하더라도 패스워드 검증이 최소 한 번 수행되지 않은 경우에는 검증로직 1번에서 ds:[0AD4h]의 값이 0인 경우 검증로직 3번의 패스워드 검증 루틴으로 점프(jz, 74 09)하기 때문에 수정된 2번의 로직이 실행되지 않는다. 1번의 로직은 에디터에서 수정된 곳의 바로 위에 인접해 있다. 점프를 수행하지 않기 위하여 아무 명령을 수행하지 않는 NOP (0x90)으로 두 바이트를 수정한다.
크랙이 완료된 바이너리를 실행하여 패스워드 검증 부분에서 DOSBox Debugger로 코드를 확인한 그림은 아래와 같다.
Ghidra로 16Bits DOS Game Binary 분석
SOURCER와 DOSBox Debugger가 생각만큼 성능이 뛰어나지 않아 보인다. 1990년대가 아닌 2020년대에는 성능이 훨씬 좋은 IDA Pro가 있기 때문에 크랙이 매우 쉽게 느껴진다. IDA Pro가 상용이라 부담스럽다면 아직은 부족해보이지만 Ghidra가 대안이 될 수 있어보인다.
위의 패스워드 검증 로직을 Ghidra에서 확인한 결과는 아래와 같다.
1e81:14b0 3b 06 54 3a CMP AX,word ptr [DAT_37a4_3a54] = FFFFh 1e81:14b4 74 18 JZ LAB_1e81_14ce LAB_1e81_14b6 XREF[1]: 1e81:14ab(j) 1e81:14b6 9a 21 04 CALLF FUN_2dee_0421 undefined FUN_2dee_0421() ee 2d 1e81:14bb 3d 01 00 CMP AX,0x1 1e81:14be 74 0e JZ LAB_1e81_14ce 1e81:14c0 1e PUSH DS 1e81:14c1 68 9c 0d PUSH 0xd9c 1e81:14c4 6a 08 PUSH 0x8 1e81:14c6 9a 02 00 CALLF FUN_22a0_0002 undefined FUN_22a0_0002(undefine a0 22 1e81:14cb 83 c4 06 ADD SP,0x6 LAB_1e81_14ce XREF[2]: 1e81:14b4(j), 1e81:14be(j) 1e81:14ce a1 54 3a MOV AX,[DAT_37a4_3a54] = FFFFh 1e81:14d1 a3 d4 0a MOV [DAT_37a4_0ad4],AX 1e81:14d4 e8 c5 eb CALL FUN_1e81_009c undefined FUN_1e81_009c() 1e81:14d7 b8 13 00 MOV AX,0x13 1e81:14da cd 10 INT 0x10
Ghidra 또한 ‘password error.’ 문자열의 레퍼런스가 자동으로 찾아지지는 않는다. 그러나 SOURCER에서 디스어셈블되지 않은 구문이 정상적으로 해석되고 있으며 부족하지만 C언어로 디컴파일된 결과를 함께 확인할 수 있다.
기타
특이하게도 게임에 사용된 배경화면의 이미지(Padna 로고, 오프닝, 메뉴 화면)가 PCX 파일 포맷으로 그대로 드러나 있다. 해당 파일의 검증 절차가 없기 때문에 과거에 이미지 파일을 편집하여 게임을 구동해보았던 기억이 난다. (이미지 파일을 당시 어떤 프로그램으로 편집했었는지는 기억이 가물하다.)
정품 구매한 게임이지만 게임 자체의 재미는 별로였던걸로 기억한다.
마치며
가급적 자세히 써보고자 하였지만 아주 간단한 샘플임에도 생각 보다 글의 분량이 많아지다보니 많은 부분을 생략하였다. 이런거 까지 쓸 필요가 있을까라는 내용도 있어 글의 방향을 잡기가 어려운 점이 있다. 가급적 재미겸 기록의 보존 목적으로 글을 이어가려고 하는데 다음번에는 조금 더 직관적으로 단순하게 써볼까 한다.