해시한 컬럼은 되돌릴 수 없다 — SHA-256에서 HMAC, 그리고 AES까지

자물쇠와 열쇠가 나란히 놓인 일러스트

ISMS 심사에서 개인정보 컬럼이 걸렸다. salt 없는 SHA-256은 안전한 저장으로 안 쳐준다는 것이었다. HMAC으로 바꾸려고 열어보니 컬럼에는 다이제스트만 있고 원문이 없었다.

암호화를 붙일 때 제일 먼저 정하는 게 알고리즘인데, 정작 문제가 된 건 알고리즘이 아니라 그 결정을 되돌릴 수 있느냐였다. SHA-256으로 저장해둔 컬럼은 검색도 중복 확인도 잘 됐다. 결정론적이라 같은 번호는 항상 같은 다이제스트가 나오고 WHERE phone_hash = ?가 그대로 동작한다. 그래서 그동안 아무 문제 없이 썼는데, 그 컬럼을 다른 방식으로 바꾸라는 요구가 오자 손댈 방법이 없었다.

▍salt 없는 SHA-256이 걸린 이유

심사에서 보는 건 해시를 썼느냐가 아니라 원문으로 되돌아갈 수 있느냐다. salt가 없으면 같은 입력이 항상 같은 다이제스트로 나오니까, 그 필드가 가질 수 있는 값을 전부 계산해서 표로 만들어두면 조회 한 번에 원문이 나온다. 형식이 정해진 필드는 경우의 수가 생각보다 작다.

노트북 코어 하나로 SHA-256을 재보니 초당 223만 건이 나왔다. 이 속도로 필드별 전체 공간을 훑는 데 걸리는 시간을 계산하면 이렇게 된다.

필드경우의 수전수 계산
생년월일 (100년치)3.7 × 10⁴16 ms
한글 이름 3자1.1 × 10⁶0.5 초
전화번호 010-XXXX-XXXX1.0 × 10⁸45 초
이메일, 주소사실상 무한불가

이름은 성 100개에 이름 두 글자를 자주 쓰는 음절 105개씩으로 잡은 값이다. 전화번호가 45초인데, 이건 노트북 코어 하나 기준이라 하한에 가깝다. 실제 공격은 GPU를 쓰고 SHA-256 해시레이트가 초당 수십억 건이라 이 표보다 서너 자릿수 빠르다.

그러니까 salt 없이 해시해서 저장한 건 원문을 감춘 게 아니라 조회 단계를 하나 더한 것에 가까웠다. 심사에서 지적한 게 정확히 이 부분이다.

▍원문이 없다는 문제

HMAC으로 옮기려면 HMAC(key, 원문)을 계산해야 하고, 그러려면 원문이 있어야 한다. 컬럼에는 다이제스트만 있다. SHA256(원문)에서 HMAC(key, 원문)으로 가는 변환은 존재하지 않는다. 기존 다이제스트에 HMAC을 한 번 더 씌우면 값이 나오기는 하지만 그건 원문의 HMAC이 아니라서, 사용자가 다음에 전화번호를 입력해도 그 값과 안 맞는다.

결국 원문을 복구하는 수밖에 없었는데, 여기서 상황이 뒤집힌다. 심사에서 지적받은 그 취약점이 유일한 복구 수단이었다. salt가 없어서 전수 계산으로 표를 만들 수 있었고, 그래서 옮길 수 있었다. salt를 제대로 넣어뒀다면 복구율이 0이고 마이그레이션 자체가 불가능했다.

복구되는 범위는 앞 표 그대로다. 전화번호와 생년월일은 전부 나왔고 자유 입력 필드는 하나도 안 나왔다. 그래서 컬럼 하나가 옮겨진 행과 안 옮겨진 행으로 갈렸다.

▍단방향과 양방향의 진짜 차이

바꿀 수 있는가양방향AES 컬럼원문HMAC · bcrypt · 다른 키복호화재계산단방향SHA-256 컬럼원문되돌아가는 경로가 없다단방향을 고르는 것은 알고리즘 선택이 아니라 원문을 버리는 결정이다

보통 둘을 복원 가능 여부로 나누는데, 운영에서 부딪히는 차이는 그게 아니라 나중에 바꿀 수 있느냐다.

