AES-GCM 제대로 쓰기: ECB, nonce 재사용, 태그 검증의 함정
보안 기초 강좌 5. ECB 로 그림이 비치고, nonce 재사용으로 내용이 읽히고, PHP 가 1바이트 태그를 통과시킨 사례, openssl enc 의 함정, PBKDF2·scrypt·Argon2id 키 유도 시간까지 직접 실행.
시리즈: 보안 기초 강좌 5/6 (고급). 고객 API 키, 토큰, 개인정보 컬럼, 백업 파일을 암호화해서 저장하는 일을 맡은 개발자를 위한 글입니다. "돌아가는 호출"이 아니라 "안전한 호출"이 무엇인지 알고 싶은 분이 대상입니다. 다 읽으면 ECB, nonce 재사용, 인증 없는 CBC/CTR 을 자기 컴퓨터에서 직접 깨 본 경험과, Python 과 PHP 양쪽에서 열 수 있는 "비밀번호 → 키 → AES-GCM" 패턴을 손에 넣게 됩니다.
암호화는 보안 중에서도 "돌아가는데 틀린" 코드가 가장 많은 분야입니다. 아래의 망가진 예제는 전부 자기 데이터로 테스트하면 정상적으로 복호화됩니다. 문제는 누군가 1바이트를 바꿨을 때, 암호문 두 개를 봤을 때, 짧은 태그를 보냈을 때만 드러납니다.
국내 실무에서는 개인정보보호법의 안전성 확보 조치와 연결해서 생각하면 쉽습니다. 일반적으로 비밀번호는 일방향 암호화(해시)로, 주민등록번호 같은 고유식별정보는 안전한 알고리즘으로 암호화해서 저장해야 한다는 기준이 잘 알려져 있습니다. 그런데 "AES 로 암호화했다"고 해서 그 기준을 만족한다고 보기는 어렵습니다. ECB 로 패턴이 비치거나, 키가 소스 코드에 박혀 있거나, 누구나 바꿀 수 있는 암호문이라면 사고가 났을 때 설명하기 어렵습니다(구체적인 판단은 공식 고시·가이드와 전문가에게 확인하세요). 정보보안기사를 준비 중이라면 블록 암호 운용 모드(ECB, CBC, CTR, GCM)가 단골 문제인데, 이 글은 그 표를 직접 실행해서 확인하는 편이기도 합니다.
실행 환경은 Apple Silicon Mac(M1 Pro), PHP 8.5.6(OpenSSL 3.6.2, libsodium 1.0.22 링크), OpenSSL 4.1.0 CLI, macOS 기본 Python 3.9.6, Java 26 입니다. 키·메시지·"공격"은 전부 로컬에서 만든 데모 데이터이고, 경로는 ~/demo/ 로 바꿨습니다.
앞 강좌를 건너뛰었다면: 1강에서 해시·MAC·암호화를 구분했고, 4강에서 MAC 으로 토큰에 서명했습니다. 이번에는 둘 다 필요합니다. 기밀성(읽을 수 없음)과 무결성(몰래 바꿀 수 없음)입니다.
내 도구로 실제로 쓸 수 있는 것
코드를 쓰기 전에 무엇이 실제로 있는지 확인합니다. 아래 표는 한 줄씩 직접 시험한 결과입니다.
| 도구(이번 버전) | AES-GCM | 다른 AEAD | 비밀번호용 KDF |
|---|---|---|---|
| Python 3.9.6 표준 라이브러리 | 없음(AES 자체가 없음) | 없음 | hashlib.pbkdf2_hmac 만. hashlib.scrypt 없음 |
Python + cryptography 48.0.0(외부 패키지) |
AESGCM |
ChaCha20-Poly1305 | Scrypt, PBKDF2 |
PHP 8.5.6 openssl_* |
aes-256-gcm |
목록에 aes-256-gcm-siv |
hash_pbkdf2 |
PHP 8.5.6 sodium_* |
sodium_crypto_aead_aes256gcm_*(사용 가능: true) |
XChaCha20-Poly1305 | Argon2id, scrypt |
Java 26 javax.crypto |
AES/GCM/NoPadding |
ChaCha20-Poly1305 | PBKDF2 |
OpenSSL 4.1.0 CLI enc |
거부됨 | 거부됨 | 기본은 옛 방식, -pbkdf2 는 선택 |
두 가지가 의외였습니다. macOS 기본 Python 은 LibreSSL 2.8.3 과 링크되어 있어서 hashlib.scrypt 가 아예 없습니다.
$ python3 -c "import hashlib; hashlib.scrypt(b'pw', salt=b's'*16, n=2**14, r=8, p=1)"
AttributeError: module 'hashlib' has no attribute 'scrypt'
python.org 판이나 Homebrew 판처럼 OpenSSL 과 링크된 Python 에는 있으니, "내 맥에서는 됐는데" 코드가 동료 환경에서 터질 수 있습니다. 다른 하나인 CLI 는 뒤에서 다룹니다. 이 글의 Python AES 코드는 cryptography 패키지를 씁니다. 이 맥에는 이미 깔려 있었지만 표준 라이브러리가 아니므로, 프로젝트에서는 requirements 에 명시합니다.
ECB: 데이터의 "그림"이 비치는 모드
ECB(Electronic Codebook)는 16바이트 블록을 같은 키로 하나씩 따로 암호화합니다. 같은 평문 블록은 같은 암호문 블록이 됩니다. 이걸 눈으로 보려고 8×8 "아이콘"을 만들었습니다. 한 칸이 16바이트 블록이고 0xFF(검정) 또는 0x00(흰색)입니다. 암호문에서 서로 다른 블록이 몇 개인지 셉니다.
<?php
// ~/demo/ecb.php — 반복 블록이 있는 데이터로 ECB / CBC / GCM 비교
$key = hex2bin('000102030405060708090a0b0c0d0e0f'); // 데모 전용 고정 키
$icon = ["..####..", ".#....#.", "#.#..#.#", "#......#",
"#.#..#.#", "#..##..#", ".#....#.", "..####.."];
$pt = '';
foreach ($icon as $row) foreach (str_split($row) as $c)
$pt .= str_repeat($c === '#' ? "\xFF" : "\x00", 16);
function show(string $name, string $ct): void {
$blocks = str_split(substr($ct, 0, 1024), 16);
$uniq = array_values(array_unique($blocks));
echo "$name: unique ciphertext blocks = ", count($uniq), " / 64\n";
if (count($uniq) <= 4) foreach (array_chunk($blocks, 8) as $row) {
echo " ";
foreach ($row as $b) echo chr(65 + array_search($b, $uniq));
echo "\n";
}
}
$raw = OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING;
show('AES-128-ECB', openssl_encrypt($pt, 'aes-128-ecb', $key, $raw));
show('AES-128-CBC', openssl_encrypt($pt, 'aes-128-cbc', $key, $raw, random_bytes(16)));
show('AES-128-GCM', openssl_encrypt($pt, 'aes-128-gcm', $key, OPENSSL_RAW_DATA, random_bytes(12), $tag));
AES-128-ECB: unique ciphertext blocks = 2 / 64
AABBBBAA
ABAAAABA
BABAABAB
BAAAAAAB
BABAABAB
BAABBAAB
ABAAAABA
AABBBBAA
AES-128-CBC: unique ciphertext blocks = 64 / 64
AES-128-GCM: unique ciphertext blocks = 64 / 64
키가 없어도 ECB 암호문에서 웃는 얼굴이 그대로 나옵니다. 서로 다른 블록은 두 종류뿐이고 그 배치가 곧 그림입니다. 실제 데이터도 같습니다. 16바이트 "상태" 필드, 고정 길이 레코드, 패딩한 회원번호를 ECB 로 암호화하면 어떤 행끼리 값이 같은지, 값마다 몇 번 나오는지가 드러납니다.
ECB 는 실수로도 씁니다. PHP 에서 IV 없이 openssl_encrypt($data, 'aes-256-cbc', $key) 를 부르면 Warning: openssl_encrypt(): Using an empty Initialization Vector (iv) is potentially insecure and not recommended 경고를 낸 뒤 전부 0 인 IV 로 암호화합니다. 한 블록짜리 메시지에서는 ECB 와 똑같은 암호문 sHQyEV+uqAy8hauXYqiQGw== 이 나왔습니다.
nonce 재사용: 암호문 두 개면 XOR 한 번
GCM 은 CTR 모드에 인증 태그를 더한 것입니다. CTR 은 키와 nonce 로 키스트림을 만들어 평문과 XOR 합니다. 같은 키와 nonce 를 두 번 쓰면 키스트림이 같아서 서로 상쇄됩니다.
c1 ⊕ c2 = (m1 ⊕ k) ⊕ (m2 ⊕ k) = m1 ⊕ m2
공격자에게 키는 필요 없습니다. 짐작할 수 있는 평문 하나면 충분합니다. 여기서는 첫 메시지를 사내 공지(누구나 볼 수 있음), 두 번째를 인사 평가 메시지로 했습니다.
<?php
// ~/demo/nonce.php — GCM nonce 를 재사용하면 무엇이 새는가
$m1 = '공지: 오늘 22시부터 점검합니다'; // 누구나 아는 공개 문장
$m2 = '사번 1234 평가 A, 연봉 5% 인상'; // 비밀
$key = random_bytes(32);
$nonce = random_bytes(12); // 버그: 아래에서 같은 nonce 를 두 번 쓴다
$c1 = openssl_encrypt($m1, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t1);
$c2 = openssl_encrypt($m2, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t2);
$n = min(strlen($c1), strlen($c2));
$xc = substr($c1, 0, $n) ^ substr($c2, 0, $n);
echo "c1 xor c2 == m1 xor m2 ? ", var_export($xc === (substr($m1, 0, $n) ^ substr($m2, 0, $n)), true), "\n";
echo "recovered m2 prefix: ", $xc ^ substr($m1, 0, $n), "\n"; // 키는 쓰지 않았다
c1 xor c2 == m1 xor m2 ? true
recovered m2 prefix: 사번 1234 평가 A, 연봉 5% 인상
공지는 UTF-8 로 42바이트, 비밀 메시지는 38바이트라서 비밀 쪽을 전부 복원했습니다. XOR 은 글자가 아니라 바이트 단위로 먹힙니다(글자가 바이트가 되는 원리). 실행하지는 않았지만 더 나쁜 결과도 있습니다. 같은 nonce 로 된 메시지가 두 개 있으면 공격자가 GCM 내부의 인증용 키를 풀어낼 수 있고, 원하는 메시지에 올바른 태그를 붙여 위조할 수 있게 됩니다.
실제 코드에서 nonce 가 재사용되는 경우는 "일단" 넣은 상수 nonce, 재시작하면 0 으로 돌아가는 메모리 카운터, 서버 두 대가 각자 1부터 세는 카운터, 시각으로 만든 nonce 등입니다. Java 는 이 중 한 경우만 막아 줍니다.
first: 19 bytes
java.security.InvalidAlgorithmParameterException: Cannot reuse iv for GCM encryption
new Cipher, same key+iv: accepted, 19 bytes
같은 Cipher 객체는 같은 IV 로 다시 암호화하기를 거부했지만, 새 Cipher 객체는 받아들였습니다. 객체·프로세스·서버를 넘나드는 재사용은 라이브러리가 막을 수 없습니다. 규칙은 "메시지마다 랜덤 12바이트 nonce 를 만들고 암호문 옆에 저장한다" 입니다. 랜덤 96비트 nonce 라면 키 하나로 암호화하는 메시지 수를 2^32 보다 충분히 적게 유지합니다(NIST 의 GCM 지침). 그보다 많거나 난수 품질을 장담할 수 없으면 24바이트 nonce 의 XChaCha20-Poly1305 를 씁니다.
인증 없는 암호화: 100 이 900 이 된다
암호화는 데이터를 숨기지만 변경을 막지는 않습니다. CTR 모드에서는 암호문의 비트 하나를 뒤집으면 평문의 같은 자리 비트가 뒤집힙니다. 형식이 정해져 있으면 amount=100 이 어디 있는지 공격자도 압니다.
<?php
// ~/demo/tamper.php — 같은 1바이트 변조를 CTR 과 GCM 에
$m = 'to=kim&amount=100&cur=KRW';
$key = random_bytes(32);
$pos = strpos($m, 'amount=') + 7; // 100 의 '1'
$flip = ord('1') ^ ord('9');
$iv = random_bytes(16);
$c = openssl_encrypt($m, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv);
$c[$pos] = chr(ord($c[$pos]) ^ $flip); // 키 없이 1바이트 변조
echo "CTR decrypts to: ", openssl_decrypt($c, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv), "\n";
$n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$c[$pos] = chr(ord($c[$pos]) ^ $flip);
echo "GCM decrypt: "; var_dump(openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag));
CTR decrypts to: to=kim&amount=900&cur=KRW
GCM decrypt: bool(false)
CTR 은 겉보기에 멀쩡한 "900원 송금"을 돌려줬습니다. GCM 은 16바이트 태그가 맞지 않아 거부했습니다. 이것이 "인증 암호(AEAD)"의 의미입니다. 복호화는 원래 평문을 돌려주거나 실패하거나 둘 중 하나입니다.
CBC 도 안전하지 않습니다. 33바이트 파일 memo: thanks!!!!to=alice;pay=100 을 OpenSSL CLI 로 암호화하고, 첫 번째 암호문 블록의 1바이트를 뒤집은 뒤 복호화했습니다.
$ openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass pass:demo -in t2.bin | xxd
00000000: 7fbf 389b 36ff 0355 7316 78ee 6aaf d17d ..8.6..Us.x.j..}
00000010: 746f 3d61 6c69 6365 3b70 6179 3d39 3030 to=alice;pay=900
00000020: 0a .
exit=0
첫 블록은 깨졌고 두 번째 블록은 pay=900 이 됐으며 종료 코드는 0 입니다. 종료 코드만 보는 프로그램은 이걸 받아들입니다. 패딩도 도움이 안 됩니다. CBC 파일에 틀린 비밀번호 2,000개를 넣어 봤더니 8개가 bad decrypt 없이 "복호화"됐습니다(기댓값 2000/256 ≈ 7.8). 결과는 패딩만 우연히 맞아 보이는 쓰레기입니다.
PHP 가 1바이트 태그를 통과시켰다
GCM 태그는 보통 16바이트입니다. 변조 데모를 하다가 복호화 쪽에 짧은 태그를 넣어 봤는데, PHP 8.5.6 은 1바이트까지 모두 받아들였습니다.
tag len 16: ACCEPTED
tag len 12: ACCEPTED
tag len 8: ACCEPTED
tag len 4: ACCEPTED
tag len 1: ACCEPTED
요청이나 저장 데이터에서 태그를 꺼내 그대로 openssl_decrypt 에 넘기는 코드라면 공격자는 1바이트 태그를 보낼 수 있습니다. 그러면 위조는 최대 256번 시도로 끝납니다.
<?php
// ~/demo/shorttag.php — 태그 길이를 믿는 수신 측을 상대로 GCM 메시지 위조
$m = 'to=kim&amount=100&cur=KRW';
$key = random_bytes(32); $n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$pos = strpos($m, 'amount=') + 7;
$c[$pos] = chr(ord($c[$pos]) ^ (ord('1') ^ ord('9'))); // 변조됨, 진짜 태그는 모름
for ($b = 0; $b < 256; $b++) {
$r = openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, chr($b));
if ($r !== false) { echo "forged after ", $b + 1, " tries: $r\n"; break; }
}
function gcm_open(string $c, string $key, string $n, string $tag): string|false {
if (strlen($key) !== 32 || strlen($n) !== 12 || strlen($tag) !== 16) return false; // 해결책
return openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
}
forged after 221 tries: to=kim&amount=900&cur=KRW
forged after 232 tries: to=kim&amount=900&cur=KRW
forged after 52 tries: to=kim&amount=900&cur=KRW
키와 nonce 가 매번 랜덤이라 횟수는 달라집니다(평균 약 128번). 길이 확인을 넣으면 같은 1바이트 태그가 false 가 됩니다. 빈 태그는 확인 없이도 거부됐습니다. Java 는 더 엄격해서 new GCMParameterSpec(32, iv) 가 Unsupported TLen value. Must be one of {128, 120, 112, 104, 96} 로 실패했습니다. libsodium 에는 태그 길이 인자 자체가 없습니다.
PHP 의 키 처리도 마찬가지로 조용합니다. aes-256-cbc 에 키 "k" 를 주면 "k" 뒤에 NUL 31바이트를 붙인 키와 같은 암호문이 나왔고, 40바이트 키는 앞 32바이트와 같은 결과였습니다. 둘 다 경고가 없습니다. 즉 openssl_encrypt($data, 'aes-256-cbc', 'password123', …) 는 비밀번호를 0 으로 채워 그대로 AES 키로 씁니다. 키 유도는 전혀 없습니다.
완성형: 비밀번호 → 키 → AES-GCM
키를 비밀번호에서 만들 때는 암호문 말고도 세 가지를 저장 값에 넣습니다. KDF 용 랜덤 솔트, 랜덤 nonce, 나중에 파라미터를 바꿀 수 있게 하는 버전 접두사입니다. 버전은 연관 데이터(AAD)로도 넘깁니다. AAD 는 암호화되지 않지만 변조는 검출됩니다.
# ~/demo/seal.py — PBKDF2 는 표준 라이브러리, AES-256-GCM 은 "cryptography" 패키지
import base64, hashlib, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
def seal(password: str, plaintext: str, aad: bytes = b"v1") -> str:
salt, nonce = os.urandom(16), os.urandom(12)
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
ct = AESGCM(key).encrypt(nonce, plaintext.encode(), aad) # 암호문 || 16바이트 태그
return base64.b64encode(b"v1" + salt + nonce + ct).decode()
def open_(password: str, token: str, aad: bytes = b"v1") -> str:
raw = base64.b64decode(token)
if raw[:2] != b"v1" or len(raw) < 2 + 16 + 12 + 16:
raise ValueError("bad format")
salt, nonce, ct = raw[2:18], raw[18:30], raw[30:]
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
return AESGCM(key).decrypt(nonce, ct, aad).decode()
tok = seal("correct horse", "스테이징 API 키: demo-123")
print(len(tok), tok[:24] + "...")
print(open_("correct horse", tok))
try:
open_("wrong horse", tok)
except InvalidTag:
print("wrong password -> InvalidTag")
104 djEuOu6LVizmIxcRtU+nf1NU...
스테이징 API 키: demo-123
wrong password -> InvalidTag
틀린 비밀번호는 매번 InvalidTag 입니다. CBC 의 "2,000번 중 8번은 통과"와는 다릅니다. 이 형식은 언어를 넘나듭니다. Python 으로 봉한 값을 PHP 는 hash_pbkdf2 와 openssl_decrypt 만으로 열었습니다. 태그는 끝의 16바이트를 잘라서 넘깁니다.
<?php
$raw = base64_decode(trim(file_get_contents($argv[1])), true);
$salt = substr($raw, 2, 16); $nonce = substr($raw, 18, 12);
$tag = substr($raw, -16); $ct = substr($raw, 30, -16);
$key = hash_pbkdf2('sha256', 'correct horse', $salt, 600000, 32, true);
var_dump(openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, 'v1'));
// string(12) "interop test"
KDF 는 얼마나 무거워야 할까요? 역할은 비밀번호 해싱(2강)과 같습니다. 비밀번호 추측 한 번마다 확실한 시간을 치르게 하는 것입니다. 고정 비밀번호와 솔트로 설정마다 5번씩 잰 중앙값입니다.
| KDF 와 설정 | 중앙값 |
|---|---|
| PBKDF2-SHA256, 1만 회(Python) | 3.0 ms |
| PBKDF2-SHA256, 10만 회(Python) | 30.5 ms |
| PBKDF2-SHA256, 60만 회(Python) | 181.6 ms |
PBKDF2-SHA256, 60만 회(PHP hash_pbkdf2) |
793.6 ms |
| scrypt, libsodium interactive(16 MiB) | 33.9 ms |
scrypt N=2^17, r=8(OpenSSL CLI kdf, 프로세스 시작 포함) |
0.30 s |
| Argon2id, libsodium interactive(2패스, 64 MiB) | 62.9 ms |
| Argon2id, libsodium moderate(3패스, 256 MiB) | 425.7 ms |
Python 과 PHP 의 PBKDF2 는 같은 키(96a5904c2e08c8da…)를 냈지만 PHP 가 약 4.4배 오래 걸렸습니다. 한 언어에서 반복 횟수를 정하고 다른 언어에서 검증하는 구조라면 체감됩니다. Argon2id 와 scrypt 는 메모리도 쓰기 때문에 GPU 대입이 훨씬 비싸지고, 쓸 수 있다면 PBKDF2 보다 낫습니다. PBKDF2 60만 회는 그것밖에 없을 때(이 맥의 Python 표준 라이브러리처럼) 쓰는 SHA-256 용 OWASP 권장값입니다.
애초에 비밀번호가 필요 없다면(서버 쪽 데이터는 보통 이쪽) KDF 도 필요 없습니다. random_bytes(32) 나 secrets.token_bytes(32) 로 32바이트 키를 한 번 만들고, 데이터베이스 밖(환경 변수, 시크릿 관리 도구)에 두고 그대로 씁니다. 랜덤 키는 늘릴 필요가 없습니다.
명령줄의 openssl enc
백업 암호화에 OpenSSL CLI 를 많이 쓰니, 4.1.0 이 실제로 무엇을 하는지 확인해 둡니다.
$ openssl enc -aes-256-gcm -pass pass:demo -in msg.txt -out /dev/null
enc: AEAD ciphers not supported
enc: Use -help for summary.
exit=1
$ openssl enc -aes-256-cbc -pass pass:demo -in msg.txt -out c1.bin
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
exit=0
enc는 GCM 을 거부합니다. CLI 로는 인증 없는 모드밖에 못 쓰고, 위의 변조는 이 명령이 만드는 모든 파일에 해당합니다.-pbkdf2도-iter도 없으면 기본값은 여전히 한 번짜리 옛 키 유도(EVP_BytesToKey)입니다. 경고만 나오고 종료 코드는 0 입니다. 아직도 기본값인지 확신이 없어서 4.1.0 에서 확인했는데, 그대로였습니다.-pbkdf2를 붙이면openssl enc -help기준 반복 횟수 기본값은 10000 입니다. 위 실측에서 3.0 ms 였습니다.-iter 600000이상을 지정합니다.-pbkdf2로 암호화한 파일을 그 옵션 없이 복호화하면bad decrypt로 실패합니다. 옵션은 파일에 기록되지 않고Salted__와 8바이트 솔트만 들어 있습니다. 백업 옆에 설정을 적어 둡니다.-pass pass:…는 비밀번호가 셸 히스토리와 프로세스 목록에 남습니다.-pass file:이나-pass env:를 씁니다.
암호화 백업에는 인증 암호를 쓰는 도구가 낫습니다(age 나 gpg 를 많이 쓰지만 이번에 실행하지는 않았습니다). 꼭 openssl enc 를 써야 한다면 파일 전체에 별도 HMAC 을 붙이고 복호화 전에 검증합니다.
흔한 실수와 고치는 법
| 실수 | 실제로 일어난 일 | 해결 |
|---|---|---|
| AES-ECB, 또는 IV 없는 CBC | 아이콘이 남음. 64블록 중 2종류 | 랜덤 nonce 를 쓰는 AEAD 모드 |
| 고정·카운터 nonce 재사용 | 공개 문장 하나로 비밀 38바이트 전부 복원 | 메시지마다 랜덤 12바이트 nonce, 또는 XChaCha20-Poly1305 |
| MAC 없는 CTR/CBC | amount=900, pay=900 통과, 종료 코드 0 |
GCM 또는 ChaCha20-Poly1305. 복호화 결과를 반드시 확인 |
| 태그 길이를 입력에서 받음 | 1바이트 태그 통과, 256번 안에 위조 | 태그 16바이트·nonce 12바이트를 필수로 |
| 비밀번호를 그대로 키로 | PHP 가 "k" 를 조용히 NUL 로 채움 |
솔트 있는 KDF, 또는 랜덤 32바이트 키 |
openssl enc 기본값 |
옛 KDF, PBKDF2 1만 회, 무결성 없음 | -pbkdf2 -iter 600000 과 MAC, 또는 AEAD 도구 |
복호화의 false 무시 |
false 나 빈 문자열로 처리가 계속됨 |
실패는 오류로 처리하고 원본 데이터로 "되돌리지" 않음 |
어떤 방식을 고를까
| 상황 | 쓸 것 | 이유 |
|---|---|---|
| PHP 에서 libsodium 사용 가능 | sodium_crypto_aead_xchacha20poly1305_ietf_* |
크기 고정, 24바이트 랜덤 nonce, 모드 선택 불필요 |
| AES 필수(규정, 상호 운용) | AES-256-GCM, 랜덤 12바이트 nonce, 16바이트 태그 확인 | 표준 AEAD, 하드웨어 가속 |
| 키 하나로 메시지가 아주 많음, 난수가 약함 | XChaCha20-Poly1305 또는 AES-GCM-SIV | 랜덤 nonce 로 안전, 또는 오용에 강함 |
| 사람의 비밀번호로 키 생성 | Argon2id 로 키를 만든 뒤 AEAD | 추측마다 시간과 메모리가 듦 |
| 서버 측 비밀 키 | random_bytes(32) 를 DB 밖에 보관 |
KDF 불필요 |
| CLI 로 파일 암호화 | AEAD 도구, 또는 enc 와 별도 HMAC |
enc 는 AEAD 불가 |
libsodium 이면 PHP 쪽은 호출 두 번이고, 연관 데이터도 GCM 과 똑같이 씁니다.
<?php
$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen(); // 랜덤 32바이트
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
$c = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt('to=kim&amount=100&cur=KRW', 'order:42', $nonce, $key);
var_dump(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($c, 'order:43', $nonce, $key)); // bool(false)
25바이트 메시지는 41바이트(25 + 태그 16바이트)가 됐고, AAD 를 order:42 대신 order:43 으로 복호화하자 false 가 나왔습니다.
솔직한 한계와 버전 메모
- 버전: PHP 8.5.6(OpenSSL 3.6.2, libsodium 1.0.22), OpenSSL CLI 4.1.0, Python 3.9.6(LibreSSL 2.8.3)과
cryptography48.0.0, Java 26. 1바이트 태그가 통과한 것은 이 PHP 8.5.6 에서의 결과이며 다른 버전은 시험하지 않았습니다. 자기 환경에서 위 루프로 확인하세요. - nonce 재사용 뒤 GCM 인증 키 복원은 설명만 했고 실행하지 않았습니다. XOR 복원은 실행했습니다.
- KDF 시간은 M1 Pro 한 대에서 5회 중앙값이고, CLI scrypt 는 프로세스 시작 시간을 포함합니다. 운영 서버에서는 값이 다르니 거기서 재고, 로그인 처리가 감당할 수 있는 비용을 목표로 합니다.
- 이 맥에서는
sodium_crypto_aead_aes256gcm_is_available()가true였지만 CPU 에 따라false가 됩니다. XChaCha20-Poly1305 는 그런 의존이 없습니다. - Swift CryptoKit 과 .NET
AesGcm은 이번에 실행하지 않았습니다. - 키 관리 전체 설계는 아닙니다. 키 교체, 키 자체의 백업, 접근 통제는 별도 작업입니다.
연습 문제
1. 동료가 고객 메모를 openssl_encrypt($note, 'aes-256-ctr', $key, 0, $iv) 로 저장합니다. $iv 는 설정 파일에 있는 16바이트 상수입니다. 문제 두 가지와, 데이터베이스를 읽고 쓸 수 있는 공격자가 각각으로 무엇을 할 수 있는지 답하세요.
2. 위 Python seal() 이 만든 값을 PHP 에서 받습니다. openssl_decrypt 를 부르기 전에 할 확인을 순서대로 적으세요.
정답
1. (a) IV 가 고정이라 모든 메모가 같은 키스트림으로 암호화됩니다. 두 건을 읽으면 그 XOR 이 나오고, 알려졌거나 짐작 가능한 메모("고객 전화 문의: …" 같은 정형 문구) 하나로 다른 메모가 읽힙니다. nonce 데모와 같습니다. (b) CTR 은 무결성이 없습니다. 쓰기 권한이 있는 공격자는 알려진 위치의 바이트를 뒤집어 100 → 900 처럼 바꿀 수 있고 복호화는 성공합니다. 해결: AES-256-GCM 이나 XChaCha20-Poly1305 를 쓰고, 메모마다 새 랜덤 nonce 를 만들어 함께 저장하며, 복호화의 false 는 오류로 처리합니다.
2. 엄격 모드로 Base64 디코딩하고 false 면 실패. v1 접두사 확인. 전체 길이가 2 + 16 + 12 + 16 이상인지 확인. 솔트(16), nonce(12), 태그(끝 16), 암호문으로 분리. 태그가 정확히 16바이트, nonce 가 12바이트인지 확인. 같은 KDF 와 파라미터로 키 유도. AAD 로 v1 전달. 결과가 false 가 아닐 때만 평문 사용.
다음
- 이전: 보안 기초 강좌 4/6: HS256 JWT 서명의 함정
- 다음: 보안 기초 강좌 6/6: 타이밍 공격과 상수 시간 비교
- 시리즈 앞부분: 해시·암호화·인코딩의 차이, bcrypt 와 Argon2 로 비밀번호 해싱, 안전한 난수·토큰·시크릿
- 저장이 아니라 전송 구간의 암호화: HTTPS, TLS, 인증서
- PHP 웹 보안의 나머지 절반: PHP 의 XSS, CSRF, 세션
- 모든 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
키가 비밀이면 AES-GCM 의 nonce 를 재사용해도 되나요?
안 됩니다. 키와 nonce 가 같으면 키스트림이 같아서, 두 암호문의 XOR 이 두 평문의 XOR 과 같아집니다. 이번 실행에서는 공개된 공지문 하나만 알고 있으면 키 없이 나머지 메시지를 전부 읽을 수 있었습니다. 태그 위조도 가능해집니다. 메시지마다 랜덤 12바이트 nonce 를 쓰거나, 24바이트 nonce 의 XChaCha20-Poly1305 를 씁니다.
openssl_decrypt 가 위조된 AES-GCM 데이터를 통과시킨 이유는?
PHP 8.5.6 의 openssl_decrypt 는 복호화할 때 태그 길이를 가리지 않았고 1바이트도 통과했습니다. amount=100 을 amount=900 으로 바꾸고 1바이트 태그 256가지를 시도하자 52~232번째에 통과했습니다. openssl_decrypt 를 부르기 전에 태그가 정확히 16바이트, nonce 가 정확히 12바이트인지 직접 확인하거나, 크기가 고정된 libsodium 을 씁니다.
파일 암호화에 openssl enc -aes-256-cbc 를 써도 괜찮나요?
기본값 그대로는 부족합니다. OpenSSL 4.1.0 도 -pbkdf2 가 없으면 옛 키 유도를 쓰고 경고만 낸 뒤 종료 코드 0 으로 끝납니다. -pbkdf2 의 기본 반복 횟수는 1만 회입니다. CBC 는 무결성이 없어서 암호화 파일의 pay=100 을 pay=900 으로 바꿔도 복호화가 0 으로 끝났습니다. enc 는 GCM 같은 AEAD 를 받지 않습니다.