공격은 내 코드가 돌기 전에 들어왔다 — PHP를 쓰지 않는 필자가 PHP2Shell을 파고든 이유#
2026-11-01

브리프에서 지나친 한 줄#
지난주 2026년 10월 24일 AI 뉴스 브리프의 첫 항목은 PHP2Shell이었습니다. PHP에서 인증 없이 원격 코드를 실행할 수 있는 취약점 두 개가 나왔고, AI 도구를 쓰는 보안 연구자들이 이 버그들을 찾아냈다는 소식입니다. 브리프에서는 이 사건을 “찾는 속도는 빨라졌는데 고치는 쪽의 처리 용량이 따라가지 못했다"는 관점으로 정리했습니다.
정리를 마치고 나서 한 줄이 계속 걸렸습니다. “AI가 찾은 취약점"이라고 써 놓고, 정작 그 취약점이 어떻게 서버를 넘겨주는지는 필자도 설명하지 못했습니다. 필자는 PHP를 쓰지 않습니다. 그러니 “해당 버전으로 올리세요"라는 조언은 필자와 상관이 없습니다. 하지만 원리를 모르면, 같은 종류의 구멍이 필자가 운영하는 시스템에도 있는지 확인할 방법이 없습니다.
그래서 PHP 재단의 공지와 보안 업체들의 분석 글을 읽으며 원리를 따라가 봤습니다. 결론부터 적으면 이렇습니다. 이번 공격은 애플리케이션 코드의 버그로 들어오지 않았습니다. 애플리케이션 코드가 실행되기 전의 층, 그리고 에러가 난 뒤의 뒷정리를 통해 들어왔습니다. 이 글은 그 원리를 쉬운 예로 풀고, 거기서 뽑은 기준으로 필자의 서비스를 점검한 기록입니다.
공격을 재현할 수 있을 만큼의 세부는 다루지 않습니다. 방어하는 쪽이 판단하는 데 필요한 원리까지만 씁니다.
무슨 일이 있었나#
PHP는 10월 21일 8.5.12, 8.4.27, 8.3.36, 8.2.35를 내고 취약점 두 개(CVE-2026-109618, CVE-2026-109619)를 고쳤습니다. 둘 다 CVSS(Common Vulnerability Scoring System, 취약점 심각도 점수) 9.2로 “치명적” 등급입니다. PHP 재단은 공지에서, Apache에 PHP를 모듈로 붙인 구성이라면 인증되지 않은 요청이 애플리케이션 코드가 실행되기 전에 이 문제를 일으킬 수 있다고 적었습니다. Wiz는 지난 10년 동안 나온 거의 모든 PHP 버전이, 멀리는 5.4까지 이 취약점 중 하나 이상에 해당한다고 분석했습니다.
이 문제에는 이력이 있습니다. 2024년 외부 보안 감사에서 메모리 부족 상황이 공격에 쓰일 수 있다는 가능성이 이미 제기됐습니다. 다만 당시 결론은 “이론상 가능해 보이지만 구체적인 공격 경로는 찾지 못했다"였고, 영향이 더 분명한 다른 문제에 밀렸습니다. 2026년 여름, AI 도구를 쓰는 보안 연구자들이 이 이론을 실제 공격으로 완성했습니다. Calif는 7월 30일 감사를 시작해 8월 11일 무인증 원격 코드 실행을 제보했고, Wiz와 OpenAI가 다른 공격 경로를 이어서 제보했습니다. Wiz는 분석 글에서 연구 전 과정에 AI 모델을 썼다고 밝혔습니다. 공개한 타임라인을 보면 8월 19일 버그를 찾았고, 다음 날 Drupal에서 무인증 원격 코드 실행을 확인했습니다.
고치는 쪽은 그 속도를 따라가지 못했습니다. 재단은 수정이 늦어진 것을 “보안 팀의 의사 결정 방식과 자원 부족이 함께 만든 구조적 실패"라고 인정했습니다. 이 속도 차이는 글 뒷부분에서 다시 다룹니다. 먼저 원리부터 보겠습니다.
원리: 게시판 하나로 따라가 보기#
먼저 알아 둘 말 네 개#
- RCE(Remote Code Execution, 원격 코드 실행): 공격자가 네트워크로 요청만 보내서 남의 서버에 원하는 명령을 실행시키는 것입니다. 앞에 “무인증"이 붙으면 로그인조차 필요 없다는 뜻입니다. 사실상 서버를 넘겨주는 것과 같아서 가장 심각한 등급으로 분류됩니다.
- 메모리 한도: PHP는 요청 하나가 쓸 수 있는 메모리의 상한을 둡니다. 한도를 넘으면 하던 일을 그 자리에서 포기하고 요청을 끝냅니다.
- use-after-free(해제 후 사용): 이미 반납한 메모리를 프로그램이 아직 쓰는 중인 것처럼 다시 건드리는 버그입니다. 그 자리에 다른 데이터가 들어와 있으면 프로그램은 엉뚱한 값을 믿고 움직입니다.
- ASLR(Address Space Layout Randomization, 주소 공간 배치 무작위화): 프로그램이 뜰 때마다 코드와 데이터가 놓이는 메모리 위치를 무작위로 섞는 운영체제의 방어 장치입니다. 공격자가 메모리를 망가뜨려도 어디로 보내야 할지 모르면 대개 서버가 죽는 것으로 끝납니다. 그래서 실제 공격에는 메모리 위치를 알아내는 정보 유출 단계가 하나 더 필요합니다.
“우리동네 장터"에서 벌어지는 일#
PHP로 만든 동네 중고 거래 게시판이 있다고 해 보겠습니다. 회원은 글을 쓰면서 사진을 첨부할 수 있습니다.
평소에는 이렇게 동작합니다. 브라우저가 제목, 본문, 사진을 한 요청에 묶어 보냅니다. 그런데 게시판 코드가 이 요청을 받기 전에 PHP 엔진이 먼저 요청을 뜯어서 항목별로 정리합니다. 이때 업로드가 얼마나 진행됐는지 기록하는 기능도 함께 돕니다. 이 기능은 기본으로 켜져 있고, Wiz와 Datadog의 분석에 따르면 게시판이 세션 기능을 쓰지 않아도 동작합니다. 정리가 끝나야 게시판 코드가 실행되고, 거기서 비로소 로그인 여부와 파일 확장자를 검사합니다.
공격자는 회원 가입도 하지 않고 글쓰기 주소로 직접 요청을 보냅니다. 큰 파일을 올리는 것이 아닙니다. PHP가 요청을 정리하는 동안 내부에서 메모리를 아주 많이 쓰도록 요청의 모양을 짭니다. 보안 업체들의 분석을 종합하면 공격은 크게 네 단계로 나눌 수 있습니다.
- 메모리 지도 엿보기: 결함이 있는 서버는 응답 쿠키 값에 자기 메모리 조각을 섞어 돌려보낼 수 있었습니다. 공격자는 이것으로 ASLR이 섞어 놓은 메모리 배치를 알아냅니다.
- 한도까지 밀어붙이기: 공격자는 PHP가 요청을 정리하는 도중, 원하는 순간에 메모리 한도에 걸리게 만듭니다.
- 반쯤 기록된 칸: Datadog의 분석에 따르면 PHP는 입력 항목을 내부 목록에 넣을 때 “항목이 하나 늘었다"는 개수를 먼저 올리고, 그다음에 내용을 담을 메모리를 할당합니다. 그 사이에 한도에 걸리면 개수는 늘었는데 내용은 비어 있는 칸이 남습니다. 그 칸에는 직전에 같은 메모리를 쓰던 데이터의 잔해가 있고, 공격자는 그 잔해를 자기가 보낸 데이터로 미리 채워 둘 수 있습니다.
- 뒷정리의 착각: 요청을 포기한 PHP는 뒷정리를 합니다. 목록에 적힌 개수만큼 칸을 돌며 정리하는데, 이때 잔해를 정상 데이터로 믿고 처리합니다. 결국 잔해 안에 적힌 대로 실행 흐름이 넘어갑니다.
코드를 심는 게 아니라, 있는 기능을 부른다#
필자가 가장 오래 헷갈린 부분은 “공격자가 원하는 코드를 서버에 어떻게 심는가"였습니다. 답은 심지 않는다였습니다.
서버 안에는 원래부터 운영체제 명령을 실행하는 기능이 들어 있습니다. 그리고 공격자가 보낸 요청 본문은 처리를 위해 서버 메모리에 그대로 올라갑니다. 여기까지는 둘 다 정상입니다. 메모리 손상이 일어나면 뒷정리 루틴이 공격자의 데이터를 “이 기능을 이 값으로 불러라"라는 내부 지시로 읽습니다. 1단계에서 알아낸 메모리 지도는 그 기능이 어디에 있는지 가리키는 데 쓰입니다.
비유하면 무기를 들고 들어가는 것이 아닙니다. 접수 서류의 한 칸을 교묘하게 망가뜨려서, 직원이 그 칸을 “금고를 열라"는 결재로 읽게 만드는 것에 가깝습니다. ASLR은 금고가 몇 층 몇 호에 있는지 매번 바꾸는 장치이고, 1단계의 유출은 그 호수를 몰래 알아내는 일입니다.
명령 하나를 실행시키는 데 성공하면 공격자는 보통 작은 PHP 파일을 서버에 써 둡니다. 웹셸이라고 부르는 뒷문입니다. 그 뒤로는 버그 없이도 이 파일을 불러 계속 드나듭니다. 흔히 말하는 “코드를 심는” 장면은 공격의 마지막 단계에서야 나옵니다.
게시판 운영자에게 남는 것#
이 공격에서 게시판 코드는 한 줄도 실행되지 않았습니다. 로그인 검사, 확장자 검사, 직접 만든 보안 필터 모두 거치지 않았습니다. Datadog에 따르면 서버 로그에는 스크립트가 시작되기도 전에 메모리 한도를 넘었다는 오류(Unknown on line 0) 한 줄만 남을 수 있습니다.
명령을 실행할 수 있게 된 공격자는 게시판 설정 파일에서 데이터베이스 비밀번호를 읽고 회원 정보를 통째로 가져갈 수 있습니다. 게시글에 악성 스크립트를 넣어 방문자를 노릴 수도 있고, 같은 서버나 내부망의 다른 시스템으로 옮겨 갈 수도 있습니다. 게시판 개발자가 코드를 아무리 잘 짰어도 막을 수 없는 공격이었습니다. 그래서 PHP 재단과 보안 업체들의 결론은 하나였습니다. 취약점이 고쳐진 PHP 버전으로 업데이트하는 것만이 완전한 대책입니다.
PHP의 수정도 버그 함수 하나를 고친 것이 아니었습니다. 메모리 한도를 넘어도 진행 중인 작업이 안전한 지점까지 끝나도록 오류를 미루고, 그래도 메모리가 계속 늘면 뒷정리 없이 작업 프로세스를 바로 끝내게 바꿨습니다. 요청 사이에 남는 내부 상태도 재사용 전에 비우게 했습니다. 다만 Datadog은 “내용을 할당하기 전에 개수부터 고치는” 방식 자체는 남아 있어서 비슷한 버그가 또 나올 수 있다고 경고했습니다.
PHP 밖으로 옮기면 남는 네 가지#
필자는 서비스를 만들 때 Node.js를 많이 씁니다. JavaScript는 메모리를 직접 다루지 않는 언어라서, 필자가 쓴 코드에서 위와 똑같은 메모리 손상이 일어나지는 않습니다. 그렇다고 안심할 수는 없었습니다. Node.js의 HTTP 해석기와 런타임 핵심부, 그리고 적지 않은 네이티브 패키지는 C나 C++로 되어 있습니다. 메모리를 직접 다루지 않는 언어에서도 같은 모양의 실수는 논리 수준에서 반복됩니다.
필자는 이번 사건에서 언어와 상관없이 가져갈 기준을 네 가지로 정리했습니다. 아래는 공식 분석이 아니라 필자의 해석입니다.
1. 내 코드보다 먼저 도는 층이 있다#
웹 서비스에 들어온 요청은 개발자의 코드에 닿기 전에 여러 층을 지납니다. 앞단의 웹 서버나 프록시가 있고, 런타임이나 프레임워크가 요청 본문을 해석하고 업로드 파일을 받고 세션 쿠키를 읽습니다. Express라면 본문 해석 미들웨어가, Django나 FastAPI라면 프레임워크의 요청 해석기가 이 일을 합니다. 이 층은 인증 검사보다 먼저 돕니다. 누가 보낸 요청이든 일단 받아서 처리한다는 뜻입니다.
개발자가 보안을 점검할 때는 보통 오른쪽, 즉 자기 코드의 입력 검증과 권한 검사를 봅니다. PHP2Shell은 왼쪽에서 터졌습니다. 그래서 첫 번째 점검 질문은 이렇습니다. 로그인하지 않은 사람이 보낸 요청을, 내 코드가 보기 전에 무엇이 얼마나 처리하는가? 그 처리에 크기와 개수의 상한이 있는가?
2. 실패한 뒤에 반쯤 된 상태가 남는다#
PHP의 버그는 “개수를 먼저 올리고 내용은 나중에 채우는” 순서에서 시작했습니다. 이 순서는 평소에는 아무 문제가 없습니다. 중간에 실패할 때만 거짓 상태가 남습니다.
메모리를 직접 다루지 않는 언어에도 같은 모양이 있습니다. 주문 상태를 “결제 완료"로 먼저 바꾸고 나서 외부 결제 API를 부르는 코드, 첨부 파일 목록에 먼저 추가하고 업로드는 나중에 하는 코드, 재시도 도중 일부만 반영된 작업이 그렇습니다. 요청이 시간 초과나 메모리 부족으로 중간에 끊기면 그 상태가 남고, 다음 처리가 그 상태를 믿습니다. 두 번째 질문은 이렇습니다. 실패한 요청이 남긴 상태를 다음 처리가 그대로 믿지 않는가? 에러 경로는 테스트가 가장 적은 곳이기도 합니다.
3. 요청 사이에 남는 상태가 있다#
두 번째 CVE는 앞 요청이 쓰던 내부 상태가 메모리 반납 뒤에도 남아 있다가, 같은 작업 프로세스의 다음 요청이 그것을 건드리는 문제였습니다. PHP의 작업 프로세스는 요청 여러 개를 차례로 처리하기 때문입니다.
Node.js는 이 특성이 훨씬 강합니다. 프로세스 하나가 모든 사용자의 요청을 동시에 처리하므로, 모듈 수준의 변수나 메모리 캐시는 모든 요청이 함께 씁니다. 한 사용자의 정보가 다음 사용자의 응답에 섞이는 사고는 이 구조에서 생깁니다. 세 번째 질문은 이렇습니다. 요청 하나에만 속해야 할 값이 프로세스 전역에 남지 않는가?
4. 작은 유출이 큰 공격의 디딤돌이 된다#
이번 공격에서 쿠키에 메모리 조각이 섞여 나오는 유출은 그 자체로는 서버를 넘겨주지 않습니다. 하지만 그것이 없으면 ASLR 때문에 공격이 대부분 실패합니다. 작은 유출이 큰 공격을 가능하게 만든 것입니다.
웹 서비스에서 흔한 유출은 에러 응답에 그대로 나가는 스택 트레이스, 내부 경로와 라이브러리 버전이 적힌 오류 메시지, 디버그용으로 열어 둔 상태 페이지입니다. 하나하나는 사소해 보여도 공격자에게는 지도가 됩니다. 네 번째 질문은 이렇습니다. 에러가 났을 때 밖으로 나가는 정보가 일반적인 메시지뿐인가?
필자의 서비스에 대입해 보기#
네 가지 질문을 들고 필자가 운영하는 작은 Node.js 웹 서비스 하나를 다시 열어 봤습니다. 저장소의 코드와 운영 문서를 읽는 방식으로 점검했고, 운영 서버의 설정은 이번에 직접 확인하지 않았습니다.
생각보다 잘 되어 있는 곳이 많았습니다.
- 요청 해석의 상한: 요청 본문 해석에 경로별 크기 상한과 항목 개수 상한이 걸려 있었습니다.
- 데이터는 데이터로: 사용자가 올리는 코드를 서버에서 실행하지 않고 데이터로만 저장한다는 원칙이 설계 문서에 있었습니다. 실제 실행은 사용자 브라우저 안에서만 합니다. PHP2Shell이 무너뜨린 “데이터와 지시의 경계"를 설계 단계에서 지켜 둔 셈입니다.
- 실패 처리: 회원 탈퇴처럼 여러 테이블을 바꾸는 작업은 트랜잭션 하나로 묶여 있었습니다.
- 에러 응답: 에러가 나면 일반적인 메시지만 돌려주고 자세한 내용은 서버 로그에만 남깁니다.
- 의존성 검사: 배포 파이프라인에 의존성 취약점 검사가 들어 있습니다.
빈 곳도 있었습니다.
- 실시간 연결의 상한: HTTP 요청에는 크기 상한을 명시했지만, 실시간 연결로 오가는 메시지의 상한은 라이브러리 기본값에 맡기고 있었습니다. 기본값이 있으니 당장 위험하지는 않지만, 내 코드보다 먼저 도는 층의 상한을 “기본값이 알아서 해 주겠지"에 맡긴 것은 질문 1의 답이 될 수 없습니다.
- 앞단 웹 서버의 상한: 웹 서버의 요청 크기 상한은 저장소 밖의 운영 설정이라 코드만으로는 확인할 수 없었습니다.
- 뚫렸을 때의 피해 범위: 작은 서비스는 대개 서버 한 대에 앱과 데이터가 함께 있습니다. 앱 프로세스가 뚫리면 같은 권한으로 회원 데이터와 비밀 키를 읽을 수 있습니다. 그리고 웹 프로세스가 셸을 띄우는 이상 행동을 알아챌 장치가 없었습니다.
가장 크게 걸린 것은 런타임 자체였습니다. 이 서비스의 Node.js는 공식 지원이 이미 끝난 메이저 버전에 머물러 있었습니다. 배포 파이프라인의 의존성 검사는 매번 “취약점 0개"를 보여 줬지만, 그 검사는 프로젝트가 가져다 쓰는 라이브러리만 봅니다. 그 라이브러리들이 올라가 있는 런타임은 검사 대상이 아니었습니다. PHP2Shell은 정확히 이 층, 애플리케이션 아래의 런타임에서 터졌습니다. Zscaler의 정리에 따르면 이번 패치도 지원 중인 PHP 8.2 이상에만 나왔고, 8.1 이하의 지원 종료 버전에는 공식 패치가 없습니다. 같은 일이 Node.js에서 일어나면 필자의 서비스는 올릴 패치가 아예 없는 쪽에 서게 됩니다.
그래서 다음 개선을 계획했습니다.
- 런타임을 지원 중인 LTS(Long-Term Support, 장기 지원) 버전으로 올립니다. 그리고 런타임의 지원 종료일을 의존성 검사와 같은 자리에서 확인하게 합니다.
- 내 코드보다 먼저 도는 층의 상한을 모두 명시합니다. 실시간 연결의 메시지 상한을 코드에 적고, 앞단 웹 서버의 요청 크기 상한은 운영 설정에서 확인해 운영 문서에 남깁니다.
- 요청 사이에 값이 섞이지 않는지 테스트로 고정합니다. 여러 사용자가 동시에 요청할 때 각자의 값만 돌아오는지 확인하는 테스트입니다.
- 뚫렸을 때를 대비합니다. 웹 프로세스의 외부 통신을 꼭 필요한 곳으로만 제한하는 방안과, 웹 프로세스가 셸이나 다운로드 도구를 띄우면 알리는 감시를 검토합니다. Datadog도 이 신호를 성공한 공격의 가장 직접적인 증거로 꼽았습니다. 이 감시는 언어와 상관없이 같은 종류의 공격을 잡아냅니다.
고치는 속도가 마지막 방어선이다#
원리를 알고 나니 브리프에 쓴 “속도 격차"가 다르게 읽혔습니다. 필자가 할 수 있는 점검은 결국 런타임 안의 버그를 막지 못합니다. PHP2Shell 같은 결함 앞에서 애플리케이션 운영자가 가진 무기는 하나, 패치가 나왔을 때 얼마나 빨리 올리느냐입니다.
그 시간은 생각보다 짧습니다. 공개 전날 CVE 정보와 시연 영상이 새어 나갔고, 공개 당일 아침에는 한 배포사가 수정 이미지를 먼저 내놓았습니다. VulnCheck는 그 직후인 10월 21일 08:37(UTC)부터, 그러니까 공식 공개 다섯 시간여 전부터 관련 스캔을 관측했습니다. Zscaler는 공개 후 48시간 동안 천 건이 넘는 공격 시도를 관측했다고 밝혔습니다. 찾는 쪽은 AI 도구로 하루 만에 실제 공격을 확인했고, 노리는 쪽은 공개 몇 시간 안에 움직였습니다. 그런데 고치는 쪽은 실제 공격 제보를 받고 패치를 내기까지 약 10주가 걸렸습니다.
여기서 같은 브리프의 다른 조언과 부딪히는 지점이 있습니다. LiteLLM 공급망 사고를 다루며 필자는 새 버전을 며칠 기다리는 유예 정책을 권했습니다. 공식 파이프라인이 서명한 릴리스도 악성일 수 있기 때문입니다. 그런데 보안 패치는 며칠 기다리면 늦습니다. 필자가 정리한 답은 두 종류의 업데이트를 나누는 것입니다. 기능 업데이트와 일반 의존성은 유예 기간을 둡니다. 런타임과 핵심 구성 요소의 보안 릴리스는 공식 공지와 배포처를 확인한 뒤 바로 올립니다.
그러려면 “바로 올릴 수 있는 상태"가 평소에 준비돼 있어야 합니다. 지원이 끝난 런타임은 이 준비가 안 된 상태입니다. 올릴 패치가 나오지 않으니까요.
맺으며#
PHP를 쓰지 않는 필자가 PHP 취약점을 파고든 이유는, 원리를 모르면 점검할 곳을 고를 수 없어서였습니다. 원리를 따라가 보니 점검할 곳은 필자가 평소 보던 곳의 바깥에 있었습니다. 내 코드가 돌기 전에 요청을 처리하는 층, 실패한 뒤의 뒷정리, 요청 사이에 남는 상태, 사소해 보이는 유출, 그리고 그 모든 것 아래의 런타임입니다.
이번 사건이 보여 준 것은 AI가 취약점을 잘 찾는다는 사실보다, “이론상 위험하지만 실제 공격 경로는 없다"는 판단의 유효기간이 짧아졌다는 사실입니다. 2024년에 미뤄 둔 이론이 2026년에 AI 도구를 쓴 연구자들의 손에서 몇 주 만에 실제 공격이 됐습니다. 필자의 서비스에도 “설마 여기까지 오겠어"라며 미뤄 둔 항목이 있었습니다. 런타임 업그레이드부터 처리하고, 나머지 개선을 적용한 뒤 그 결과를 다시 정리해 보겠습니다.