개요
시작은 분명 이용자가 거의 없는 게임의 데이터파일을 분석하는 일이었습니다.
어쩌다보니 얻게 된 파일 언패커와, 그 파일이 널리 보급되면서 생긴 핵들.
해당 문제를 수정하기 위해 대규모 패치가 이루어진 역사.
사실 언패커는 컴파일된 프로그램이었고, 어떤 식으로 동작하는진 관심 없었습니다.
꽃이 지고 나서야 봄인 걸 알듯
패치되어 사용 못 하게 돼서야 파일 구조를 뜯어보기 시작했습니다.
데이터 파일
게임은 대체 어떤 식으로 데이터를 로딩할까요?
배경음악, 효과음, 목소리 음성파일만 해도 벅찰텐데
이미지 및 모델링 파일도 로딩해야 하죠.
또한 RPG의 경우, 장비마다 모델링 파일을 로딩해야 합니다.
그럼 모든 데이터 파일을 메모리에 로딩할까요?
위는 유명한 배틀그라운드 게임의 최소 사양입니다.
저장공간이 약 33GB이며, 실행파일 및 동적 링크 파일을 제외하더라도
대부분 게임 데이터파일임에 틀림 없습니다.
그럼 30GB나 되는 용량을 메모리에다가 넣을 순 없죠.
때문에 필요할 때마다 사용자 컴퓨터에서 읽어와 메모리에 넣는 것입니다.
서버에서 받아오면 되지 않을까?
물론, 안 될 일은 없지만 그렇다고 되는 것도 힘듭니다.
어차피 실시간으로 변경될 것도 아닌 파일을, 서버에서 계속 다운로드 받는다면
서버도 힘들고, 네트워크가 느린 컴퓨터는 게임이용에 불편할테니까요.
패키징
그중에서도 제가 하던 게임은 ZIP 파일 형식을 그대로 사용하고 있었습니다.
원래는 더 복잡하게 되어있었지만, 패치되고 나서는 암호화만 걸린 압축파일이더군요.
일단 아무것도 모르고 메모장으로 열어봤습니다.
왼쪽이 패치 전 데이터파일, 오른쪽이 패치된 후 데이터 파일입니다.
한 번 언패킹된 데이터가 있다보니, 오른쪽에 있는 파일들 내용을 알아보는데 어렵진 않았습니다.
아. 이건 ZIP 파일이랑 다를게 없구나.
어찌되었든 저런 결론에 도달한 것이 2.5년 전입니다.
그 옛날 걸 이제와서, 하지만
2.5년 전에는 저걸 바이너리 에디터로 열 생각조차 안 해봤습니다.
그냥 무작정 메모장으로 열고 뚫어지게 쳐다보는 게 다였죠.
지금은 적어도 그때보단 더 나은 개발자가 된 것 같아서 다시 분석했고
언패킹에 성공했습니다.
이제 프로그램으로 만드려는 찰나, C#의 WinForm보단 일렉트론이 더 편해진 저에게
압축파일을 관리하는 모듈이 필요했습니다.
하지만 제가 원하는 정도로 자유성을 보장하는 모듈을 찾지 못 했습니다.
- 파일별로 비밀번호를 다르게 설정 가능해야 함.
- 항목마다 Buffer로 추출 및 수정 가능해야 함.
위 두 개를 지원하는 모듈이 없었습니다.
그래서 예전에 C#때 사용한 ZipArchive 모듈이 괜찮길래 비슷한 느낌으로 만들어봤습니다.
ZIP 파일 구조
ZIP 파일을 분석하고 보니, 왜 데이터 저장 형식이 이럴까 생각이 들었습니다.
꼼꼼히 찾아보면 이유가 있을 것 같지만 영어를 읽을 자신이 없어 핵심 내용만 읽었습니다.
간단하게 설명하면, ZIP 파일은 먼저 파일 데이터 정보들이 나열되어 있고
파일 데이터 정보를 가진 정보들이 나열되어 있고
마지막에 ZIP 파일 전체에 대한 설명이 있습니다.
Zip File Signature |
---|
Local File Header ( 파일 정보 ) |
파일 데이터 |
데이터 설명 ( Optional ) |
Local File Header ( 파일 정보 ) |
파일 데이터 |
데이터 설명 ( Optional ) |
… |
Central Directory ( 파일 정보의 정보 ) |
Central Directory ( 파일 정보의 정보 ) |
… |
End Of Central Directory ( Zip 파일 정보 ) |
위와 같은 구조로 이루어져있기 때문에 ZIP 파일을 읽을 땐 가장 마지막부터 읽습니다.
제 글에서도 제가 만든 모듈이 동작하는 순서대로 설명하겠습니다.
만약! 분할 압축에 관련된 정보를 얻고 싶으신 거라면 해당 글에선 분할 압축에 대해 설명하지 않으니 다른 글을 찾아보시는 걸 추천드립니다.
End Of Central Directory
End Of Central 필드에서 가장 중요한 것은
Central Directory의 시작위치 데이터가 존재한다는 것입니다.
아래는 데이터 구조입니다
Offset | Bytes | 설명 |
---|---|---|
0 | 4 | End of central directory 시그니처 정보 (0x06054b50) |
4 | 2 | (분할압축시) 현재 파일이 몇 번째 파일인지 |
6 | 2 | (분할압축시) Central Directory가 시작되는 disk 번호 |
8 | 2 | 현재 파일의 총 Central Directory 개수 |
10 | 2 | 총 Central Directory 개수 (분할압축시 다른 파일 포함) |
12 | 4 | Central Directory 들의 총 크기 |
16 | 4 | 첫 번째 Central Directory 시작되는 위치 |
20 | 2 | 현재 ZIP 파일의 Comment 길이 (n) |
22 | n | Comment |
저는 정말 기본적인, 제가 필요한 만큼만 구현했기 때문에
분할 압축에 관해선 공부하지 않았습니다.
End of central directory를 읽었다면, Offset 16의 데이터인
첫 번째 Central Directory 가 시작되는 위치부터
Offset 8의 데이터인 현재 파일의 총 Central Directory 개수만큼 읽으면 되겠네요.
Central Directory
Central Directory 는 Local File Header 보다 간략하면서,
Local File Header의 위치 정보를 가지고 있는 구조체입니다.
아래는 데이터 구조입니다.
Offset | Bytes | 설명 |
---|---|---|
0 | 4 | Central directory 시그니처 정보 (0x02014b50) |
4 | 2 | 압축시 사용된 ZIP 규격 버전 |
6 | 2 | 압축 해제시 필요한 최소 ZIP 규격 버전 |
8 | 2 | 해당 파일에 대한 옵션 플래그 값 |
10 | 2 | 압축에 사용된 알고리즘 |
12 | 2 | 해당 파일 마지막 수정 시간 |
14 | 2 | 해당 파일 마지막 수정 날짜 |
16 | 2 | 압축 해제된 파일의 CRC-32 값 |
20 | 4 | 압축 된 상태의 파일 크기 |
24 | 4 | 압축 해제된 상태의 파일 크기 |
28 | 2 | 파일 이름 길이 (n) |
30 | 2 | 특수 필드의 길이 (m) |
32 | 2 | 파일 코멘트 길이 (k) |
34 | 2 | Disk number where file start (분할압축시 해당 파일이 몇 번째 파일에 존재하는지 말하는듯) |
36 | 2 | 파일의 내부 속성 |
38 | 4 | 파일의 외부 속성 |
42 | 4 | 해당 파일의 Local File Header 가 존재하는 위치. |
46 | n | 파일 이름 |
46+n | m | 특수 필드 |
46+n+m | k | 파일 코멘트 |
Offset 8 에 해당하는 옵션 플래그값은, 다음 표를 참고하시면 됩니다.
BIT | 옵션 |
---|---|
00 | 파일의 암호화 사용 여부 |
01 | 압축 옵션 |
02 | 압축 옵션 |
03 | Data Descriptor 존재 여부 |
04 | Enhanced Deflation |
05 | Compressed patched data |
06 | 더 강한 암호화 |
07 |
사용되지 않음 |
08 |
사용되지 않음 |
09 |
사용되지 않음 |
10 |
사용되지 않음 |
11 | 언어 인코딩 |
12 | 예약 필드 |
13 | Mask header values |
14 | 예약 필드 |
15 | 예약 필드 |
06 번 필드 더 강한 암호화는 암호 알고리즘을 어떤 걸 사용할지에 대한 설명입니다.
기본적으로 ZIP 파일 암호화는 Standard ZIP 2.0 Encryption 알고리즘을 사용합니다.
해당 옵션을 사용하면, 암호화 알고리즘을 AES암호화로 사용하게 되며 관련된
Strong Encryption Header 필드가 추가됩니다.
참고로 제가 만든 모듈에선 00번 옵션만 판단합니다.
다시 Central Directory 의 파일 마지막 수정 시간 계산법은 다음과 같습니다.
앞에서 5비트 (시) 중간 6비트 (분) 마지막 6비트 * 2 (초)
앞에서 6비트 + 1980 (연) 중간 4비트 (월) 마지막 5비트 (일)
예시로 현재 시간인 2021년 01월 18일 13시 08분 28초를 예시로 들어보겠습니다.
2021 - 1980 = 0b101001(41)
01 = 0b0001
18 = 0b10010
그래서 0b101001000110010 인 21042의 값이 됩니다.
13 = 0b01101
08 = 0b001000
28 / 2 = 0b01110 (14)
그래서 0b0110100100001110 인 26894의 값이 됩니다.
그 이외엔 반대로 계산하면 됩니다.
실제 계산에 쓰인 함수는 이곳을 참고하세요.
Local File Header
Local File Header는 실제 데이터의 정보가 들어있습니다.
헤더를 읽고 나면 바로 뒤에 압축 데이터 파일이 있습니다.
아래는 데이터 구조입니다.
Offset | Bytes | 설명 |
---|---|---|
0 | 4 | Local File Header 시그니처 정보 (0x04034b50) |
4 | 2 | 압축 해제시 필요한 최소 ZIP 규격 버전 |
6 | 2 | 해당 파일에 대한 옵션 플래그 값 |
8 | 2 | 압축에 사용된 알고리즘 |
10 | 2 | 해당 파일 마지막 수정 시간 |
12 | 2 | 해당 파일 마지막 수정 날짜 |
14 | 4 | 압축 해제된 파일의 CRC-32 값 |
18 | 4 | 압축 된 상태의 파일 크기 |
22 | 4 | 압축 해제된 상태의 파일 크기 |
26 | 2 | 파일 이름 길이 (n) |
28 | 2 | 특수 필드의 길이 (m) |
30 | n | 파일 이름 |
30+n | m | 특수 필드 |
Offset 8번 압축에 사용된 알고리즘은 다음과 같은 표를 따라 선택합니다.
값 | 옵션 |
---|---|
00 | 압축 안 함 (파일 내용 그대로) |
01 | Shrunk |
02 | Reduced with compression factor 1 |
03 | Reduced with compression factor 2 |
04 | Reduced with compression factor 3 |
05 | Reduced with compression factor 4 |
06 | Imploded |
08 | Deflated |
09 | Enhanced Deflated |
10 | PKWare DCL Imploded |
12 | Compressed using BZIP2 |
14 | LZMA |
18 | Compressed using IBM TERSE |
19 | IBM LZ77 z |
98 | PPMd version I, Rev 1 |
위 표에서, 가장 많이 사용되는 것은 0 번과 8번이기 때문에
제 모듈에서도 두 가지에 대해서만 처리를 해놨습니다.
마무리
어딘가 허전하게 끝나버렸지만 이렇게 읽는다면 벌써 ZIP 파일을 다룰 수 있게 되었습니다.
Deflate 알고리즘이 LZ77과 허프만 알고리즘을 사용하는데,
허프만 알고리즘만 배웠는데도 너무 어려워서 고민하던 찰나
노드에는 기본적으로 zlib 모듈을 지원한다는 것입니다.
함수 하나만 사용하니 압축 및 압축 해제가 바로 가능했었습니다.
오늘도 긴 글 읽어주셔서 감사합니다.
아래는 소스 깃헙이며, 나름 열심히 Wiki도 열심히 짰습니다.
글이 마음에 드셨다면 스타 하나만 부탁드립니다.