양방향으로 저장해둔 컬럼은 키만 있으면 원문이 나오니까, 나중에 HMAC으로 가든 다른 키로 재암호화하든 배치 한 번이면 끝난다. 단방향으로 저장해둔 컬럼은 원문이 없어서 어디로도 못 간다. 알고리즘을 잘못 골랐다는 걸 나중에 알아도 고칠 방법이 없다.

그래서 단방향을 고르는 건 알고리즘 선택이 아니라 이 값의 원문을 영구히 버린다는 결정이다. 비밀번호는 그래도 되는 필드라서 맞는 선택이고, 전화번호는 그러면 안 되는 필드였다.

▍lazy migration

원문이 들어올 때만 옮긴다로그인, 본인확인, 재입력처럼 원문이 들어오는 요청없으면① WHERE phone_hash = HMAC(key, 입력)찾으면 그대로 쓴다② WHERE phone_hash = SHA256(입력)찾으면 HMAC 값으로 UPDATE원문이 다시 들어오지 않는 행은 끝까지 남는다

복구 못 한 행이 남았으니 배치로 한 번에 못 옮긴다. 그래서 조회 경로를 두 겹으로 만들고, 원문이 들어오는 순간에 갱신하는 방식으로 갔다. 배치 대신 접근할 때 옮긴다고 해서 lazy migration이라고 부른다.

async function findByPhone(input: string) {
  // ① 이미 옮겨진 행
  const row = await db.user.findFirst({
    where: { phone_hash: hmacSha256(indexKey, input) },
  });
  if (row) return row;

  // ② 아직 안 옮겨진 행은 옛 다이제스트로 한 번 더 찾는다
  const legacy = await db.user.findFirst({
    where: { phone_hash: sha256(input) },
  });
  if (!legacy) return null;

  // 원문이 들어온 김에 갱신한다
  await db.user.update({
    where: { id: legacy.id },
    data: { phone_hash: hmacSha256(indexKey, input) },
  });
  return legacy;
}

조회가 두 번 나가는 건 아직 안 옮겨진 행에서만이라 부담이 크지 않다. 문제는 다른 데 있다. 끝나는 시점이 없다. 원문이 다시 들어오지 않는 행은 그대로 남는다. 탈퇴하지 않은 채 몇 년째 로그인하지 않는 계정이 그렇다.

그래서 이 방식에는 마감이 같이 붙어야 한다. 남은 행 수를 지표로 뽑아두고 옛 경로를 언제 닫을지 정해두지 않으면, 조회 코드에 분기가 영구히 남는다.

비밀번호 해시를 올릴 때 쓰는 silent rehash가 기전이 똑같다. 로그인에 성공하면 그 김에 새 알고리즘으로 다시 해시해서 덮어쓰는 방식인데, 원문이 들어오는 순간에만 옮길 수 있다는 제약까지 같다. Symfony의 password migration이나 PHP의 password_needs_rehash()가 이걸 프레임워크 차원에서 지원한다.

▍세 방식의 공존

나중에 이 필드는 암호화를 안 해도 될 수 있다는 얘기가 나왔다. 그러면 양방향으로 바꾸게 되는데, 지금 옮기는 중인 컬럼에 세 세대가 겹친다.

세대상태
1세대SHA256(원문)아직 안 옮겨진 행
2세대HMAC(key, 원문)지금 옮기는 중
3세대AES(dek, 원문)갈 수도 있는 방향

컬럼 값만 봐서는 어느 세대인지 알 수 없다. 그래서 값에 세대 표시를 붙여둔다.

v1:9f86d081884c7d65...
v2:c1e3672da5da19b9...
v3:073036ee2e1f28cc...

접두어 세 글자면 조회 코드가 분기를 정확히 태울 수 있고, 1세대를 걷어낼 때 남은 행을 세는 것도 WHERE phone_hash LIKE 'v1:%' 하나로 끝난다. 처음부터 붙여뒀으면 이 절이 필요 없었다.

여기서 중요한 건 v2가 헛수고가 아니라는 점이다. 양방향으로 가더라도 HMAC 컬럼은 버려지지 않는다. 아래 나오는 이유로, 양방향으로 저장하면서 검색까지 하려면 HMAC 컬럼이 반드시 다시 필요해진다.

▍양방향으로 바꿀 때의 검색 문제

양방향으로 옮기면 마이그레이션 문제는 영구히 사라진다. 대신 지금 잘 되던 게 하나 안 되기 시작한다.

