금요일 밤 11시, 작은 쇼핑몰을 운영하는 개발자에게 전화가 왔다고 해보자. 관리자 페이지에 접속했더니 상품 목록이 텅 비어 있었다. 서버는 멀쩡했고, 디스크도 넉넉했고, 에러 로그에도 이상이 없었다. 다만 접속 로그에 이런 요청이 하루 종일 찍혀 있었다.
GET /product.php?id=12%20OR%201=1--숫자 하나가 들어와야 할 자리에 문장이 들어왔고, 그 문장이 그대로 데이터베이스로 전달됐다. 서버가 뚫린 게 아니라 코드 한 줄이 문을 열어둔 것이다. 웹 보안 사고의 상당수는 정교한 해킹 기술이 아니라, 이런 평범한 실수에서 시작된다.
1. SQL 인젝션 — 문자열을 붙이는 순간 문이 열린다
가장 흔하고, 가장 피해가 큰 취약점이다. 원인은 단순하다. 사용자가 보낸 값을 SQL 문장에 그대로 이어 붙였기 때문이다.
// 위험한 코드
$id = $_GET['id'];
$sql = "SELECT * FROM products WHERE id = " . $id;
$rows = $conn->query($sql);id에 12가 들어오면 정상이지만, 12 OR 1=1--이 들어오면 조건이 항상 참이 되어 테이블 전체가 열린다. 더 나쁜 경우 ; DROP TABLE ... 같은 구문이 붙을 수도 있다. 값과 명령어가 같은 문자열 안에서 섞이는 순간, 데이터베이스는 둘을 구분할 방법이 없다.
해결책은 값과 명령어를 애초에 분리하는 것, 즉 프리페어드 스테이트먼트(prepared statement)다.
// 안전한 코드
$stmt = $pdo->prepare("SELECT * FROM products WHERE id = ? AND status = ?");
$stmt->execute([$_GET['id'], 'sale']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);? 자리에 들어가는 값은 SQL 문법으로 해석되지 않고 순수한 데이터로만 취급된다. 값에 따옴표가 들어 있든 세미콜론이 들어 있든 상관없다.
이스케이프 함수로 막는 것보다, 값이 SQL 문법으로 해석될 기회 자체를 없애는 편이 훨씬 안전하다.
다만 주의할 곳이 하나 있다. 테이블명·컬럼명·ORDER BY 방향은 바인딩이 되지 않는다. 정렬 기준을 사용자가 고르게 하는 화면이라면 이렇게 처리한다.
$allowed = ['price', 'created_at', 'name'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
$dir = ($_GET['dir'] ?? '') === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT * FROM products ORDER BY {$sort} {$dir} LIMIT 20";핵심은 허용 목록(allowlist)이다. 위험한 문자를 걸러내는 방식은 늘 빠뜨리는 게 생기지만, 허용할 값을 나열하는 방식은 새로운 공격 문자열이 나와도 뚫리지 않는다.
2. XSS — 저장할 때가 아니라 보여줄 때 막는다
두 번째는 크로스 사이트 스크립팅이다. 게시판 본문에 스크립트 태그를 넣어두면, 그 글을 읽는 다른 사람의 브라우저에서 그 코드가 실행된다. 로그인 쿠키를 훔치거나, 방문자를 다른 사이트로 넘기는 식으로 악용된다.
여기서 자주 하는 실수가 있다. 입력 시점에만 필터링하고 안심하는 것이다. 저장할 때 태그를 지워버리면 원본 데이터가 훼손되고, 관리자 화면·API·메일 발송 등 다른 출력 경로에서 그 필터를 거치지 않는 순간 구멍이 생긴다.
원칙은 하나다. 데이터는 원본 그대로 저장하고, 화면에 뿌릴 때 이스케이프한다.
// 출력 시점에 반드시
echo htmlspecialchars($row['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');ENT_QUOTES를 빼먹으면 작은따옴표가 그대로 남아 속성값 안에서 탈출이 가능해진다. 옵션까지 챙기는 습관을 들이는 게 좋다.
맥락에 따라 방어 방법도 달라진다.
| 출력 위치 | 처리 방법 |
|---|---|
| HTML 본문 | htmlspecialchars() |
속성값 value="..." | htmlspecialchars() + 반드시 따옴표로 감싸기 |
| URL 파라미터 | urlencode() |
| 자바스크립트 변수 | json_encode() 로 넘기기 |
에디터로 작성한 HTML을 그대로 살려야 하는 경우라면 이스케이프가 답이 아니다. 이때는 HTMLPurifier처럼 검증된 정화 라이브러리로 허용 태그와 허용 속성만 남기고 나머지를 제거해야 한다. 정규표현식으로 직접 <script>를 지우려는 시도는 거의 예외 없이 우회된다.
3. CSRF — 로그인된 상태를 남이 대신 쓴다
세 번째는 이름이 낯설어서 넘기기 쉬운 취약점이다. 사용자가 우리 사이트에 로그인한 상태로 다른 사이트를 방문했는데, 그 페이지에 이런 폼이 숨어 있다고 해보자.
<form action="https://example.com/mypage/withdraw.php" method="post">
<input type="hidden" name="confirm" value="yes">
</form>
<script>document.forms[0].submit();</script>브라우저는 쿠키를 자동으로 실어 보내기 때문에, 서버 입장에서는 정상 로그인 사용자의 요청과 구분되지 않는다. 방어법은 그 요청이 우리 화면에서 시작됐다는 증거를 요구하는 것이다.
// 폼을 그릴 때
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
echo '<input type="hidden" name="csrf" value="' . $_SESSION['csrf'] . '">';
// 처리할 때
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('잘못된 요청입니다.');
}비교에 == 대신 hash_equals()를 쓰는 이유는 비교에 걸리는 시간 차이로 값을 추측당하지 않기 위해서다. 여기에 쿠키 설정으로 한 겹 더 얹으면 좋다.
session_set_cookie_params([
'httponly' => true, // 자바스크립트에서 쿠키 접근 차단
'secure' => true, // HTTPS에서만 전송
'samesite' => 'Lax', // 외부 사이트발 요청에 쿠키 미전송
]);그리고 기본 중의 기본. 데이터를 바꾸는 동작은 GET으로 만들지 않는다. 삭제 링크를 <a href="delete.php?id=5">로 만들어두면 검색 엔진 크롤러가 지나가다 데이터를 지우는 황당한 일도 실제로 벌어진다.
4. 업로드 폴더와 권한 — 가장 자주 잊는 뒷문
세 가지 취약점을 다 막아도, 파일 업로드에서 무너지는 경우가 많다. 확장자만 검사하면 photo.php.jpg나 대소문자 변형으로 우회당한다. 최소한 이 정도는 지켜야 한다.
- 확장자는 허용 목록으로 검사하고,
mime_content_type()으로 실제 형식도 함께 확인한다 - 원본 파일명을 쓰지 말고 서버에서 새 이름을 만들어 저장한다 (
uniqid()+ 검증된 확장자) - 업로드 폴더는 웹 루트 바깥에 두고, 다운로드는 PHP 스크립트를 거치게 한다
- 부득이 웹 루트 안에 둔다면 해당 디렉터리의 PHP 실행을 서버 설정에서 막는다
- 디렉터리 권한은
755, 파일은644로 충분하다.777은 어떤 상황에서도 정답이 아니다
여기에 하나 더. 운영 서버에서는 display_errors를 반드시 꺼야 한다. 에러 메시지에 담긴 파일 경로와 쿼리문은 공격자에게 훌륭한 지도가 된다.
display_errors = Off
log_errors = On
error_log = /var/log/php/error.log5. 오늘 30분이면 할 수 있는 점검
거창한 보안 감사를 하라는 게 아니다. 지금 프로젝트 폴더에서 이 정도만 확인해도 위험의 상당 부분이 걷힌다.
# 1) 문자열로 조립한 쿼리가 남아 있는지
grep -rn "query(\"SELECT.*\$" --include="*.php" .
# 2) 이스케이프 없이 출력하는 곳이 있는지
grep -rn "echo \$_\(GET\|POST\)" --include="*.php" .
# 3) POST 처리에 csrf 검증이 있는지
grep -rLn "csrf" --include="*.php" ./admin결과가 나오면 목록을 만들어 접근 빈도가 높은 화면부터 고친다. 로그인·검색·게시판 쓰기처럼 외부에서 값이 들어오는 입구가 우선순위다. 한 번에 전부 뜯어고치려다 손도 못 대는 것보다, 입구 다섯 개를 확실히 막는 편이 훨씬 낫다.
보안은 완벽을 목표로 하는 작업이 아니다. 공격자가 들이는 비용을 계속 올려서, 결국 다른 곳으로 가게 만드는 일이다.
마무리
정리하면 세 가지다. SQL은 값을 바인딩해서 넘기고, HTML은 출력 시점에 이스케이프하고, 상태를 바꾸는 요청에는 토큰을 요구한다. 여기에 업로드 폴더 권한과 에러 노출만 챙기면, 흔한 사고의 대부분은 막힌다.
이 글의 코드는 PHP·MySQL 기준으로 썼지만 원리는 언어를 가리지 않는다. 값과 명령어를 섞지 않는 것, 신뢰할 수 없는 입력은 목적지 형식에 맞게 변환하는 것, 그리고 요청의 출처를 확인하는 것. 프레임워크가 달라져도 이 세 문장은 그대로다.
오늘 당장 전부 고칠 필요는 없다. 다만 다음에 새 화면을 만들 때, 쿼리에 문자열을 이어 붙이려는 손을 한 번만 멈춰보자. 그 한 번의 멈춤이 언젠가의 금요일 밤 11시를 지켜줄 것이다.

