타이밍 공격과 상수 시간 비교: == 와 hash_equals 실측
보안 기초 강좌 6. == 와 hash_equals·compare_digest 를 일치 길이별로 재고, 프로세스 안과 루프백 HTTP 에서 검출되는지 통계로 비교. 가입 여부가 127배 시간 차로 새는 로그인도 실측.
시리즈: 보안 기초 강좌 6/6 (고급). if ($token === $expected) 라고 써 놓고 나중에 "hash_equals 를 써야 한다"는 글을 본 개발자를 위한 마지막 강좌입니다. 비교가 일치한 길이에 따라 시간을 얼마나 흘리는지, 그 누출이 HTTP 왕복을 거쳐도 남는지, 흔한 웹 앱에서 어떤 타이밍 누출이 정말 큰지를 자기 컴퓨터에서 잽니다. 마지막에는 시리즈 전체 체크리스트를 정리합니다.
타이밍 공격은 연구실 얘기처럼 들리고, 어떤 코드에서는 실제로 그렇습니다. 하지만 다른 코드에서는 "요청 한 번이면 되는 버그"입니다. 정보보안기사 공부를 해 봤다면 부채널 공격(side-channel attack)이라는 이름으로 만났을 텐데, 시험에서는 정의만 외우고 지나가기 쉽습니다. 실무에서 더 자주 문제가 되는 건 로그인이나 비밀번호 찾기 화면이 "이 아이디가 가입되어 있는지"를 흘리는 경우입니다. 웹 취약점 점검에서도 계정 존재 여부 노출은 단골 지적 사항이고, 서비스에 따라서는 가입 사실 자체가 민감한 개인정보가 됩니다. 이 강좌의 목적은 두 경우를 소문이 아니라 숫자로 구분하고, 신호가 잡음에 묻혀 안 보일 때도 그대로 보고하는 것입니다.
환경: Apple Silicon Mac(M1 Pro), Python 3.9.6(time.perf_counter_ns), PHP 8.5.6(hrtime), 127.0.0.1:18150 에 띄운 php -S 8.5.6. 모든 측정은 이 강좌를 위해 이 컴퓨터에서 만든 코드와 데이터를 대상으로 했습니다. 시드는 고정했고(random.seed(2026), mt_srand(2026)), 드리프트가 모든 경우에 고르게 걸리도록 측정 순서를 섞었습니다. 경로는 ~/demo/ 로 바꿨습니다.
타이밍 공격에 필요한 것
a == b 같은 비교는 보통 처음 다른 바이트에서 멈춥니다. 공격자가 b 를 마음대로 정하고 시간을 잴 수 있다면 앞에서부터 몇 바이트가 맞았는지 알 수 있습니다. 64자 16진 토큰이라면 불가능한 한 번의 추측(16^64 가지)이, 후보 16개짜리 작은 추측 64번으로 바뀝니다.
실제로 통하는지는 세 가지가 정합니다.
- 신호: 일치하는 바이트가 하나 늘 때마다 늘어나는 시간. 16바이트 단위로 비교하는
memcmp는 거의 0 이고, 인터프리터 언어로 직접 짠 루프는 훨씬 큽니다. - 잡음: 그 밖의 모든 흔들림(스케줄러, 네트워크, 서버의 다른 작업). 공격자는 반복과 통계로 맞섭니다.
- 표본 수: 잡음은 대략 측정 횟수의 제곱근에 비례해 줄어듭니다. 신호가 10분의 1이면 요청이 약 100배 필요합니다.
세 가지를 모두 잽니다. 경우마다 중앙값(이상치에 강함)과 IQR(사분위 범위: 측정값 가운데 절반의 폭)을 보여 줍니다.
프로세스 안: Python == 와 hmac.compare_digest
첫 스크립트는 앞 k 바이트가 맞고 k 번째 바이트가 틀린 추측값으로 두 비교를 잽니다.
# ~/demo/t_inproc.py — 일치한 앞부분 길이별로 == 와 compare_digest 측정
import hmac, random, statistics, sys, time
random.seed(2026)
L = int(sys.argv[1]) if len(sys.argv) > 1 else 64
secret = bytes(random.choice(b"0123456789abcdef") for _ in range(L))
def guess(k): # 앞 k 바이트는 맞고 k 번째가 틀림
if k >= L: return bytes(bytearray(secret)) # 값은 같고 객체는 다름
return secret[:k] + b"x" + secret[k+1:]
prefixes = sorted({0, L//4, L//2, 3*L//4, L-1, L})
cands = {k: guess(k) for k in prefixes}
INNER, REPS = 2000, 301
fns = {"==": lambda a, b: a == b, "compare_digest": hmac.compare_digest}
res = {(f, k): [] for f in fns for k in prefixes}
order = list(res)
for r in range(REPS):
random.shuffle(order) # 드리프트가 모든 경우에 고르게 걸리도록 섞음
for f, k in order:
fn, g = fns[f], cands[k]
t0 = time.perf_counter_ns()
for _ in range(INNER):
fn(secret, g)
res[(f, k)].append((time.perf_counter_ns() - t0) / INNER)
for (f, k), v in res.items():
q = statistics.quantiles(v, n=4)
print(f"{f:>15} prefix={k:>5}: median {q[1]:7.2f} ns IQR {q[2]-q[0]:5.2f} ns")
첫 실행에는 보여 줄 만한 버그가 있었습니다. "전부 맞는" 추측값을 bytes(secret) 로 만들었더니 그 == 가 0번째 바이트에서 틀린 경우만큼 빨랐습니다. 원인은 bytes 객체에 bytes() 를 부르면 같은 객체가 돌아오고, CPython 의 == 는 양쪽이 같은 객체면 바로 답을 낸다는 것이었습니다. 위처럼 bytearray 를 거쳐 복사하는 게 해결책입니다. 자기 자신과 비교하는 벤치마크는 아무것도 재지 않습니다.
64바이트 비밀값(2,000회 × 301반복) 결과입니다.
| 추측값 | == 중앙값(IQR) |
compare_digest 중앙값(IQR) |
|---|---|---|
| 0바이트 일치 | 84.42 ns (2.57) | 89.50 ns (1.72) |
| 16바이트 일치 | 86.12 ns (1.54) | 89.46 ns (1.99) |
| 32바이트 일치 | 87.08 ns (1.56) | 89.48 ns (1.71) |
| 63바이트 일치 | 87.17 ns (1.74) | 89.48 ns (1.59) |
| 64바이트 전부 일치 | 87.04 ns (1.82) | 89.48 ns (1.84) |
== 는 전체적으로 약 2.8나노초 움직였고, 이는 자기 IQR 과 비슷한 크기입니다. 0~32바이트 사이에 계단이 있고 그 뒤로는 평평합니다. 큰 덩어리로 비교하는 memcmp 와 맞는 모양입니다. compare_digest 는 어느 일치 길이에서나 0.1나노초 안이었습니다. 비밀값을 4,096바이트로 늘리면 누출이 뚜렷합니다. == 는 0바이트 일치 82.98 ns 에서 4,095바이트 일치 205.33 ns 까지 늘었고, compare_digest 는 1,861~1,868 ns 그대로였습니다. 상수 시간의 비용도 보입니다. 4 KiB 에서는 == 보다 약 9배 느리지만, 보통의 64바이트 토큰이라면 2~5나노초 차이입니다.
프로세스 안: PHP ===, hash_equals, 직접 짠 루프
PHP 에는 세 번째 후보를 넣습니다. "제대로 직접 구현하자"며 흔히 쓰는 한 글자씩 비교하는 루프입니다.
<?php
// ~/demo/t_inproc.php(발췌) — 측정 대상 비교
function loop_eq(string $a, string $b): bool { // 소박한 조기 반환 비교
if (strlen($a) !== strlen($b)) return false;
for ($i = 0; $i < strlen($a); $i++) if ($a[$i] !== $b[$i]) return false;
return true;
}
// 측정 방식은 Python 판과 같음: 섞은 301반복 × 2,000회, hrtime(true)
| 추측값(64바이트 비밀값) | === |
hash_equals |
직접 짠 루프 |
|---|---|---|---|
| 0바이트 일치 | 25.94 ns | 67.85 ns | 61.56 ns |
| 16바이트 일치 | 26.29 ns | 67.88 ns | 258.81 ns |
| 32바이트 일치 | 26.85 ns | 67.85 ns | 441.54 ns |
| 48바이트 일치 | 27.21 ns | 67.85 ns | 623.75 ns |
| 63바이트 일치 | 27.23 ns | 67.85 ns | 795.17 ns |
IQR 은 0.15~6나노초였습니다. 동작은 세 가지입니다.
===는 새지만 아주 조금입니다. 0~63바이트 일치에서 1.35나노초.hash_equals는 0.03나노초 안에서 평평합니다.- 직접 짠 루프는 일치 1바이트당 약 11.6나노초 를 흘립니다((795.17 − 61.56) / 63). 선의로 짠, 교과서 그대로의 타이밍 공격 대상입니다.
루프백 HTTP 너머에서도 신호가 남을까
프로세스 안 숫자는 공격자에게 가장 좋은 조건입니다. 실제 공격자에게 보이는 건 HTTP 왕복 전체입니다. 그래서 같은 세 비교를 127.0.0.1 의 php -S 뒤에 두고, 별도 프로세스의 Python 클라이언트에서 쟀습니다. 내 컴퓨터의 내 서버만 썼습니다.
<?php
// ~/demo/router.php — php -S 127.0.0.1:18150 router.php (로컬 데모 전용 서버)
const SECRET = '3f9c2a7e5b1d4c8f0a6e2b9d7c1f5a3e8b0d4f6a2c9e7b1d5f3a0c8e6b4d2f19'; // 데모 값
function loop_eq(string $a, string $b): bool {
if (strlen($a) !== strlen($b)) return false;
for ($i = 0; $i < strlen($a); $i++) if ($a[$i] !== $b[$i]) return false;
return true;
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/check') {
$t = (string)($_GET['t'] ?? '');
$ok = match ($_GET['mode'] ?? '') {
'eq' => $t === SECRET,
'hash' => hash_equals(SECRET, $t),
'loop' => loop_eq(SECRET, $t),
default => false,
};
http_response_code($ok ? 200 : 403);
echo $ok ? "ok\n" : "no\n";
}
# ~/demo/t_http.py — 요청마다 새 연결, 경우를 섞어서 측정
# 사용법: python3 t_http.py 3000 eq,hash,loop 0,32,63
import http.client, random, statistics, sys, time
random.seed(2026)
SECRET = "3f9c2a7e5b1d4c8f0a6e2b9d7c1f5a3e8b0d4f6a2c9e7b1d5f3a0c8e6b4d2f19"
N = int(sys.argv[1])
modes = sys.argv[2].split(",")
ks = [int(k) for k in sys.argv[3].split(",")]
cases = [(m, k) for m in modes for k in ks]
res = {c: [] for c in cases}
order = cases * N
random.shuffle(order)
for mode, k in order:
conn = http.client.HTTPConnection("127.0.0.1", 18150)
t0 = time.perf_counter_ns()
conn.request("GET", f"/check?mode={mode}&t={SECRET[:k]}x{SECRET[k+1:]}")
conn.getresponse().read()
res[(mode, k)].append((time.perf_counter_ns() - t0) / 1000) # 마이크로초
conn.close()
def boot_ci(a, b, B=2000): # median(b) - median(a) 의 95% 신뢰구간
rnd = random.Random(7)
d = sorted(statistics.median(rnd.choices(b, k=len(b))) - statistics.median(rnd.choices(a, k=len(a)))
for _ in range(B))
return d[int(.025 * B)], d[int(.975 * B)]
for m in modes:
for k in ks:
q = statistics.quantiles(res[(m, k)], n=4)
print(f"{m:>5} prefix={k:>2}: median {q[1]:6.1f} us IQR {q[2]-q[0]:5.1f} us")
a, b = res[(m, ks[0])], res[(m, ks[-1])]
lo, hi = boot_ci(a, b)
d = statistics.median(b) - statistics.median(a)
print(f" difference {d*1000:+.0f} ns, 95% CI [{lo*1000:+.0f}, {hi*1000:+.0f}] ns")
python3 t_http.py 3000 eq,hash,loop 0,32,63(27,000 요청, 11.2초) 결과입니다.
| 방식 | 0 일치 | 32 일치 | 63 일치 | 중앙값 차(63 − 0), 95% 신뢰구간 |
|---|---|---|---|---|
=== |
192.6 µs | 192.3 µs | 191.5 µs | −1,146 ns [−1,875, −250] |
hash_equals |
191.7 µs | 192.8 µs | 192.5 µs | +770 ns [−104, +1,459] |
| 직접 짠 루프 | 192.1 µs | 192.7 µs | 192.7 µs | +541 ns [−314, +1,355] |
모든 경우의 IQR 이 약 20마이크로초로, 직접 짠 루프의 신호 전체(730나노초)의 20배가 넘습니다. === 줄을 보세요. 신뢰구간이 0 을 포함하지 않지만 방향이 반대 입니다. 일치가 늘어서 비교가 빨라질 리는 없으니, 잡음과 드리프트가 우연히 줄을 선 것입니다. 세 가지를 동시에 검정하면 가끔 이런 일이 생기는 게 당연합니다. 스크립트를 정리하기 전의 같은 실험에서는 IQR 이 60마이크로초였고 세 구간 모두 0 을 포함했습니다. "1마이크로초 느렸다"는 한 번의 관찰은 어느 방향으로도 아무것도 증명하지 못합니다.
다음으로 직접 짠 루프와 hash_equals 만 0 일치와 63 일치로, 경우마다 2만 요청 쟀습니다. python3 t_http.py 20000 hash,loop 0,63(8만 요청, 44.3초)입니다.
| 방식 | 0 일치 | 63 일치 | 차이, 95% 신뢰구간 |
|---|---|---|---|
hash_equals |
190.3 µs(IQR 14.0) | 190.5 µs(IQR 14.2) | +126 ns [−84, +416] |
| 직접 짠 루프 | 190.3 µs(IQR 14.3) | 191.2 µs(IQR 14.1) | +875 ns [+624, +1,084] |
이제 루프의 누출이 보입니다. 프로세스 안에서 잰 730나노초에 가깝고 구간이 0 을 포함하지 않습니다. 정리 전 실행도 같은 경향으로, 루프 +688 ns [+208, +1,186], hash_equals −166 ns [−625, +292] 였습니다. 솔직하게 정리하면 이렇습니다.
- 루프백에서는 느린 루프의 63바이트 전체 차이를 수만 요청으로 검출할 수 있었습니다.
- 토큰을 복원하려면 1바이트당 차이, 약 11.6나노초가 필요합니다. 2만 요청에서 95% 구간 폭은 약 ±230~±490나노초였습니다. 이를 약 ±6나노초로 좁히려면 (230/6)² ≈ 1,500 ~ (490/6)² ≈ 6,700 배의 표본, 즉 후보 바이트 하나당 수천만~약 10^8 요청이 필요하다는 계산입니다. 외삽이지 측정이 아닙니다.
- PHP
===(토큰 전체에서 1.35나노초)는 전혀 보이지 않았습니다.
그렇다고 === 를 그대로 둬도 된다는 뜻은 아닙니다. 같은 네트워크나 같은 클라우드 호스트에 있는 공격자는 Python 으로 도는 루프백 클라이언트보다 잡음이 적고, 중앙값보다 강한 통계를 쓰고, 우리의 44초보다 훨씬 끈기가 있습니다. 좋은 조건에서는 작은 차이를 노린 원격 타이밍 공격이 성립한 연구도 공개되어 있습니다. 해결책의 비용은 몇 나노초이니 판단은 쉽습니다.
재기 쉬운 누출: 누가 가입되어 있는가
웹 앱의 큰 타이밍 누출은 문자열 비교에 있는 경우가 드뭅니다. 일부 입력에만 무거운 처리를 하는 데서 생깁니다. 대표적인 게 사용자가 있을 때만 password_verify 를 돌리는 로그인입니다. 같은 라우터에 /login 을 붙였습니다.
// ~/demo/router.php 이어서: /check 블록 아래에 덧붙인다
// users.php 는 ['mina' => '$2y$10$…'] 를 반환(bcrypt cost 10)
// 아무도 모르는 랜덤 문자열의 bcrypt 해시. 실제 해시와 같은 cost
const DUMMY_HASH = '$2y$10$KDMs6ijzOIwe1yxm5R15tOxeShXOlOTt33nqyt3WzZ5xQOkehLuLG';
if ($path === '/login') {
$users = require __DIR__ . '/users.php';
$u = (string)($_POST['user'] ?? '');
$p = (string)($_POST['pass'] ?? '');
if (($_GET['fix'] ?? '') === '1') {
$hash = $users[$u] ?? DUMMY_HASH; // 해결: bcrypt 검증을 항상 한 번
$ok = password_verify($p, $hash) && isset($users[$u]);
} else {
$ok = isset($users[$u]) && password_verify($p, $users[$u]); // 버그: 조기 반환
}
http_response_code($ok ? 200 : 401);
echo $ok ? "welcome\n" : "invalid\n";
}
틀린 비밀번호 로그인을 경우마다 300번, 섞은 순서로, 매번 새 연결로 보냈습니다.
buggy user=mina : status [401], median 59.62 ms IQR 0.60 ms
buggy user=nobody: status [401], median 0.47 ms IQR 0.16 ms
fixed user=mina : status [401], median 59.63 ms IQR 0.58 ms
fixed user=nobody: status [401], median 59.64 ms IQR 0.58 ms
상태 코드도 본문도 같은데 시간은 127배 다릅니다. 아이디마다 요청 한 번이면 어떤 계정이 있는지 압니다. 통계도 필요 없습니다. 더미 해시를 쓴 버전은 두 사용자에 대한 응답 차가 0.01밀리초로 IQR 안에 완전히 들어갑니다. 두 번째 실행도 같았습니다(버그 있음 59.61 ms 대 0.45 ms, 수정 후 59.62 ms 대 59.58 ms). 같은 패턴은 비밀번호 찾기("사용자가 있을 때만 메일 발송"), 회원 가입 중복 확인, 조회한 다음 검증하는 API 키에서도 나옵니다. 고치는 생각은 늘 같습니다. 두 경로 모두 같은 무거운 처리를 하고 같은 응답을 돌려줍니다. 횟수 제한(2강 참고)을 더하면 남은 누출을 악용하는 비용도 올라갑니다.
흔한 실수와 실제로 일어나는 일
| 실수 | 실제로 본 것 | 해결 |
|---|---|---|
| 직접 짠 "안전한" 비교 루프 | 일치 1바이트당 11.6나노초. 루프백 너머에서도 검출 | hash_equals / hmac.compare_digest |
비 ASCII str 에 hmac.compare_digest |
'토큰' 으로 TypeError: comparing strings with non-ASCII characters is not supported |
bytes 끼리, 또는 16진 다이제스트끼리 비교 |
빠진 필드의 null 을 hash_equals 에 전달 |
TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, null given |
먼저 타입을 맞춤: (string)($_GET['t'] ?? '') |
hash_equals 인자 순서가 반대 |
결과는 맞지만 사용자 문자열의 길이가 시간에 영향을 줄 수 있음 | 아는 값이 먼저, 사용자 입력이 나중 |
| 올바른 사용자에게만 무거운 처리 | 0.47 ms 대 59.62 ms | 더미 해시, 같은 응답 |
x == x 를 벤치마크 |
같은 객체 지름길 때문에 "전부 일치"가 0바이트 일치만큼 빠름 | 서로 다른 객체끼리 비교 |
| 토큰 값 그대로 DB 조회 | 인덱스 비교는 상수 시간이 아님(설명만, 측정 안 함) | 토큰의 SHA-256 을 저장하고 그걸로 조회 |
마지막 줄은 코드로 보여 줍니다. 저장하는 건 토큰의 해시뿐입니다. 사용자는 토큰을 내밀고, 우리는 그걸 해시해서 해시끼리 비교합니다. 공격자는 SHA-256 출력의 앞부분을 조종할 수 없으니 앞부분 일치 누출은 쓸모가 없고, 16진 다이제스트는 ASCII 라서 compare_digest 가 예외를 낼 일도 없습니다.
import hashlib, hmac, secrets
token = secrets.token_urlsafe(32) # 사용자에게 한 번만 보냄
stored = hashlib.sha256(token.encode()).hexdigest() # DB 에는 이것만 저장
def check(presented: str) -> bool:
h = hashlib.sha256(presented.encode()).hexdigest()
return hmac.compare_digest(h, stored)
print(len(token), check(token), check(token[:-1] + "x"), check("토큰"))
# 43 True False False
hash_equals 와 compare_digest 모두 경우에 따라 비밀값의 길이 는 흘릴 수 있습니다(문서에도 그렇게 적혀 있습니다). 길이가 고정된 토큰이나 다이제스트라면 문제가 되지 않습니다. 해시끼리 비교하는 또 하나의 이유입니다.
그 밖의 부채널(설명만, 실행 안 함)
시간은 여러 채널 중 하나일 뿐입니다. 아래는 재지 않았지만 알아볼 수 있어야 합니다.
- 오류 메시지나 상태 코드의 차이: "없는 아이디입니다"와 "비밀번호가 틀렸습니다", 존재하는 객체에 대한 404 와 403. 통계 없이 샙니다. 메시지를 같게 합니다.
- 응답 크기: 비밀값과 공격자가 조종하는 문자열을 한 페이지에 넣고 압축하면 크기로 비밀값이 드러날 수 있습니다(HTTPS 압축에 대한 CRIME, BREACH 공격의 원리). 전송 쪽 이야기는 HTTPS 와 TLS를 보세요.
- CPU 캐시와 분기 예측: 테이블을 쓰는 AES 구현이나 비밀값에 따라 갈리는 분기는 공유 하드웨어를 통해 샙니다(Spectre 계열, 캐시 타이밍 공격). 직접 만든 AES 대신 검증된 암호 라이브러리를 쓰는 이유 중 하나입니다.
- 비밀 데이터에 따라 달라지는 작업량: 파서의 조기 종료, 백트래킹 양이 달라지는 정규식, 일찍 끝나는 DB 쿼리.
- 횟수 제한과 잠금 동작: 있는 계정에만 걸리는 잠금도 존재 여부의 단서가 됩니다.
어떤 비교를 쓸까
| 비교하는 것 | 쓸 것 | 주의 |
|---|---|---|
| 비밀번호 | password_verify(PHP), 라이브러리의 verify 함수 |
없는 사용자에게는 더미 해시로 실행 |
| HMAC·서명(웹훅, JWT, CSRF) | hash_equals($expected, $given), hmac.compare_digest |
기댓값은 직접 다시 계산하고, 보내온 값을 믿지 않음 |
| DB 의 API 키나 재설정 토큰 | 토큰의 SHA-256 을 저장 키로 | 해시로 조회하고 해시끼리 비교 |
| 짧은 숫자 코드(OTP) | 상수 시간 비교 + 시도 횟수 제한 | 비교보다 횟수 제한이 중요 |
| 비밀이 아닌 데이터 | == / === 로 충분 |
모든 비교를 상수 시간으로 할 필요는 없음 |
시리즈 정리: 체크리스트
여섯 강을 한 페이지로 정리합니다. 신입이 "배포 전에 뭘 확인해야 하나요?"라고 물으면 이 목록입니다. 개인정보보호법이 요구하는 비밀번호 일방향 암호화와 고유식별정보 암호화를 코드 수준에서 지키는 방법이 2번과 5번입니다.
- 인코딩은 암호화가 아니다. Base64 나 16진은 누구나 되돌립니다(1강).
- 비밀번호:
password_hash/password_verify로 bcrypt 나 Argon2id. 빠른 해시는 쓰지 않음. cost 를 바꾸면 재해시. bcrypt 의 72바이트 제한 주의(2강). - 난수: 토큰에는
secrets/random_bytes.random/mt_rand는 쓰지 않고 시각으로 시드하지 않음. 128비트 이상(3강). - 서명 토큰: 알고리즘 고정,
alg: none거부, 32바이트 랜덤 HMAC 키,exp와aud확인(4강). - 암호화: AEAD 만(AES-256-GCM 또는 XChaCha20-Poly1305). 메시지마다 새 랜덤 nonce, 16바이트 태그 확인, 키는 DB 밖에(5강).
- 비교: 비밀값에는
hash_equals/compare_digest. 맞는 사용자와 틀린 사용자에게 같은 처리, 같은 응답(이번 강좌). - 나머지 웹 보안: SQL 인젝션에는 Prepared Statement(PDO 와 SQLite), 출력 이스케이프와 CSRF 토큰(XSS, CSRF, 세션), 쿠키 속성(헤더, 쿠키, 세션), 전 페이지 HTTPS(TLS). 오래된 PHP 코드를 출력을 바꾸지 않고 점검하는 방법은 레거시 PHP 의 SQLi/XSS 점검에 있습니다.
솔직한 한계와 버전 메모
- 측정은 M1 Pro 한 대, Python 3.9.6, PHP 8.5.6,
php -S8.5.6 입니다.php -S는 단일 프로세스이고 매번 연결을 닫았습니다. keep-alive, TLS, 다른 트래픽이 있는 운영 서버는 잡음의 성질이 달라서 공격이 쉬워질 수도 어려워질 수도 있습니다. - 루프백 클라이언트는 Python
http.client이고 그 자체의 흔들림이 있습니다. C 클라이언트나 raw 소켓이면 잡음이 줄겠지만 시험하지 않았습니다. - "바이트당 10^7~10^8 요청"은 2만 요청의 신뢰구간에서, 잡음이 표본 수의 제곱근에 비례해 준다고 가정한 외삽입니다.
- 루프백 잡음은 실행마다 달랐습니다(한 번은 IQR 60마이크로초, 다음에는 20마이크로초). 자기 측정도 믿기 전에 여러 번 돌려 보세요.
hash_equals는 PHP 5.6,hmac.compare_digest는 Python 3.3 부터 있습니다. 실행한 건 위 버전뿐입니다.- 앞 절의 부채널은 설명만 했고 실행하지 않았습니다.
연습 문제
1. 웹훅 수신 코드가 if ($_SERVER['HTTP_X_SIGNATURE'] == hash_hmac('sha256', $body, $secret)) 로 서명을 확인합니다. 이 한 줄의 문제를 모두 들고 다시 써 보세요.
2. 비밀번호 찾기 폼은 "가입된 주소라면 메일을 보냈습니다"라고 표시하지만, 주소가 있으면 300 ms(화면 렌더링과 메일 발송), 없으면 5 ms 가 걸립니다. 공격자는 무엇을 알게 되고, 메시지를 바꾸지 않고 어떻게 고칠까요?
정답
1. (a) == 는 상수 시간이 아니고 느슨한 비교이기도 합니다. PHP 8.5.6 에서도 숫자처럼 보이는 문자열은 숫자로 비교되어 "0e1234" == "0e5678" 이 true 였습니다. 16진 HMAC 이 우연히 0e 와 숫자로만 이루어지면 같은 모양의 다른 문자열과 일치해 버립니다. (b) 헤더가 없으면 null 이 됩니다. (c) 보내온 값이 앞에 있어서 비교가 공격자의 문자열을 따라 진행됩니다. 다시 쓰기: $given = (string)($_SERVER['HTTP_X_SIGNATURE'] ?? ''); $expected = hash_hmac('sha256', $body, $secret); if (!hash_equals($expected, $given)) { http_response_code(401); exit; }. HMAC 은 받은 원본 본문 그대로에 대해 계산합니다.
2. 60배의 시간 차이로 어떤 주소에 계정이 있는지 알게 됩니다. 로그인 데모와 똑같습니다. 해결: 두 경우 모두 바로 응답하고 실제 작업은 백그라운드(큐, 또는 응답을 보낸 뒤 도는 작업)에서 처리합니다. 그러면 요청 시간이 계정 유무에 좌우되지 않습니다. IP 별·주소별 횟수 제한도 더합니다.
다음
- 이전: 보안 기초 강좌 5/6: AES-GCM 제대로 쓰기
- 시리즈 처음: 보안 기초 강좌 1/6: 해시·암호화·인코딩의 차이
- 시리즈의 다른 강좌: 비밀번호 해싱, 안전한 난수와 토큰, JWT HS256 의 함정
- 스크립트에 쓴 Python 기초: Python 의 타입과 가변성
- 모든 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
API 토큰을 == 로 비교하면 정말 위험한가요?
새긴 하지만 얼마나 새는지는 비교 방식에 달렸습니다. PHP 의 === 는 64바이트 토큰에서 일치 0바이트와 63바이트의 차이가 약 1.4나노초뿐이었고, 루프백 HTTP 로는 보이지 않았습니다. 한 글자씩 비교하는 직접 짠 루프는 일치 1바이트당 약 11.6나노초가 샜고, 그 63바이트치 차이는 경우마다 2만 요청으로 루프백 HTTP 너머에서도 검출됐습니다. 그래도 hash_equals 나 hmac.compare_digest 를 씁니다. 비용은 수십 나노초입니다.
웹 앱에서 가장 재기 쉬운 타이밍 누출은 무엇인가요?
일부 입력에만 무거운 처리를 하는 것입니다. 이번 로그인은 없는 사용자 이름이면 일찍 반환해서, bcrypt cost 10 기준 59.62 ms 대 0.47 ms, 127배 차이가 요청 한 번으로 보였습니다. 없는 사용자에게도 더미 해시로 password_verify 를 돌리자 네 경우 모두 약 59.6 ms 가 됐습니다.
hmac.compare_digest 가 토큰에서 TypeError 를 내는 이유는?
str 끼리 비교할 때는 ASCII 만 받기 때문입니다. 비 ASCII 문자가 하나라도 있으면 TypeError: comparing strings with non-ASCII characters is not supported 가 납니다. 양쪽을 UTF-8 bytes 로 바꿔 비교하거나, 항상 ASCII 인 SHA-256 16진 다이제스트끼리 비교합니다.