AES-GCM은 암호화할 때마다 새 IV를 만들어 쓴다. 같은 평문을 세 번 넣어도 결과가 세 번 다 다르다.

AES-256-GCM (랜덤 IV) 3회
  073036ee2e1f28ccacfa9571c016fd6c88f0b9b56bacf779...
  b8c187d321d4468c605782066d8504f925d27cb04149740c...
  72d03c86a044276a50ecf9064a7f8a727d5567cfd3d656a2...

HMAC-SHA256 3회
  c1e3672da5da19b98e4b94364b07eb2804e77d8f660db573...
  c1e3672da5da19b98e4b94364b07eb2804e77d8f660db573...
  c1e3672da5da19b98e4b94364b07eb2804e77d8f660db573...

이건 결함이 아니라 AES-GCM이 보장하려는 성질이다. 암호문이 매번 달라야 컬럼을 통째로 떠가도 어느 행과 어느 행이 같은 값인지 알 수 없다. 그리고 정확히 이 성질 때문에 WHERE phone = ?가 성립하지 않는다. SHA-256 컬럼에서 아무 문제 없이 되던 조회가 안 되는 것이다.

그러면 IV를 고정하면 되지 않을까 싶은데, 검색은 실제로 된다. 대신 평문도 같이 풀린다.

왜 그렇게 되는지는 AES가 실제로 하는 일을 보면 알 수 있다. AES-GCM은 평문을 직접 뒤섞지 않는다. 키와 IV로 평문 길이만큼의 난수열을 만들어서 평문과 XOR 하는 게 전부다. 이 난수열을 키스트림이라고 부른다.

XOR에는 성질이 하나 있는데, 이 절의 내용이 전부 여기서 나온다. 같은 값을 두 번 XOR 하면 지워진다.

9 XOR 5 XOR 5 = 9

복호화가 되는 이유가 이것이다. 암호문에 같은 키스트림을 한 번 더 XOR 하면 키스트림이 지워지고 평문만 남는다.

암호화   c = p XOR KS
복호화   c XOR KS = p XOR KS XOR KS = p

문제는 IV를 고정했을 때다. 키스트림은 키와 IV로 만드는데 키는 원래 고정이니까, IV까지 고정하면 모든 행이 같은 키스트림 하나를 쓰게 된다. 그러면 암호문 두 개를 XOR 하는 것만으로 같은 일이 일어난다. 아래에서 KS가 지워지는 지점을 볼 수 있다.

IV를 고정하면 KS가 지워진다p 평문 · c 암호문 · KS 키스트림 (키와 IV로만 정해진다)c1=p1KSc2=p2KSIV가 같으니 같은 값c1 ⊕ c2=(p1 ⊕ KS) ⊕ (p2 ⊕ KS)=p1 ⊕ p2KS ⊕ KSKS가 두 번=p1 ⊕ p2키를 쓰지 않았다(p1 ⊕ p2) ⊕ p1=p2p1이 두 번 → 지워진다

복호화할 때 키스트림을 걷어내는 것과 정확히 같은 일이, 암호문 두 개를 나란히 놓는 것만으로 일어나는 것이다. 키를 한 번도 쓰지 않았다는 게 중요하다. 실제로 돌려보면 두 값이 그대로 맞는다.

same plaintext -> same ciphertext : true

c1 XOR c2 = 000000080a04020002040a
p1 XOR p2 = 000000080a04020002040a

첫 줄이 검색이 된다는 뜻이고, 아래 두 줄이 그 대가다.

그래서 실제 공격은 이렇게 된다. 자기 번호로 계정을 하나 만들면 p1을 알게 되고, DB에서 자기 행의 c1과 대상의 c2를 꺼내 마지막 단계의 계산을 돌리면 남의 번호가 나온다.

p2 recovered = 01098765432
p2 actual    = 01098765432

키가 전혀 필요 없고 DB 덤프와 자기 계정 하나만 있으면 된다. 이 상태로 심사를 받으면 salt 없는 SHA-256보다 나쁜 지적을 받는다.

전부 가져와서 앱에서 복호화해 거르는 방법도 있다. 10만 행 복호화가 131밀리초로 끝나서 될 것 같아 보이는데, 실제로는 인덱스, UNIQUE 제약, 네트워크 비용, 메모리에 올라오는 평문을 한꺼번에 포기하는 방법이다. O(log n) 조회가 O(n) 풀스캔이 되고, 중복 확인을 DB 제약으로 못 걸게 된다.

