오래된 PHP의 SQL 인젝션·XSS를 출력 1바이트도 안 바꾸고 고치기
10년 된 앱 백엔드 PHP 36개 파일, 90곳을 고치고 저장해 둔 실제 응답과 바이트 비교로 무변경을 확인했다. 스캐너가 놓친 두 패턴과 WAF가 있는 환경에서 검증하는 법.
앱이 아직 호출하는 오래된 PHP 백엔드에서 SQL 인젝션과 XSS를 고쳐야 하는데, 응답이 1바이트만 바뀌어도 업데이트를 강제할 수 없는 구버전 앱이 깨질 수 있는 상황을 위한 글입니다. 결론부터 말하면, SQL을 조립하는 줄과 출력하는 줄에서만 그 자리에서 이스케이프하고 query()는 그대로 두었습니다. 그리고 패치를 눈으로 읽는 대신 저장해 둔 실제 응답과 비교해서 "바뀌지 않았다"는 것을 증명했고, 이 비교 덕분에 소스 리뷰가 놓친 구멍 두 개를 찾았습니다.
대상은 공유 호스팅(Hostinger, LiteSpeed + 호스트 CDN)에 있는 PHP입니다. 모바일 앱 8개가 쓰는 JSON API와 WebView 시절의 PHP 페이지가 있습니다. 이미 설치된 구버전 앱이 지금도 WebView로 그 페이지를 열기 때문에 지울 수 없습니다. 아래 수치는 모두 2026년 11월 9일 작업에서 직접 잰 값입니다.
점검해 보니
| 구분 | 파일 수 | 비고 |
|---|---|---|
| 운영 중인 JSON API | 12 | 현재 버전과 구버전 앱이 호출 |
| WebView 시절 PHP 페이지 | 20 | 구버전 앱이 아직 염 |
| 공통 include | 4 | 내비게이션, 공통 유틸 클래스 등 |
| 쓰이지 않는 폴더 | 7 | 어느 앱도 호출하지 않음 |
가장 위험한 건 마지막 줄이었습니다. 이 7개 폴더 안에는 비밀번호를 평문으로 비교하는 REST 형태의 로그인 API와, 행을 넣고 지울 수 있는 테스트 스크립트가 있었습니다. 아무 데서도 참조하지 않지만 인터넷에서는 그대로 접근할 수 있는 상태였습니다. 삭제하지 않고 웹 루트 밖으로 옮겼고, 지금은 404가 납니다. 혹시 무언가가 이 파일에 기대고 있었다는 게 드러나도 몇 초면 되돌릴 수 있습니다.
스캐너가 놓친 두 가지 형태
먼저 정규식으로 만든 짧은 파이썬 스캐너로 후보를 뽑았습니다.
- HIGH: SQL이 들어 있는 줄에
$_GET/$_POST/$_REQUEST가 직접 있음 - MED: 입력값을 정제 없이 대입한 변수가 나중에 SQL 문자열에 들어감
"SQL이 들어 있는 줄"은 SELECT, INSERT INTO, UPDATE … SET, DELETE FROM, WHERE, ORDER BY, LIMIT, VALUES, LIKE 키워드와 ->query( 호출로 판단했습니다. 대부분은 이걸로 잡혔지만 두 종류가 빠져나갔습니다.
놓친 것 1: 이어 붙이는 줄. 오래된 코드는 쿼리를 한 줄씩 붙여 나갑니다.
$Query = "SELECT … FROM … WHERE col1 = '".$_GET['k1']."'";
$Query .= " AND col2 = '".$_GET['k2']."'";
첫 줄은 잡혔지만 둘째 줄은 잡히지 않았습니다. 키워드 목록에 AND가 없었기 때문입니다. 스캐너는 이 줄을 "입력값 대입"으로만 보고 $Query를 오염 변수로 표시한 뒤, 줄 자체는 보고하지 않았습니다.
놓친 것 2: 배열을 거친 오염. 검색 페이지에 이런 코드가 있었습니다.
$arr[0] = $_GET['keyword1'];
$arr[1] = $_GET['keyword2'];
sort($arr);
$kw = implode(' ', $arr);
…
$Query .= " AND name LIKE '%".$kw."%'";
$Query .= " OR code = '".$kw."'";
스캐너는 $x = … 같은 일반 변수 대입만 추적했고, $arr[0] = … 같은 배열 원소 대입은 추적하지 않았습니다. 입력값이 배열에 들어가 정렬되고, 문자열로 합쳐진 다음 LIKE '%…%'와 = '…' 양쪽에 쓰이는 흐름을 끝까지 보지 못한 것입니다.
이 두 형태를 알고 나서 손으로 추적하니, 배열 패턴이 있는 파일이 3개 더 나왔습니다. 더 고약한 것도 있었습니다. 공통 유틸 클래스가 $_GET을 전역 변수에 복사하고, 다른 파일이 그 전역 변수를 SQL에 쓰고 있었습니다. 파일 단위 스캐너로는 찾을 수 없고, include 연결을 따라가야만 보입니다.
정규식 스캐너가 주는 건 점검을 시작할 목록이지, 안전하다는 증명이 아닙니다.
수정: 프리페어드 스테이트먼트 대신 그 자리에서 이스케이프
새 코드라면 프리페어드 스테이트먼트가 정답입니다. 하지만 여기서는 API 출력이 바뀝니다. mysqlnd 환경에서 프리페어드 스테이트먼트는 정수·실수 컬럼을 PHP 숫자로 돌려주므로 "7"이 7이 되고, 문자열을 기대하는 구버전 앱은 여기서 죽을 수 있습니다. 실측은 프리페어드 스테이트먼트와 JSON 타입 글에 정리했습니다.
그래서 $Query나 $Sub_Query를 조립하는 줄에서만 값을 이스케이프하고, 나머지는 건드리지 않았습니다.
// 수정 전
$Query .= " AND col2 = '".$_GET['k2']."'";
// 수정 후
$Query .= " AND col2 = '".$Conn->real_escape_string((string)($_GET['k2'] ?? ''))."'";
(string)은 k2[]=x 같은 배열 입력 대비입니다. 이게 없으면 PHP 8에서는 TypeError가 납니다. ?? ''는 파라미터가 없을 때 옛 코드가 사실상 쓰던 빈 문자열을 유지하기 위한 것입니다.
실제 트래픽에서 출력이 바이트 단위로 같을 거라고 볼 수 있었던 이유는 이렇습니다. real_escape_string이 건드리는 문자는 NUL, \n, \r, \, ', ", Ctrl-Z 7개뿐입니다. UTF-8 멀티바이트 문자는 모든 바이트가 0x80 이상이므로, 앱이 실제로 보내는 한글·일본어·숫자는 그대로 지나갑니다. SQL 문자열이 같으면 결과도, JSON도 같습니다.
단, 이 논리는 set_charset('utf8mb4')로 연결 문자셋을 UTF-8로 맞췄을 때만 성립합니다. 국내 레거시에 많은 EUC-KR/CP949는 두 번째 바이트가 0x5C(백슬래시)가 되지 않지만, Shift_JIS나 GBK에서는 0x5C가 되는 문자가 있어서, 문자셋이 어긋난 채로 이스케이프하면 따옴표 밖으로 빠져나가는 공격이 가능해집니다. 일본·중국 서비스와 DB를 공유한다면 꼭 확인해야 합니다.
| 대상 | SQL 수정 | 출력 수정 | 파일 수 |
|---|---|---|---|
| API | 13곳 | – | 12 |
| 페이지 (1차 20개 페이지, 2차 공통 include 4개) | 64줄 | 13곳 | 24 |
XSS: 출력되는 자리에 따라 이스케이프가 다르다
페이지들은 요청 파라미터를 그대로 HTML에 다시 써 주고 있었습니다. 값이 어디에 들어가느냐에 따라 고치는 방법이 다릅니다.
| 출력 위치 | 처리 |
|---|---|
HTML 본문(<h2>, <h3>) |
htmlspecialchars($v, ENT_QUOTES, 'UTF-8') |
속성값(value="…") |
위와 같음. 작은따옴표 속성이 있으니 ENT_QUOTES 필수 |
공통 클래스 안에서 조립하는 <title> |
호출하는 쪽이 아니라 제목 문자열을 만드는 곳에서 이스케이프 |
onclick 속 JS 문자열 속 URL |
rawurlencode 먼저, 그다음 htmlspecialchars |
마지막 줄은 설명이 필요합니다.
<a onclick="location.href='?d=<?= $d ?>'"> // 취약
<a onclick="location.href='?d=<?= htmlspecialchars($d, ENT_QUOTES, 'UTF-8') ?>'"> // 여전히 취약
<a onclick="location.href='?d=<?= htmlspecialchars(rawurlencode($d), ENT_QUOTES, 'UTF-8') ?>'"> // OK
htmlspecialchars는 '를 '로 바꿉니다. 그런데 브라우저는 속성값의 엔티티를 먼저 디코딩하고 나서 자바스크립트 엔진에 넘기기 때문에, JS 입장에서는 진짜 '가 되어 문자열이 거기서 끝납니다. rawurlencode를 쓰면 '가 %27이 되어 HTML에도 JS에도 아무 의미가 없습니다. 바깥의 htmlspecialchars는 결과를 바꾸지 않지만, "속성에는 무조건 이스케이프"라는 규칙을 단순하게 지키려고 붙였습니다. 이 자리에 실제로 들어가는 값은 20251228 같은 8자리 날짜였고, rawurlencode를 거쳐도 그대로라 출력은 변하지 않았습니다.
출력이 바뀌지 않았다는 증명
패치 전에, 앱에서 가져온 실제 파라미터로 27개 페이지를 호출해 응답을 저장했습니다. 모든 요청에는 CDN 캐시가 대신 답하지 못하도록 고유한 캐시 우회 쿼리를 붙였습니다(CDN 캐시에 속은 적이 있습니다).
배포 후 결과는 이랬습니다.
- 24개 페이지: 바이트 단위로 동일
- 3개 페이지: 차이 있음. 무작위 예상 번호를 보여 주는 페이지라 원래 요청할 때마다 달라집니다.
이 3개를 "아마 랜덤 부분이겠지"로 넘기지 않았습니다. 패치 없는 상태에서 연속으로 두 번 받아, 달라지는 줄 번호의 집합을 기록했습니다. 그리고 기준 응답과 패치 후 응답 사이에서 달라진 줄의 집합과 비교했습니다.
# 비교 스크립트에서 발췌
def diff_lines(a: str, b: str) -> set[int]:
return {i for i, (x, y) in enumerate(zip(a.splitlines(), b.splitlines())) if x != y}
noise = diff_lines(fresh1, fresh2) # 랜덤 줄만
changed = diff_lines(baseline, patched)
assert changed == noise # 둘 다 같은 200줄
두 집합이 똑같은 200줄이었으니, 바뀐 것은 랜덤 줄뿐입니다. 공통 include 4개를 고친 2차에서는 8개 페이지를 더 확인했고 모두 동일했습니다. API 스냅샷 비교는 실패 0건, 사이트 전체 URL 점검은 53/53이었습니다.
WAF가 가로막을 때의 공격 테스트
흔히 쓰는 ' OR '1'='1은 쓸모가 없었습니다. PHP에 닿기 전에 호스팅 WAF가 "Forbidden"으로 막아서, 고치기 전이나 후나 결과가 똑같았습니다. WAF는 버그를 가릴 뿐 고쳐 주지 않고, 조금 다른 페이로드라면 통과할 수도 있습니다.
그래서 WAF가 신경 쓰지 않는 입력을 썼습니다.
- SQL: 작은따옴표 하나. 고쳐진 엔드포인트는 200과 함께
[]나 빈 결과를 돌려줍니다. 이스케이프가 안 됐다면 SQL 문법 오류가 납니다. - XSS: 모든 파라미터에 무해한 가짜 태그
<x1>을 넣고, 응답 HTML에서 날것의<x1>을 찾습니다.
1차 XSS 수정 뒤에도 프로브가 두 군데에서 그대로 나왔습니다. 공통 내비게이션 include의 검색창과, 유틸 클래스 안에서 설정하는 페이지 제목입니다. 값이 include를 거쳐 들어오고 읽은 곳에서 먼 곳에서 출력되다 보니 소스 grep에서는 놓쳤습니다. 응답을 검색했기 때문에 찾을 수 있었고, 공통 include 4개를 대상으로 2차 작업을 한 이유도 이것입니다.
원래부터 고장 나 있던 것
회귀 점검은 작업 전에 무엇이 이미 고장 나 있었는지 알 때만 의미가 있습니다. 먼저 이것들을 기록해 두었습니다.
- 한 페이지는 PHP 문법 오류(닫히지 않은 중괄호)로 예전부터 늘 500을 돌려주고 있었습니다. 이 페이지의 수정본은 배포하지 않았습니다. 고치면 동작이 바뀌는데, 그건 이번 작업 범위 밖입니다.
- 결과가 0건인 조회는 패치 전과 후 모두 500이었습니다. 기준 응답이 없었다면 영락없이 회귀로 보였을 겁니다.
배포하다 사고 내지 않기
- 갱신 전용 모드: 서버에 이미 있는 파일이 아니면 중단합니다. 경로 오타로 새로 공개되는 파일이 생기지 않습니다.
- 먼저 백업: 덮어쓰기 전에 서버의 원본을 내려받아 둡니다.
- 모든 파일에
php -l: 로컬 PHP 8.4가 일부 오래된 파일에서Case statements followed by a semicolon are deprecated를 출력했습니다. 원래부터 있던 무해한 경고지만, 그걸 모르면 이 메시지 때문에 배포를 멈추게 됩니다.
언제 무엇을 고를까
| 상황 | 방법 |
|---|---|
| 업데이트를 강제할 수 없는 클라이언트가 쓰는 기존 엔드포인트 | SQL 조립 줄에서 이스케이프, query() 유지, 저장한 응답과 비교 |
| 새 엔드포인트, 새 API 버전 | 프리페어드 스테이트먼트 + 타입 약속을 문서화 |
| 쓰지 않는데 외부에서 접근되는 코드 | 웹 루트 밖으로 옮기고 404 확인, 나중에 삭제 |
| WAF가 페이로드를 막음 | 작은따옴표 하나와 무해한 프로브 태그. 소스가 아니라 응답을 확인 |
| 랜덤 내용이 있는 페이지 | 바뀐 줄 집합을 연속 두 번 받았을 때의 차이 집합과 비교 |
핵심은, 패치 전에 "바뀌지 않는다"가 무엇인지 정해 두고 증거를 저장한 뒤, 맞았는지는 비교 결과로 확인하는 것입니다.
자주 묻는 질문
real_escape_string을 쓰면 한글 입력값이 바뀌나요?
연결 문자셋이 UTF-8(utf8mb4)이면 바뀌지 않습니다. 이스케이프 대상은 NUL, \n, \r, 백슬래시, 작은따옴표, 큰따옴표, Ctrl-Z 7가지뿐이고 UTF-8 멀티바이트 문자는 모든 바이트가 0x80 이상이라, 한글·숫자 입력에서는 SQL이 바이트 단위로 같습니다.
onclick 속성 안의 값은 htmlspecialchars만으로 충분한가요?
아닙니다. 브라우저는 속성값의 HTML 엔티티를 먼저 디코딩한 뒤 자바스크립트에 넘기므로 '가 진짜 작은따옴표로 돌아옵니다. JS 문자열 안의 URL에 들어가는 값은 rawurlencode를 먼저, 그다음 htmlspecialchars를 적용합니다.
호스팅 WAF가 공격 문자열을 막아서 수정 여부를 확인할 수 없을 때는?
WAF가 통과시키는 입력을 씁니다. SQL은 작은따옴표 하나(고쳐졌으면 200에 빈 결과, SQL 오류 없음), XSS는 무해한 가짜 태그 <x1>을 넣고 소스가 아니라 응답 HTML에서 날것의 <x1>을 찾습니다.