PHP 보안 실습: XSS·CSRF·세션을 PHP 8.5 에서 직접 공격해 보기
출력 위치별 XSS, 빈 토큰을 통과시킨 CSRF 검사, 실제 Set-Cookie 로 본 세션 고정, bcrypt 와 argon2id 실측, 코드가 실행된 업로드까지.
시리즈: PHP 강좌 5/6 (고급). PHP 로 폼과 PDO 쿼리는 만들 수 있고, 이제 그걸 인터넷에 공개해야 하는 분을 위한 글입니다. 다 읽으면 출력 위치에 맞게 이스케이프하고, 구멍 없는 CSRF 검사를 쓰고, 세션 고정을 막고, 비밀번호 해시 비용을 측정으로 정하고, 남의 코드가 실행되지 않게 업로드를 받을 수 있습니다.
로그인, 댓글창, 파일 업로드 버튼이 있는 PHP 사이트를 맡고 있다면, 이 글은 실제로 악용되는 실수 목록입니다. 사내 보안 점검이나 KISA 시큐어코딩 가이드 체크리스트에서 "XSS 대책 있음"에 체크했는데도 뚫리는 경우는 대부분 '어디에 출력하느냐'를 따지지 않았을 때입니다. 아래 예제는 전부 127.0.0.1 에만 묶은 PHP 내장 서버에 curl 로 직접 공격하고, 응답을 그대로 남겼습니다. 이 강좌용으로 처음 짠 CSRF 검사가 빈 토큰을 통과시킨 것도 숨기지 않고 실었습니다.
실행 환경: PHP 8.5.6 CLI 와 내장 서버(php -S 127.0.0.1:8851), curl 8.7.1, Apple Silicon Mac (M1 Pro), macOS. 예제는 모두 일반적인 예이며 실제 서비스 코드가 아닙니다. 출력의 경로는 ~/demo/ 로 줄였습니다.
DB 쪽은 4 강 PDO·SQLite 와 SQL 인젝션에서 다뤘습니다. 10 년 된 코드에서 실제로 어떻게 고쳤는지는 레거시 PHP 의 SQLi·XSS 를 출력 변경 없이 고친 기록을 참고하세요.
XSS: 같은 입력, 네 가지 출력 위치
XSS(크로스 사이트 스크립팅)는 공격자가 넣은 HTML·JavaScript 가 다른 사용자의 브라우저에서 실행되는 것입니다. 요청에 들어온 값이 바로 되돌아오면 반사형(검색창), 저장됐다가 모두에게 보이면 저장형(댓글)입니다. 대책은 둘 다 같습니다. 출력할 때, 출력 위치에 맞게 이스케이프한다. 파라미터 하나를 다섯 곳에 찍는 페이지입니다.
<?php
// xss.php — one input, five output contexts. Run: php -S 127.0.0.1:8851 -t www
declare(strict_types=1);
$q = $_GET['q'] ?? '';
function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
header('Content-Type: text/html; charset=UTF-8');
?>
<p>1 raw: <?= $q ?></p>
<p>2 escaped: <?= e($q) ?></p>
<a href="<?= e($q) ?>">3 href, escaped</a>
<input value=<?= e($q) ?>>
<script>
var a = "<?= e($q) ?>"; // 5a htmlspecialchars in JS
var b = <?= json_encode($q, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>; // 5b "pretty" flags
var c = <?= json_encode($q, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>; // 5c safe
</script>
q=</script><script>alert(1)</script> 를 보냈을 때의 응답입니다.
<p>1 raw: </script><script>alert(1)</script></p>
<p>2 escaped: </script><script>alert(1)</script></p>
...
var a = "</script><script>alert(1)</script>";
var b = "</script><script>alert(1)</script>";
var c = "<\/script><script>alert(1)<\/script>";
1 번은 고전적인 구멍입니다. 의외인 것은 b 입니다. json_encode 는 기본으로 / 를 \/ 로 바꾸는데, 바로 이것이 </script> 가 블록을 닫지 못하게 막아 주고 있었습니다. 한글이 \uXXXX 로 깨져 보이는 게 싫어서 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES 를 습관처럼 붙이는 코드가 많은데, 그 순간 보호가 사라집니다. HTML 파서는 JavaScript 문자열이든 아니든 처음 만난 </script> 에서 스크립트를 끝냅니다.
htmlspecialchars 가 돌았는데도 소용없는 경우가 두 가지 더 있습니다.
q=javascript:alert(1)
<a href="javascript:alert(1)">3 href, escaped</a>
q=x onfocus=alert(1) autofocus
<input value=x onfocus=alert(1) autofocus>
javascript:alert(1) 에는 이스케이프할 문자가 없으니 링크를 누르면 실행됩니다. URL 은 이스케이프 전에 스킴을 허용 목록(http, https)으로 확인해야 합니다. 따옴표 없는 속성은 공백 하나로 새 속성이 시작되므로 따옴표가 필요 없습니다. 속성은 항상 따옴표로 감싸세요. 그러면 ENT_QUOTES 를 준 htmlspecialchars 로 충분합니다.
a 는 다른 식으로 망가집니다. 입력 Tom "the cat" O'Neil 의 결과입니다.
var a = "Tom "the cat" O'Neil";
var b = "Tom \"the cat\" O'Neil";
var c = "Tom "the cat" O'Neil";
<script> 안에서는 브라우저가 엔티티를 풀지 않습니다. 사용자 이름이 " 라는 글자 그대로 화면에 나옵니다. 이것만이면 데이터 버그지만, var id = <?= e($id) ?>; 처럼 따옴표 없는 자리라면 1;alert(1) 은 특수문자가 하나도 없어서 그대로 실행됩니다. JSON_HEX_* 네 개를 준 json_encode 는 타입이 맞는 JavaScript 리터럴을 만들고 < > & ' " 를 절대 내보내지 않습니다.
잘못된 UTF-8: 두 가지 다른 실패
EUC-KR 로 저장된 옛 데이터처럼 UTF-8 이 아닌 바이트가 섞이면 어떻게 될까요. q=caf%E9(Latin-1 의 é 한 바이트)를 보내자 JSON_THROW_ON_ERROR 쪽이 출력 도중에 죽었습니다.
Fatal error: Uncaught JsonException: Malformed UTF-8 characters, possibly incorrectly encoded in ~/demo/www/xss.php:15
Stack trace:
#0 ~/demo/www/xss.php(15): json_encode('caf\xE9', 4194319)
플래그가 없으면 json_encode 가 false 를 돌려주고 var c = ; 라는 문법 오류가 됩니다. 이 에러 화면이 방문자에게 파일 경로까지 보여 줬다는 점도 보세요. 운영 서버에서는 display_errors 를 꺼야 합니다. htmlspecialchars 플래그에도 함정이 있었습니다.
htmlspecialchars("caf\xe9") → string(6) "caf�" (기본 플래그에 ENT_SUBSTITUTE 포함)
htmlspecialchars("caf\xe9", ENT_QUOTES) → string(0) ""
ENT_QUOTES 만 넘기면 기본 플래그를 대체하므로 ENT_SUBSTITUTE 가 빠지고, 잘못된 입력은 빈 문자열이 됩니다. 항상 ENT_QUOTES | ENT_SUBSTITUTE 를 함께 넘기세요.
댓글 게시판의 저장형 XSS
prepared statement 로 저장하므로 SQL 인젝션은 없는 작은 게시판입니다. ?raw=1 이면 이스케이프를 건너뜁니다.
$ curl -b j1 -d 'body=<script>alert(document.cookie)</script>' '.../board.php?nocsrf=1'
saved
$ curl -b j1 '.../board.php?raw=1'
<li><script>alert(document.cookie)</script></li>
$ curl -b j1 '.../board.php'
<li><script>alert(document.cookie)</script></li>
prepared statement 는 쿼리를 지킬 뿐 페이지는 지키지 않습니다. 저장은 입력한 그대로, 이스케이프는 출력할 때. 저장 전에 이스케이프하면 JSON API 에서 데이터가 망가지고 &lt; 같은 이중 이스케이프가 생깁니다.
CSRF: random_bytes, hash_equals, 그리고 빈 토큰 구멍
CSRF(사이트 간 요청 위조)는 공격자 페이지가 피해자의 브라우저로 하여금 피해자의 쿠키를 단 채 우리 사이트에 요청을 보내게 하는 공격입니다. 보통의 방어는 세션에 비밀 토큰을 두고, 상태를 바꾸는 모든 폼이 그 토큰을 돌려보내게 하는 것입니다.
function csrf_token(): string {
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32)); // 64 hex chars, 256 bits
}
random_bytes 는 OS 의 암호학적 난수를 씁니다. mt_rand, rand, uniqid 는 예측 가능하므로 토큰에 쓰면 안 됩니다.
자주 하는 실수: 아무것도 안 보내도 통과하는 CSRF 검사
실수. 처음 짠 코드입니다.
function csrf_check(): void {
$sent = $_POST['csrf'] ?? '';
if (!is_string($sent) || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
http_response_code(403);
exit("403 bad CSRF token\n");
}
}
토큰 누락, 틀린 토큰, 배열(csrf[]=x)은 403 으로 막았고 올바른 토큰은 200 이었습니다. 실제로 일어난 일: 로그인만 하고 csrf_token() 을 부르는 페이지를 한 번도 열지 않은 피해자입니다.
$ curl -c v1 -b v1 -d user=alice .../login.php
id=9dd562e445a4cb40820d8ec11a1b560f user=alice
$ curl -i -b v1 -d "body=attack&csrf=" .../board.php
HTTP/1.1 200 OK
saved
세션에 토큰이 없으니 $_SESSION['csrf'] ?? '' 는 '', 공격자도 '' 를 보내고, hash_equals('', '') 는 true 입니다. 수정: 저장된 토큰이 없으면 실패로 처리합니다.
function csrf_check(): void {
$expected = $_SESSION['csrf'] ?? null;
$sent = $_POST['csrf'] ?? null;
if (!is_string($expected) || $expected === '' || !is_string($sent)
|| !hash_equals($expected, $sent)) {
http_response_code(403);
exit("403 bad CSRF token\n");
}
}
수정 후 같은 요청은 HTTP/1.1 403 Forbidden 이었습니다. is_string 검사도 필요합니다. 없으면 hash_equals 가 false 대신 예외를 던집니다.
PHP Fatal error: Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, null given
#0 Command line code(1): hash_equals(Object(SensitiveParameterValue), Object(SensitiveParameterValue))
(트레이스의 SensitiveParameterValue 는 비밀값이 로그에 남지 않도록 PHP 가 인수를 가린 것입니다.)
=== 가 아니라 hash_equals 인 이유
=== 는 처음 다른 바이트에서 멈추고 hash_equals 는 항상 끝까지 봅니다. 경우마다 500 만 번 비교한 시간(3 라운드 중 3 번째)입니다.
| 비교 | 4,096 바이트 | 64 바이트 |
|---|---|---|
===, 0 번째 바이트가 다름 |
26.8 ns | 26.5 ns |
===, 마지막 바이트가 다름 |
154.8 ns | 28.1 ns |
hash_equals, 처음 |
2,122.9 ns | 73.1 ns |
hash_equals, 마지막 |
2,113.8 ns | 72.7 ns |
긴 문자열에서는 차이가 뚜렷합니다(27 대 155 ns). 64 자 토큰이면 2 ns 미만이라 네트워크 너머에서 재기는 어렵겠지만, hash_equals 의 비용은 아낄 만한 것이 아닙니다. == 는 절대 안 됩니다. PHP 8.5 에서도 "0e1234" == "0e9999" 는 bool(true) 입니다. 둘 다 지수 표기의 숫자 0 으로 보이기 때문이고, md5("240610708") == md5("QNKCDZO") 도 true 였습니다.
세션: 세션 고정, session_regenerate_id, 쿠키 플래그
세션 고정은 공격자가 피해자의 로그인 전에 세션 ID 를 정해 두고, 로그인 후 그 ID 를 그대로 쓰는 공격입니다. 로그인 스크립트입니다.
<?php
require __DIR__ . '/common.php'; // session_start() lives here
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// pretend the password was checked here
if (($_GET['fix'] ?? '') === '1') {
session_regenerate_id(true); // new id, delete the old session file
}
$_SESSION['user'] = $_POST['user'] ?? 'nobody';
}
echo 'id=', session_id(), ' user=', $_SESSION['user'] ?? '-', "\n";
PHP 기본값(session.use_strict_mode=0)에서:
$ curl -i -b 'PHPSESSID=attacker1234567890' .../login.php
id=attacker1234567890 user=-
$ curl -i -b 'PHPSESSID=attacker1234567890' -d user=alice .../login.php
id=attacker1234567890 user=alice
# 나중에 공격자가 같은 쿠키로:
$ curl -b 'PHPSESSID=attacker1234567890' .../login.php
id=attacker1234567890 user=alice
서버는 자기가 발급한 적 없는 ID 를 받아들였고, Set-Cookie 도 보내지 않았으며, 로그인 후 공격자는 alice 가 됐습니다. ?fix=1 로 session_regenerate_id(true) 를 부르면 새 ID 가 나오고 심어 둔 ID 는 비어 있었습니다.
Set-Cookie: PHPSESSID=cd27da816f4f065991013f1f3357da6e; path=/
id=cd27da816f4f065991013f1f3357da6e user=alice
$ curl -b 'PHPSESSID=attacker2234567890' .../login.php
id=attacker2234567890 user=-
php.ini 를 건드리지 않았을 때의 헤더입니다.
X-Powered-By: PHP/8.5.6
Set-Cookie: PHPSESSID=cbe551da3f65b2298e9aafd66cf69633; path=/
HttpOnly 가 없으니 JavaScript 가 쿠키를 읽을 수 있고(XSS 하나가 곧 계정 탈취), Secure 도 SameSite 도 없고, PHP 버전까지 광고합니다. 설정 다섯 개를 주고 서버를 다시 띄웠습니다.
php -d session.use_strict_mode=1 -d session.cookie_httponly=1 -d session.cookie_secure=1 \
-d session.cookie_samesite=Lax -d expose_php=0 -S 127.0.0.1:8852 -t www
$ curl -i -b 'PHPSESSID=attacker3234567890' .../login.php
Set-Cookie: PHPSESSID=c820dbbc39f17601dd73818788753c12; path=/; secure; HttpOnly; SameSite=Lax
id=c820dbbc39f17601dd73818788753c12 user=-
strict 모드는 심어 둔 ID 를 거부하고 즉시 새 ID 를 발급했고, X-Powered-By 도 사라졌습니다. strict 모드와 로그인·권한 변경 시의 session_regenerate_id(true) 를 둘 다 쓰세요. SameSite=Lax 는 사이트 간 POST 를 대부분 막아 주지만 CSRF 토큰의 대체가 아니라 두 번째 방어선입니다.
테스트 함정 하나. 쿠키에 Secure 가 붙었는데도 curl 은 다음 요청에서 평문 http://127.0.0.1(그리고 http://localhost)로 쿠키를 다시 보냈습니다. curl 도 요즘 브라우저처럼 localhost 를 안전한 출처로 취급합니다. 실제 호스트의 평문 HTTP 에서는 돌아오지 않으니 Secure 동작은 실제 도메인에서 확인하세요.
예전 튜닝 설정을 8.5 에서 써 보면 이렇게 나옵니다.
PHP Deprecated: PHP Startup: session.sid_length INI setting is deprecated in Unknown on line 0
PHP Deprecated: ini_set(): Enabling session.use_trans_sid INI setting is deprecated
기본 ID 는 16 진수 32 자(128 비트)로 조정할 필요가 없고, URL 에 ID 를 싣는 use_trans_sid 는 로그와 Referer 로 새어 나갑니다.
비밀번호: password_hash·password_verify 와 cost 실측
md5, sha1, hash('sha256') 로 비밀번호를 저장하면 안 됩니다. 빠르기 때문인데, 테이블을 훔친 공격자가 원하는 게 바로 그 속도입니다. password_hash 는 솔트와 느린 알고리즘을 고르고, password_verify 는 일정 시간에 비교합니다. 8.5 에서 PASSWORD_DEFAULT 는 cost 12 의 bcrypt 였습니다.
PASSWORD_DEFAULT: $2y$12$pSB6GmFI8jk.DSQgPsxnmezkJ5yMuJWMIolLGojMotf9ecXex0.KO
argon2id available: yes
bcrypt cost 10 hash median 58.7 ms (min 58.6, max 60.3) verify 58.8 ms
bcrypt cost 12 hash median 235.3 ms (min 234.1, max 246.3) verify 241.4 ms
argon2id default hash median 156.0 ms (min 151.9, max 167.7) verify 156.3 ms $argon2id$v=19$m=65536,t=4,p=1
각 9 회 중앙값입니다. bcrypt 는 cost 가 1 오를 때마다 2 배(+2 로 58.7 → 235.3ms). argon2id 는 메모리도 씁니다. 해시 한 번에 프로세스 최대 상주 메모리가 25.5MB 에서 89.6MB 로 늘었고, 이는 m=65536 의 64MiB 와 맞습니다. libargon2 가 PHP 할당자 밖에서 잡기 때문에 memory_get_peak_usage() 에는 보이지 않습니다. 작은 공유 호스팅에서 동시 로그인 20 건이면 1GB 를 넘습니다.
8.5 에서 확인한 bcrypt 의 한계 두 가지. NUL 바이트가 든 비밀번호는 ValueError: Bcrypt password must not contain null character 를 던지고, 앞의 72 바이트만 쓰입니다. a 72 개로 만든 해시에 str_repeat('a', 72) . 'TOTALLY DIFFERENT' 를 넣어도 password_verify 는 true 였습니다. 73 바이트 비밀번호에도 오류는 없었습니다. 아주 긴 패스프레이즈를 허용한다면 argon2id 에는 이런 제한이 없습니다.
고르는 법: 운영 하드웨어에서 로그인 1 회 100~300ms 정도를 목표로 하고, 예전 해시는 password_needs_rehash() 로 로그인할 때 올립니다(연습문제 2).
파일 업로드: 브라우저가 알려 주는 정보는 거짓말이다
$_FILES 에서 tmp_name 과 size 말고는 전부 클라이언트가 보낸 값입니다. type 과 name 을 믿는 엔드포인트입니다.
<?php
// upload_bad.php — DO NOT COPY. Trusts the browser's name and type.
$f = $_FILES['f'] ?? null;
if (!$f || $f['error'] !== UPLOAD_ERR_OK) { http_response_code(400); exit('error code ' . ($f['error'] ?? 'none') . "\n"); }
echo "client says type=", $f['type'], " name=", $f['name'], "\n";
if (!str_starts_with($f['type'], 'image/')) { http_response_code(415); exit("images only\n"); }
move_uploaded_file($f['tmp_name'], __DIR__ . '/uploads/' . $f['name']); // name chosen by the client!
echo "stored at /uploads/", $f['name'], "\n";
PHP_OS_FAMILY 를 출력하는 한 줄짜리 hello.php 를 curl 로 PNG 라고 속여 보냅니다.
$ curl -F 'f=@hello.php;type=image/png' .../upload_bad.php
client says type=image/png name=hello.php
stored at /uploads/hello.php
$ curl .../uploads/hello.php
this ran on the server: Darwin
무해한 내용일 뿐, 원격 코드 실행입니다. PHP 가 이미 해 주는 것도 있습니다. name 에서 디렉터리는 떼어 냅니다(filename=../escaped.php 는 escaped.php 로 도착). 하지만 원래 경로는 $_FILES['f']['full_path'] 에 "../../etc/escaped.php" 로 남아 있으니 이 키를 경로에 쓰면 안 됩니다. 바이트를 읽는 finfo 도 필요조건일 뿐입니다. 진짜 1×1 PNG 뒤에 PHP 한 줄을 붙인 파일은 image/png 로 판정됐고, 나쁜 엔드포인트가 poly.php 로 저장하자 그대로 실행됐습니다.
모든 테스트를 통과한 버전은 이름·확장자·위치를 서버가 정합니다.
<?php
// upload_good.php — decide everything on the server.
declare(strict_types=1);
const ALLOWED = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp'];
$f = $_FILES['f'] ?? null;
if (!is_array($f) || !is_int($f['error']) || $f['error'] !== UPLOAD_ERR_OK) {
http_response_code(400); exit('upload error ' . ($f['error'] ?? 'none') . "\n");
}
if ($f['size'] > 1_000_000) { http_response_code(413); exit("too large\n"); }
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']); // look at the bytes
if (!isset(ALLOWED[$mime])) { http_response_code(415); exit("rejected: content is $mime\n"); }
$name = bin2hex(random_bytes(16)) . '.' . ALLOWED[$mime]; // our name, our extension
$dir = dirname(__DIR__) . '/safe_uploads'; // outside the document root
if (!move_uploaded_file($f['tmp_name'], "$dir/$name")) { http_response_code(500); exit("move failed\n"); }
echo "stored as $name ($mime)\n";
hello.php 를 cat.png 로 전송 → rejected: content is text/x-php
dot.png 를 x.php 로 전송 → stored as 730012bfe318b504c45210f535042d0b.png (image/png)
300KB 파일 (제한 200K) → upload error 1 (UPLOAD_ERR_INI_SIZE)
1.2MB 파일 (post_max_size 1M) → HTTP/1.1 400, upload error none
마지막 줄이 자주 헷갈리는 부분입니다. post_max_size 를 넘으면 PHP 가 본문 전체를 버려서 $_FILES 도 $_POST 도 비고, 로그에는 PHP Warning: PHP Request Startup: POST Content-Length of 1200209 bytes exceeds the limit of 1048576 bytes 만 남습니다. "아무것도 안 왔다"를 별도 오류로 처리하세요. 폴리글롯 PNG 는 문서 루트 밖에 .png 로 저장되니 웹 서버가 실행할 일이 없습니다.
보안 헤더와 "headers already sent" 함정
헤더는 본문보다 먼저 나가야 합니다.
$nonce = base64_encode(random_bytes(16));
header_remove('X-Powered-By');
header("Content-Security-Policy: default-src 'self'; script-src 'nonce-$nonce'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
if (($_SERVER['HTTPS'] ?? '') === 'on') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); // only over HTTPS
}
curl 로 보면 요청마다 새 nonce 가 붙은 CSP 가 나오고 X-Powered-By 는 없습니다. nonce 방식 CSP 에서는 <script nonce="..."> 만 실행되므로, 어딘가에서 이스케이프를 빠뜨려도 주입된 <script> 가 막힙니다. 다만 차단 자체는 브라우저가 하는 일이라 curl 만 쓴 이번 실습에서는 확인하지 않았습니다. frame-ancestors 'none' 은 X-Frame-Options: DENY 를 대신하고, HSTS 는 HTTPS 에서만 보내야 합니다.
<?php 앞에 공백 하나가 있고 그 뒤에 header(...) 를 부르는 파일은 내장 서버에서 멀쩡히 동작했습니다. 거기서 ini_get('output_buffering') 은 "4096" 이었고, 공백은 4KB 버퍼에 들어가 있었던 것입니다. flush() 를 넣자 진짜 경고가 나왔습니다.
Warning: Cannot modify header information - headers already sent in ~/demo/www/late2.php on line 4
로컬에선 되고 output_buffering=0 인 호스트나 헤더 전에 4KB 넘게 출력하는 페이지에서는 터지는 코드입니다. PHP 만 있는 파일은 닫는 ?> 를 생략하고, 파일 맨 앞의 BOM 도 조심하세요.
판단표
| 상황 | 할 일 | 위에서 본 근거 |
|---|---|---|
| HTML 본문·따옴표 속성 | htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') |
<img …> 가 글자로 출력 |
href·src |
http/https 만 허용한 뒤 이스케이프 |
javascript: 가 통과 |
<script> 안 |
JSON_HEX_* 네 개를 준 json_encode |
JSON_UNESCAPED_SLASHES 로 탈출 |
| 상태를 바꾸는 POST | 세션 토큰 + hash_equals + 빈 값 거부 |
빈 토큰이 200 |
| 로그인 | use_strict_mode=1 + session_regenerate_id(true) |
심은 ID 가 alice 로 |
| 세션 쿠키 | HttpOnly; Secure; SameSite=Lax |
기본값엔 셋 다 없음 |
| 비밀번호 | PASSWORD_DEFAULT + password_needs_rehash |
cost 12 에 235ms |
| 업로드 | finfo + 허용 목록 + 무작위 이름 + 문서 루트 밖 |
.php 업로드가 실행 |
솔직한 한계와 버전 메모
- 모든 실행은 PHP 8.5.6 에서만 했습니다. 다음 버전 정보는 PHP 변경 기록에 따른 것이며 옛 버전을 직접 돌려 확인하지는 않았습니다.
htmlspecialchars기본 플래그가ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401이 된 것은 8.1(그 전에는 작은따옴표를 기본으로 이스케이프하지 않음),$_FILES[...]['full_path']는 8.1, 트레이스의SensitiveParameterValue는 8.2, bcrypt 기본 cost 가 10 에서 12 로 오르고session.sid_length·use_trans_sid가 폐지 예정이 된 것은 8.4 입니다. - 내장 서버는 단일 스레드 개발용입니다. 어느 디렉터리에서
.php를 실행할지는 Apache, nginx + PHP-FPM, LiteSpeed 가 정합니다. 그래서 업로드 검사보다 "문서 루트 밖"이 더 중요합니다. - XSS 는 curl 이 받은 HTML 을 읽어서 확인했고 브라우저로 실행하지는 않았습니다. CSP 차단과
SameSite동작은 설명만 했습니다. - 비교 시간 표는 한 프로세스 안의 측정입니다. 원격 공격자가 네트워크 너머로 2ns 차이를 잴 수 있는지는 보여 주지 않습니다. 요점은
hash_equals를 쓰면 그 질문 자체가 사라진다는 것입니다.
연습문제
1. 사용자 이름으로 클릭을 기록하는 버튼입니다. htmlspecialchars 도 적용했습니다. 이름이 x');alert(1);// 일 때 왜 뚫리는지, 어떻게 고치는지 답해 보세요.
<button onclick="track('<?= e($name) ?>')">Like</button>
정답
브라우저는 JavaScript 를 실행하기 전에 속성 안의 엔티티를 풉니다. 출력은 track('x');alert(1);//') 였고, 엔티티를 풀면 JS 엔진에는 track('x');alert(1);//') 가 보입니다. JavaScript 용으로 먼저, 그다음 HTML 속성용으로 인코딩합니다.
<button onclick="track(<?= e(json_encode($name, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR)) ?>)">Like</button>
출력은 track("x');alert(1);\/\/") 이고, 브라우저가 풀면 문자열 인수 하나가 됩니다. 더 좋은 방법은 값을 data- 속성에 넣고 핸들러는 스크립트 파일에서 붙이는 것입니다.
2. 몇 년 전 bcrypt cost 10 으로 만든 해시들을, 비밀번호 재설정 없이 현재 기본값으로 올려 보세요.
정답
function login(string $password, string &$storedHash): bool {
if (!password_verify($password, $storedHash)) return false;
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$storedHash = password_hash($password, PASSWORD_DEFAULT); // save this to the DB
}
return true;
}
cost 10 해시로 실행하면 $2y$10$ -> true -> $2y$12$ 가 출력됐습니다. 평문 비밀번호는 로그인 순간에만 있으므로 그때가 재해시할 수 있는 유일한 시점입니다.
자주 묻는 질문
<script> 안에 넣는 값도 htmlspecialchars 면 충분한가요?
아닙니다. <script> 안에서는 브라우저가 엔티티를 풀지 않아서 따옴표가 " 라는 글자 그대로 들어가고, 따옴표 없는 숫자 자리라면 특수문자 없이도 코드가 실행됩니다. JSON_HEX_TAG·JSON_HEX_AMP·JSON_HEX_APOS·JSON_HEX_QUOT 를 준 json_encode 를 쓰세요. 보기 좋게 하려고 JSON_UNESCAPED_SLASHES 를 붙였더니 </script> 로 탈출이 됐습니다.
hash_equals 로 만든 CSRF 검사가 빈 토큰을 통과시키는 이유는?
세션에 토큰이 한 번도 저장되지 않았다면 $_SESSION['csrf'] ?? '' 가 빈 문자열이 되고, hash_equals('', '') 는 true 입니다. 로그인만 하고 폼 페이지를 한 번도 열지 않은 사용자가 실제로 공격당했습니다. 저장된 토큰이 없거나 비어 있으면 거부해야 합니다.
PHP 8.5 에서 password_hash 는 얼마나 걸리나요?
M1 Pro 에서 9 회 중앙값으로 bcrypt cost 10 은 58.7ms, PHP 8.4 부터 기본값인 cost 12 는 235.3ms, 기본 설정(64MiB, 4 패스) argon2id 는 156.0ms 였습니다. password_verify 도 해시와 같은 시간이 걸립니다.