▍이중 컬럼 구성

저장은 두 번, 조회는 두 단계"01012345678"phoneAES-256-GCM · 랜덤 IV복원 O · 검색 Xphone_bidxHMAC-SHA256 · 결정론복원 X · 검색 O조회WHERE phone_bidx = HMAC(k, 입력)① 단방향 컬럼으로 행을 찾고AES 복호화(dek, phone)② 양방향 컬럼에서 원문을 꺼낸다

그래서 복원용 컬럼과 검색용 컬럼을 따로 둔다. 두 컬럼의 요구 조건이 정반대라서 하나로 합칠 수가 없다. 저장 컬럼은 같은 평문이 매번 다른 값이어야 하고, 검색 컬럼은 같은 평문이 항상 같은 값이어야 한다.

CREATE TABLE "user" (
  id          uuid  PRIMARY KEY,
  phone       bytea NOT NULL,   -- AES-256-GCM. 양방향. 원문 복원용
  phone_bidx  bytea NOT NULL    -- HMAC-SHA256. 단방향. 검색용
);
CREATE INDEX user_phone_bidx_idx ON "user" (phone_bidx);
ALTER TABLE "user" ADD CONSTRAINT user_phone_bidx_key UNIQUE (phone_bidx);

지금 옮기고 있는 HMAC 컬럼이 그대로 phone_bidx 자리에 들어간다. 양방향으로 가더라도 새로 만드는 건 AES 컬럼 하나뿐이라, 2세대 마이그레이션은 3세대로 갈 때 버려지는 작업이 아니다.

조회할 때는 단방향 컬럼으로 행을 찾고, 양방향 컬럼에서 원문을 꺼낸다.

const row = await db.user.findFirst({
  where: { phone_bidx: hmacSha256(indexKey, "01012345678") },
});
const plain = row ? aesGcmDecrypt(dek, row.phone) : null;

WHERE, JOIN, UNIQUE는 전부 bidx 컬럼에 걸고, AES 컬럼은 이미 찾은 행에서 원문을 꺼낼 때만 쓴다.

결정론적 AES를 써서 컬럼 하나로 끝내면 되지 않을까 싶은데, 속도 때문은 아니다. 둘 다 마이크로초 단위라 차이가 없다.

알고리즘건당
HMAC-SHA2560.963 µs
AES-256-GCM (랜덤 IV)2.066 µs

인덱스 컬럼을 복원 불가능하게 두는 것이 목적이다. 결정론적 암호문도 암호문이라 키가 있으면 평문이 나오고, 그 컬럼에 인덱스를 걸면 백업, 리드 리플리카, 인덱스 덤프에 평문의 두 번째 사본이 그대로 들어간다. 게다가 암호문이 곧 인덱스면 DEK를 회전할 때마다 B-tree를 다시 만들어야 한다.

▍bidx에 남는 노출

단방향이라고 해서 동일성까지 가려지는 것은 아니다. 결정론적이니까 같은 번호는 항상 같은 다이제스트가 되고, 그것을 확인하는 데는 역산이 필요 없다.

SELECT phone_bidx, COUNT(*) FROM "user" GROUP BY phone_bidx HAVING COUNT(*) > 1;

심사에서 다시 물어볼 수 있는 지점이라 미리 정리해뒀다. 동일성을 가리려면 다이제스트를 잘라서 충돌을 일부러 만들어야 하는데, 얼마나 잘라야 하는지가 직관과 어긋난다. 서로 다른 전화번호 10만 개를 넣고 세어봤다.

남긴 비트bucket 수평균 후보최대 후보유일한 값
256 (자르지 않음)100,0001.001100.0%
3299,9991.002100.0%
2499,7291.00299.5%
2095,4071.05491.0%
1651,3261.95921.7%
124,09624.41470.0%
8256390.634510.0%

4바이트인 32비트까지 잘라도 10만 행이 전부 유일하게 남는다. 실제로 가려지기 시작하는 구간은 12비트에서 16비트 사이다. 그런데 자른 bidx에는 UNIQUE를 걸 수 없다. 충돌을 일부러 만든 컬럼이라 제약을 걸면 멀쩡히 다른 번호를 넣는 가입이 막힌다.

자르는 정도동일성 은닉DB UNIQUE조회당 복호화
자르지 않음 (32B)안 됨가능1건
24~32비트거의 안 됨가능하나 충돌 시 오탐1~2건
12~16비트불가2~24건

