정수를 저장했는데 float 로 돌아왔다, 세이브 서명이 깨진 이유
Godot 에서 세이브에 HMAC 서명을 걸었더니 아무도 손대지 않은 파일이 위조로 떨어졌다. JSON.parse_string 이 정수를 전부 float 로 돌려준 탓이었다.
「브루니 키우기: 바랜 섬」은 Godot 4.7.2 로 만드는 방치형 게임이다. 계정 상태는 user://save.json 한 장에 들어가고, 웹에서는 그 자리가 IndexedDB 가 된다. 골드·보석·동료·진행 단계가 전부 여기 있다. 그러니 누가 파일을 열어 골드 숫자만 고쳐 넣는 일은 막고 싶었다. 서명을 붙이기로 했다.
처음 떠오르는 방법은 단순하다. 저장할 때 상태를 JSON 으로 만들고 HMAC 을 건다. 읽을 때 JSON 을 파싱하고, 그 값을 다시 JSON 으로 만들어 HMAC 을 계산한 뒤 저장된 서명과 비교한다. 같으면 통과, 다르면 위조다.
이 방법은 Godot 에서 정상 세이브까지 위조로 판정한다. 범인은 숫자였다.
정수를 넣었는데 float 가 나온다
Godot 의 JSON.parse_string 은 숫자를 전부 float 로 준다. 정수 gold 를 저장했어도 읽어 오면 float 다. 저장 상태에는 정수가 가득하다. 레벨, 경험치, 재화, 타임스탬프(밀리초), 시퀀스 번호까지 모두 정수다.
서명 검증이 「읽은 값을 다시 직렬화해서 비교」라면 문제가 바로 보인다. 처음 서명을 건 문자열은 정수로 쓴 JSON 이었다. 검증할 때 만든 문자열은 float 로 바뀐 값을 직렬화한 JSON 이다. 같은 데이터인데 문자열이 달라진다. HMAC 은 문자열 한 글자만 달라도 완전히 다른 값을 낸다. 결과적으로 아무도 손대지 않은 세이브가 서명 불일치로 떨어진다.
이 저장소 개발 문서의 「밟은 함정」 표 맨 윗줄에도 이 문제가 올라 있다.
| 함정 | 증상 | 대응 |
|---|---|---|
| JSON 숫자 | JSON.parse_string 이 정수도 float 로 준다 → 저장 서명이 다시 직렬화하면 달라진다 | 저장은 「서명 줄 + 본문 줄」로 쓰고 본문 문자열 그대로 검증. 읽은 값은 Jsonx.normalize |
더 무서운 건 실패가 조용하다는 점이다. 서명이 틀리면 저장 시스템은 그 파일을 버리고 백업으로 넘어간다. 백업도 같은 이유로 틀리면 새 계정으로 시작한다. 플레이어 눈에는 어제 키운 게 그냥 사라졌다.
서명은 「다시 만든 문자열」이 아니라 「읽은 문자열」에 건다
그럼 무엇과 비교해야 할까? 비교 대상을 바꾸면 된다. 값을 다시 직렬화하지 않는다. 디스크에서 읽은 본문 문자열을 바이트 그대로 HMAC 에 넣는다. 그러려면 서명과 본문을 파일 안에서 분리해 둬야 한다. 그래서 파일 형식을 두 줄로 정했다.
- 1줄: HMAC-SHA256 hex
- 2줄: 키를 정렬한 JSON 본문
save.gd 머리 주석에도 이유가 적혀 있다.
## 파일 형식: 1줄 = HMAC-SHA256 hex, 2줄 = 키 정렬 JSON 본문. 서명은 2줄 문자열 그대로에 건다
## (JSON 을 다시 읽으면 숫자가 float 가 되어 문자열이 달라지므로 다시 직렬화해 비교하지 않는다).쓰는 쪽은 이렇게 생겼다. 본문을 한 번 만들고, 그 문자열에 서명을 건 뒤 줄바꿈으로 잇는다.
func to_signed_text() -> String:
var body := Jsonx.canonical(data)
return hmac_hex(derive_key(str(data.get("install_id", ""))), body) + "\n" + bodyJsonx.canonical 은 키를 정렬한 한 줄 JSON 을 만든다. 정렬은 Godot 의 JSON.stringify 세 번째 인자로 켠다. 딕셔너리 순서가 바뀌어도 같은 상태면 같은 문자열이 나오게 하려는 장치다.
## 키를 정렬한 한 줄 JSON — 서명 대상 문자열.
static func canonical(v: Variant) -> String:
return JSON.stringify(v, "", true)읽는 쪽은 첫 줄바꿈에서 자른다
읽을 때는 첫 번째 줄바꿈을 찾아 앞을 서명, 뒤를 본문으로 나눈다. 본문을 파싱하는 건 서명을 계산하기 전이다. 이유가 있다. 키를 만들려면 본문 안의 install_id 가 필요하다. 하지만 HMAC 에 들어가는 건 파싱한 값이 아니라 body 문자열 그 자체다.
## "sig\nbody" → 본문 Dictionary. 서명이 틀리거나 형식이 깨지면 {}.
func parse_signed(txt: String) -> Dictionary:
var nl := txt.find("\n")
if nl <= 0:
return {}
var sig := txt.substr(0, nl).strip_edges()
var body := txt.substr(nl + 1)
var raw = JSON.parse_string(body)
if not (raw is Dictionary):
return {}
var key := derive_key(str(raw.get("install_id", "")))
if hmac_hex(key, body) != sig:
return {}
return Jsonx.normalize(raw)줄바꿈이 없거나 맨 앞에 있으면 빈 딕셔너리, 본문이 딕셔너리가 아니어도 빈 딕셔너리, 서명이 틀려도 빈 딕셔너리다. 실패를 한 가지 모양으로 돌려주니 호출하는 쪽은 is_empty() 하나만 보면 된다. 읽기 순서는 save.json → save.bak → 새 계정이다. 백업으로 넘어가면 anti.tamper 를 하나 올리고, 본 파일이 있었다면 경고도 남긴다.
키는 고정 문자열(PEPPER)에 install_id 를 붙여 SHA-256 으로 만든다. 코드 주석이 솔직하게 적어 둔 대로, 이 PEPPER 는 비밀이 아니다. 클라이언트에 들어 있으니 마음먹으면 꺼낼 수 있다. 이 서명은 변조를 막는 자물쇠라기보다 귀찮게 만드는 지연책이다. 기대치를 거기 맞춰 두면 설계가 과해지지 않는다.
읽은 값은 normalize 로 정수로 되돌린다
서명 문제는 풀렸다. 그런데 파싱한 값은 여전히 float 투성이다. 이대로 게임에 넘기면 다른 데서 탈이 난다. 정수끼리 비교하던 코드, 배열 인덱스, 딕셔너리 키로 쓰는 값이 전부 float 가 된다. 그래서 parse_signed 의 마지막 줄이 Jsonx.normalize(raw) 다.
const _INT_LIMIT := 9007199254740992.0 # 2^53 — 이보다 크면 float 가 정수를 정확히 못 담는다
static func normalize(v: Variant) -> Variant:
match typeof(v):
TYPE_FLOAT:
var f: float = v
if is_finite(f) and absf(f) < _INT_LIMIT and f == floorf(f):
return int(f)
return f
TYPE_DICTIONARY:
var out := {}
for k in v:
out[k] = normalize(v[k])
return out
TYPE_ARRAY:
var arr: Array = []
for x in v:
arr.append(normalize(x))
return arr
return v규칙은 하나다. 유한하고, 절댓값이 2^53 보다 작고, 소수부가 없는 float 는 int 로 바꾼다. 딕셔너리와 배열은 재귀로 내려간다. 2^53 은 float 가 정수를 빈틈없이 담을 수 있는 한계다. 그보다 크면 이미 정밀도가 깎였으니 int 로 바꿔 봐야 의미가 없다.
부작용도 있다. 원래 소수였는데 우연히 정수로 떨어진 값(예를 들어 설정의 bgm 이 1.0 이 된 경우)도 int 가 된다. 그래서 jsonx.gd 는 규칙을 하나 더 적어 둔다. 소수가 필요한 계산은 float() 로 감싸 쓴다. 값의 타입을 믿지 말고 쓰는 자리에서 정하라는 얘기다. 이 함수는 저장만이 아니라 게임 데이터(DB)를 읽을 때도 같이 쓴다.
저장 파일을 지키는 나머지 장치들
서명만으로는 세이브가 안전하지 않다. 쓰는 도중에 앱이 꺼지면 반쯤 쓴 파일이 남는다. 그래서 쓰기는 세 걸음이다. save.tmp 에 먼저 다 쓴다. 기존 save.json 을 save.bak 으로 옮긴다. 그다음 tmp 를 save.json 으로 이름 바꾼다. 어느 단계에서 끊겨도 서명이 맞는 파일 하나는 남는다.
쓰는 횟수도 줄였다. 상태가 바뀌면 바로 쓰지 않고 표시만 해 둔다. 2초(DEBOUNCE_S)가 지나면 그때 쓴다. 창을 닫거나 앱이 백그라운드로 가거나 포커스를 잃으면, 남은 변경이 있을 때 바로 쓴다.
옛 저장과의 호환은 _fill_defaults 가 맡는다. 새 필드가 생기면 기본 상태에서 빠진 키만 채우고, 값이 있는 키는 건드리지 않는다. 동료·인벤토리처럼 키가 동적인 칸은 안쪽까지 내려가 채우지 않도록 따로 뺐다.
시험에서는 디스크를 끈다. install_id 도 고정값으로 박는다. 이 값이 키뿐 아니라 항아리·상자·모집 난수의 씨앗으로도 쓰이기 때문이다. 무작위로 두었을 때 통합 시험이 4% 확률로 흔들렸다.
같은 함정을 다른 데서 만나지 않으려면
이 문제는 Godot 에만 있는 게 아니다. 「파싱한 뒤 다시 직렬화한 문자열」은 원래 문자열과 같다는 보장이 없다. 숫자 타입, 키 순서, 공백, 이스케이프 방식 중 하나만 달라도 서명이 깨진다. 서명이든 해시든, 검증은 디스크에 있던 바이트 그대로에 건다. 값은 그다음에 꺼내 쓴다.
순서만 기억하면 된다. 문자열을 검증하고, 그다음에 파싱한 값을 정리한다.
핵심 한 줄서명은 다시 만든 문자열이 아니라 읽은 문자열 그대로에 걸어야 한다.
자주 묻는 것
Godot JSON.parse_string 정수가 float 로 나오는 이유
Godot 의 JSON.parse_string 은 숫자를 전부 float 로 준다. 정수가 필요하면 읽은 뒤 정리해야 한다. 이 프로젝트는 유한하고 절댓값이 2^53 보다 작으며 소수부가 없는 float 를 int 로 되돌리는 Jsonx.normalize 를 쓴다.