리버싱 / 2026.01
지뢰찾기 리버싱
Windows XP 지뢰찾기를 OllyDbg로 분석해 지뢰 판정 코드와 칸 주소 계산식을 찾고, 빈 코드 영역에 패치를 넣어 지뢰 위치를 기록·표시했습니다. 세 번 고친 과정을 명지대 합동 세미나에서 발표했습니다.
ANALYSIS
왜 했나
고등학생 때 Python과 C로 프로그래밍을 하면서, 내가 짠 코드가 실제로 어떻게 실행되는지 궁금했습니다.
대학에 와서 리버싱을 시작했고, 직접 결과를 눈으로 확인할 수 있는 대상으로 지뢰찾기를 골랐습니다.
지뢰찾기 메모리에서 지뢰 칸을 찾아 화면에 그려 주는 패치를 OllyDbg로 직접 짜 넣었습니다. 시나리오를 세 번 고쳤는데, 앞의 두 번은 원래 함수가 쓰던 레지스터와 메모리 값을 그대로 따르지 않아서 실패했습니다. 같은 실습을 하다 막힌 사람이 어디서 틀리기 쉬운지 보이도록, 실패한 시나리오를 지우지 않고 순서대로 남깁니다.
- 결과: 세 번째 시나리오에서 지뢰 칸을 찾아 그리기 함수를 부르는 패치 바이트까지 작성했습니다.
- 대상: Windows XP 지뢰찾기(
winmine). 처음엔 Windows 7(64비트) 지뢰찾기로 시작했다가 XP로 바꿨습니다. - 도구: OllyDbg
1. 왜 XP로 바꿨나: Windows 7에서 막힌 것
처음엔 Windows 7 지뢰찾기로 했습니다. 이 버전에는 ASLR이 적용돼 있습니다.
ASLR: 실행 파일을 메모리에 올릴 때 시작 주소(Image Base)를 매번 무작위로 바꾸는 기술. 시작 주소에 상대 주소(RVA)를 더해 실제 주소가 정해지므로, 주소의 앞 4자리는 실행할 때마다 바뀌고 뒤 4자리는 그대로입니다.
여기서 틀렸던 가정이 하나 있었습니다.
- 틀린 가정: 클릭한 칸의 좌표가
R8D(Y)와EDX(X)에 있다. - 실제: 처음엔 거기에 들어가지만, 조금 뒤
RSI(Y)와RBP(X)로 옮겨집니다.
R8D/EDX 기준으로 패치를 짜니 저장이 제대로 되지 않았습니다. 좌표를 처음 본 위치만 믿지 말고, 패치를 넣을 지점에서 그 값이 어느 레지스터에 있는지 다시 확인해야 했습니다.
Windows 7에서도 지뢰 위치를 메모리에 기록하는 데까지는 갔습니다. 하지만 세미나용 패치를 새로 만들 때는 Windows 7 버전이 너무 힘들어서 XP 버전으로 옮겼습니다.
2. XP에서 지뢰를 판정하는 코드 찾기
시작점: SetTimer
Windows 7 지뢰찾기는 타이머에 SetTimer()를 썼습니다. XP도 같을 수 있다고 보고 거기서부터 따라갔습니다.
- 코드 영역에서 우클릭 → [Select module] → [WINMINE]
- 우클릭 → [Search for] → [All intermodular calls]에서
SetTimer를 찾아 브레이크포인트 설정 - 타일을 누르고 F8(스텝 오버)로 진행
진행하다 보면 EAX에 X좌표, ECX에 Y좌표가 들어가는 지점이 나옵니다. Windows 7과 달리 왼쪽 위 첫 칸이 (0, 0)이 아니라 (1, 1)부터 시작하는 것으로 보였습니다. 이건 뒤에서 반복문 범위를 정할 때 중요해집니다.
비교문의 역할을 제로 플래그로 확인하기
비교 명령(CMP, TEST) 뒤에는 보통 조건 점프(JE, JNZ 등)가 옵니다. 조건 점프는 **제로 플래그(ZF)**를 보고 갈지 말지 정합니다. 그래서 비교문을 만날 때마다 ZF를 강제로 뒤집어 실행해 보고, 화면이 어떻게 달라지는지로 그 비교문의 역할을 추측했습니다.
| 비교문 | ZF 조작 | 결과 | 추측 |
|---|---|---|---|
| 첫 번째 | 1 → 0 | 타일이 안 뒤집히고 타이머만 흐름 | 정확한 역할은 아직 모름 |
| 두 번째 | 1 → 0 | 빈 타일 하나만 보임 | 눌렀을 때의 모션으로 추측 (여러 번 해도 같았지만 확실하진 않음) |
| 이어서 F7로 들어간 함수 안의 비교문 | 0 → 1 | 지뢰를 밟아 게임 오버 | 이 위가 지뢰 판정 연산 |
칸 주소 계산식
지뢰 판정 직전의 코드를 보면 Y는 EAX에서 ECX로 옮겨 시프트하고, X는 ESI에 있었습니다. 칸 하나의 주소를 만드는 건 이 두 줄입니다.
SHL ECX, 5 ; Y * 32
LEA EDX, [ESI+ECX+1005340] ; 0x01005340 + Y*32 + X
즉 칸 데이터는 0x01005340부터 한 줄에 32바이트씩 놓여 있고, 그 칸의 값을 보면 지뢰인지 알 수 있습니다. 세미나용 패치 문서에는 이 영역을 0x01005340~`0x0100548A로, 지뢰 칸의 값을 0x8F`로 정리해 뒀습니다.
3. 1차 맵핵: 지뢰 위치를 메모리에 기록
판정 코드를 직접 덮어쓰기엔 공간이 부족합니다. 그래서 코드 케이브 방식을 썼습니다.
코드 케이브: 실행 파일 안에서 쓰이지 않는 빈 코드 영역. 원래 코드 한 곳을
JMP로 덮어써서 그쪽으로 보내고, 거기서 원하는 코드를 실행한 뒤 원래 자리로 돌아오게 합니다.
0x01003529에 빈 코드 영역으로 가는JMP를 넣음- 쓸 레지스터를 스택에 저장
EDI는 결과를 저장할 위치로 쓰려고 0으로 초기화ECX(Y)와ESI(X)를 1로 시작. 칸이 (1, 1)부터 (9, 9)까지라서- 위의 계산식으로 칸 주소를 만들고 값을 검사해서, 결과에 따라 메모리에 0 또는 1을 기록
- 모든 칸을 돌도록 이중 반복
- 레지스터를 복구하고,
JMP로 덮어쓴 원래 명령어를 실행한 뒤 원래 흐름으로 복귀
마지막 단계를 빼먹으면 안 됩니다. JMP를 넣느라 지운 원래 명령어(0x01004A9D부터)를 케이브 끝에서 대신 실행해 줘야 프로그램이 정상으로 돌아갑니다.
노션에 남긴 1차 패치 바이트입니다.
57 56 52 51 BF 00 00 00 00 B9 01 00 00 00 51 C1
E1 05 BE 01 00 00 00 8D 94 31 40 53 00 01 F6 02
80 74 09 C6 87 00 5C 00 01 01 EB 07 C6 87 00 5C
00 01 00 47 46 83 FE 0A 7C DD 59 41 51 83 F9 0A
7C CD 59 59 5A 5E 5F F6 02 80 57 0F 84 EC EA FF
FF E9 81 EA FF FF
이 단계의 결과는 지뢰 위치가 메모리에 0과 1로 기록되는 것입니다.
4. 세미나용 패치: 지뢰를 화면에 그리기
명지대 합동 세미나에서 새로 보여줄 것을 만들기로 했습니다. 목표는 타일을 누르자마자 모든 지뢰 위치를 표시하고, 나머지 타일이 클릭되게 해서 게임을 끝내는 패치였습니다.
먼저 필요한 건 두 가지였습니다.
- 지뢰 정보가 있는 메모리:
0x01005340~0x0100548A - 타일을 그리는 코드:
BitBlt호출. 바로 위에 인자를 넣는 코드가 있고, 그 직전 레지스터를 보면ECX에 Y,ESI에 X가 들어 있으며 X는 곧EAX로 옮겨집니다.
BitBlt: 이미지를 화면(디바이스 컨텍스트)에 복사해 그리는 Windows API. 지뢰찾기는 이걸로 타일 그림을 그립니다.
시나리오 1: BitBlt를 직접 호출 → 실패
EAX,ECX,EBX저장EAX=1,ECX=1,EBX=0x01005340- 칸 값이
0x8F이면BitBlt호출 - 좌표를 바꿔 가며 반복, 끝나면 레지스터 복구
실패한 이유
BitBlt는 그릴 대상의 핸들이 필요합니다. 핸들을 얻으려면 그리기 함수의 앞부분을 전부 가져와야 했습니다.- 지뢰 그림이 제대로 나오려면 그 칸의 메모리 값이
0xCC여야 했는데,0x8F그대로 넘겼습니다.
BitBlt만 부르면 될 거라고 생각한 게 틀린 가정이었습니다. API 하나가 아니라 그 API를 감싸고 준비하는 함수 단위로 호출해야 했습니다.
시나리오 2: 그리기 함수를 호출 → 실패
- 레지스터 저장,
EAX=1,ECX=1,EBX=0x01005360 - 칸 값이
0x8F이면 그리기 함수 호출 (Y, X 순서로 인자 push) - X, Y를 늘리고
EBX를0x20씩 더하며 다음 칸 비교 - 레지스터 복구
실패한 이유
- 그 함수 안에서는 X를
ESI, Y를EDX에 두고 계산합니다. 제 코드는EAX/ECX를 쓰고 있어서 맞지 않았습니다. - 칸 위치도 함수가 쓰는 방식 그대로
0x01005340을 기준으로Y×2⁵ + X로 계산해야 했습니다. 시작 주소를 바꾸고0x20씩 더하는 제 방식과 달랐습니다.
1번과 같은 실수를 다른 모양으로 반복했습니다. Windows 7에서 R8D/RSI를 헷갈렸던 것과 같은 종류의 실수입니다.
시나리오 3: 원래 함수의 규칙을 그대로 따름
EBX,EDX,ESI저장EDX=Y=1,ESI=X=1,EBX=0x01005340EDX를 스택에 저장해 두고,EDX를 5만큼 왼쪽 시프트한 뒤ESI를 더함 → 칸 오프셋- 칸 값이
0x8F인가?- 예: 그 칸을
0xCC로 바꾸고, 레지스터와 플래그를 통째로 저장(PUSHAD,PUSHFD)한 뒤 인자를 넣고 그리기 함수 호출. 끝나면 복구하고 칸 값을 다시0x8F로 되돌림 - 아니오:
ESI또는EDX증가
- 예: 그 칸을
- 모든 칸을 검사했나?
- 예: 저장한 레지스터를 꺼내고 원래 명령어로 점프
- 아니오: 반복
시나리오 1·2와 달라진 점은 세 가지입니다.
- 좌표 레지스터를 함수가 쓰는 대로
EDX(Y),ESI(X)로 맞췄습니다. - 그리기 전에 칸 값을
0xCC로 바꾸고, 그린 뒤0x8F로 되돌렸습니다. 되돌리는 건 게임이 가진 원래 값을 건드리지 않으려는 제 판단입니다. - 함수 호출 전후로 레지스터와 플래그를 전부 저장·복구했습니다. 호출된 함수가 레지스터를 바꿔도 반복문이 깨지지 않게 하려는 것입니다(이것도 제 판단).
노션에 남긴 세 번째 시나리오의 패치 바이트입니다.
52 53 56 BE 01 00 00 00 BA 01 00 00 00 BB 40 53
00 01 52 C1 E2 05 01 F2 80 3C 1A 8F 75 1A C6 04
1A CC 5A 60 9C 52 56 E8 C4 DB FF FF 9D 61 52 C1
E2 05 01 F2 C6 04 1A 8F 5A 83 FE 09 75 0A BE 01
00 00 00 83 C2 01 EB 03 83 C6 01 83 FA 0A 75 C2
5E 5B 5A 84 1D 00 50 00 01 E9 BD ED FF FF
처음 목표였던 "게임을 끝내는" 동작(나머지 칸 자동 클릭)은 노션 기록에 없어서, 이 글은 지뢰 칸을 표시하는 데까지만 다룹니다.
FINDINGS
5. 틀렸던 가정 정리
| 가정 | 실제 |
|---|---|
| 처음 본 레지스터에 좌표가 계속 있다 | 함수 안에서 다른 레지스터로 옮겨진다 (Win7: R8D/EDX → RSI/RBP, XP: ESI/EDX) |
그리기 API(BitBlt)만 부르면 된다 |
핸들 등 준비가 필요해서, 그걸 감싼 함수를 불러야 한다 |
지뢰 칸 값(0x8F) 그대로 그리면 된다 |
지뢰 그림을 그리려면 값이 0xCC여야 하고, 그린 뒤 되돌려야 한다 |
| 칸 순회 방식은 내 마음대로 짜도 된다 | 원래 함수의 계산식(0x01005340 + Y×32 + X)을 따라야 맞는 칸이 나온다 |
ANALYSIS
6. 다음에 같은 걸 만나면 어디부터 볼지
- 패치를 넣을 바로 그 지점에서 레지스터 창을 보고, 필요한 값이 어느 레지스터에 있는지 다시 확인합니다. 앞에서 본 위치를 믿지 않습니다.
- 직접 부를 함수는 API가 아니라, 원래 프로그램이 그 API를 부르기 위해 쓰는 함수를 고릅니다.
- 원래 함수가 쓰는 주소 계산식을 그대로 옮겨 씁니다.
- 코드 케이브에서 다른 함수를 부를 땐
PUSHAD/PUSHFD로 전부 감싸고,JMP로 덮어쓴 원래 명령어를 끝에서 꼭 실행합니다. - 비교문의 역할을 모르겠으면 제로 플래그를 뒤집어 실행해 보고, 화면 변화로 추측합니다. 다만 이건 추측이라 여러 번 반복해서 확인합니다.
발표
이 내용을 2026년 1월 12일 명지대학교와의 합동 세미나에서 발표했습니다.
STACK
- OllyDbg
- x86 Assembly