셋을 다 가질 수는 없어서, 전화번호 중복 가입 검사가 목적이면 자르지 않고 UNIQUE를 택하는 편이 낫다. 이 번호가 이미 있다고 응답하는 순간 동일성은 애플리케이션이 알려주는 정보가 되니까, 인덱스에서 가려봐야 얻는 것이 적다.

▍세 번째 질문

필드마다 두 가지를 묻는다검색 불필요검색 필요복원 필요복원 불필요AES-256-GCM컬럼 하나상세 주소, 메모AES-256-GCM + HMAC컬럼 두 개전화번호, 이메일, 이름bcrypt / Argon2컬럼 하나비밀번호HMAC-SHA256컬럼 하나중복 확인 전용 값컬럼이 두 개가 되는 경우는 하나뿐이다

여기까지 겪고 나서 필드를 볼 때 묻는 순서가 바뀌었다. 원래는 두 가지를 물었다.

  • 원문을 다시 읽어야 하는가. 아니면 단방향으로 끝낼 수 있다.
  • 그 값으로 검색해야 하는가. 그러면 결정론적인 컬럼이 하나 필요하다.

여기에 세 번째가 붙었다. 이 결정이 나중에 바뀔 수 있는가. 법이 바뀌거나 심사 기준이 바뀌거나 요구사항이 바뀌면 저장 방식을 바꿔야 하는데, 바뀔 여지가 조금이라도 있으면 단방향 단독은 위험하다. 원문을 버리는 순간 그 뒤의 선택지가 같이 사라진다.

비밀번호만 이 질문에서 자유롭다. 어떤 기준이 와도 비밀번호를 복원할 일은 생기지 않으니까 bcrypt나 Argon2로 끝내면 된다. 나머지 개인정보는 전부 세 번째 질문에 걸린다.

▍마무리

정리하면 이렇다.

  1. salt 없는 SHA-256은 원문을 감춘 게 아니다. 전화번호 전체 공간이 노트북 코어 하나로 45초고, GPU면 서너 자릿수 빠르다.
  2. 단방향으로 저장하면 나중에 방식을 바꿀 수 없다. 알고리즘 선택이 아니라 원문을 버리는 결정이다.
  3. 이번에 옮길 수 있었던 건 salt가 없었기 때문이다. 제대로 저장했다면 복구율이 0이었다.
  4. 한 번에 못 옮기면 lazy migration으로 가되, 남은 행 수를 지표로 잡고 옛 경로를 닫을 시점을 정해둔다.
  5. 값에 세대 접두어를 붙여두면 세 방식이 공존해도 조회 코드가 분기를 정확히 태울 수 있다.
  6. 양방향으로 바꾸면 마이그레이션 문제는 사라지지만 검색이 깨진다. IV를 고정해서 풀려고 하면 키 없이 평문이 복원된다.
  7. 검색이 필요하면 AES 컬럼과 HMAC 컬럼을 따로 둔다. 지금 만드는 HMAC 컬럼이 그때 bidx로 그대로 쓰인다.
  8. 필드를 볼 때 복원과 검색에 더해 이 결정이 나중에 바뀔 수 있는지를 같이 묻는다.

처음에 SHA-256을 고른 게 틀린 판단은 아니었다. 그때 기준으로는 해시로 저장하면 안전하다는 게 상식이었고, 검색도 중복 확인도 잘 됐다. 놓친 건 알고리즘이 아니라 그 컬럼이 나중에 바뀔 수 있다는 사실이었다.

그래서 지금 컬럼 하나를 설계할 때 마지막으로 확인하는 건 성능도 알고리즘도 아니고, 이 값을 나중에 다른 방식으로 옮겨야 할 때 옮길 수 있는가다. 답이 아니오라면 그 컬럼은 지금 결정으로 영원히 간다.


측정 환경: Node v22.23.0 / OpenSSL 3.5.7, Apple Silicon, 단일 코어. SHA-256 처리량은 워밍업 20만 회 후 200만 회씩 5세트 중간값, 건당 시간은 워밍업 2만 회 후 20만 회씩 7세트 중간값, 전수 복호화는 워밍업 3회 후 7회 중간값. bucket 실측은 서로 다른 전화번호 10만 개에 HMAC-SHA256을 적용한 뒤 상위 비트만 남겨 집계.

참고