2026년 10월 16~23일 PHP, Node.js, GitLab, LiteLLM, OpenCode와 인증기관 eMudhra에서 보안 사고가 발생했다. 패치를 적용하는 것보다 취약한 소프트웨어가 어디서 실행되는지, 누가 관리하는지, 어떤 권한을 가졌는지 찾는 일이 더 오래 걸릴 수 있다는 것이 이 글의 핵심이다.

PHP, Node.js와 GitLab은 10월 21일 수정판을 냈고, 같은 날 악성 LiteLLM 릴리스도 배포됐다. 아마존의 MadPot 허니팟은 PHP2Shell 취약점이 공개되기 나흘 전 공격 시도를 기록했다. 아마존의 위협 분석에 따르면 취약점 공개 전에 이미 악용 시도가 있었다.

  • PHP2Shell은 PHP의 사전 인증 원격 코드 실행 취약점 두 건으로, 각각 심각도 9.2를 받았다. 수정 버전은 PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35다. 임시 설정 변경은 모든 변종을 막지 못해 완전한 해결책은 업그레이드다. CVE-2026-109618 및 CVE-2026-109619
  • Node.js에서는 내장 Brotli 디코더의 범위를 벗어난 쓰기 취약점이 발견됐다. 충돌, 메모리 노출 또는 원격 코드 실행으로 이어질 수 있으며 수정 버전은 22.23.4, 24.22.1, 26.12.1이다. 지원 종료된 릴리스에는 수정판이 제공되지 않는다. Node.js 보안 공지
  • GitLab 취약점은 인증 없이 원격 코드 실행이 가능하며 CVSS 10.0으로 평가됐다. 2014년 출시된 GitLab 7.0까지 영향을 받을 수 있고, 자체 관리형 인스턴스에는 우회 방법이 없어 패치가 필요하다. CVE-2026-110264
  • PyPI와 공식 컨테이너 이미지에 배포된 LiteLLM 1.107.0은 Python이 시작될 때 자격 증명 탈취 코드를 실행했다. 검증된 버전으로 다시 빌드하고 해당 환경에서 접근할 수 있던 모든 자격 증명을 교체해야 한다. 마이크로소프트 분석
  • OpenCode Console의 구형 시스템에 침입자가 접근했고, 사용자가 저장한 AI 제공업체 키가 노출됐을 가능성이 있다. 일부 사용자는 승인하지 않은 요금이 청구됐다고 보고했다. 키를 폐기하고 새로 발급해야 한다. TechCrunch 보도
  • eMudhra의 중간 인증기관(CA) 개인 키 8개는 256보다 작은 정수였다. 그중 하나는 숫자 4였다. 브라우저는 해당 중간 인증서를 차단했다. The Register 보도, 키 분석

공격 속도와 자산 파악

Mandiant가 추적한 취약점의 공개 후 악용까지 걸린 평균 시간은 2018~2019년 63일, 2020~2021년 44일, 2021~2022년 32일이었다. 2023년에는 5일로 줄었다. 그해 Mandiant가 추적한 악용 취약점 가운데 70%는 패치가 나오기 전에 공격받은 제로데이였다. Mandiant의 분석

드루 실콕(Drew Silcock)은 ‘이것이 새로운 사이버 보안의 일상인가?’라는 글에서 대규모 언어 모델(LLM)이 공격 준비 시간을 줄여, 원격 코드 실행 취약점이 공개된 뒤 자동화된 공격이 시작되기까지의 간격이 몇 주가 아니라 몇 시간으로 짧아졌다고 주장했다. PHP2Shell 사례에서는 인공지능 모델의 도움을 받아 취약점을 찾았고, 공개 전 공격 시도도 있었다. PHP2Shell 발견 과정, 공개 전 패치 바이너리 배포 보도

대응에 걸리는 시간은 영향받는 자산을 찾는 시간과 수정하는 시간으로 나뉜다. 지속적 통합·배포(CI/CD), 관리형 런타임, 자동 패치로 수정 시간을 줄인 조직도 취약 자산을 찾는 데는 자산 목록과 업무용 메신저 대화, 과거 시스템을 기억하는 사람에게 의존할 수 있다.

이 글은 자산 목록을 취약점 대응의 출발점으로 제시한다. CIS Controls는 기업 자산 목록과 소프트웨어 목록을 각각 1·2번 통제로 두고, 취약점 관리는 7번에 둔다. 미국 연방기관은 CISA의 BOD 23-01에 따라 7일마다 자동 자산 탐색을, 14일마다 취약점 목록화를 수행해야 한다. 존재를 모르는 서버에는 패치를 적용할 수 없다는 설명이다.

클라우드 환경에서 확인할 항목

AWS와 Azure의 기본 도구를 이용해 점검할 때는 실행 위치, 인터넷 노출 여부, 담당자, 자격 증명과 인증서의 위치를 확인해야 한다. 예를 들어 AWS Lambda의 지원되는 관리형 런타임은 기본 설정에서 런타임 업데이트를 자동으로 받는다. 반면 지원 종료된 nodejs20.x 런타임은 Node.js 20이 2026년 4월 지원 종료된 뒤 보안 패치를 받지 않으며, 컨테이너 이미지 함수에는 이미지에 포함된 Node.js 버전이 그대로 들어 있다.

PHP는 Azure App Service의 런타임 스택이나 Azure Resource Graph로 확인할 수 있다. PHP 8.1 이하 버전은 정기적인 업스트림 수정판을 받지 않으므로 우선순위를 높여야 한다. 가상 머신과 컨테이너는 클라우드 제어 영역만으로 설치된 소프트웨어를 알기 어렵다. AWS Systems Manager Inventory, Amazon Inspector, Microsoft Defender for Cloud 같은 도구도 활성화된 장비만 확인한다.

자체 관리형 GitLab은 이름이나 태그로 서버를 찾은 뒤 인터넷에서 접근 가능한지 확인해야 한다. 다만 이름 검색은 서버 이름이 정확할 때만 찾을 수 있고, 공인 IP가 없는 서버도 공개 로드 밸런서를 통해 노출될 수 있다. AWS CloudTrail이나 Azure Activity Log에서 자원 생성자를 확인할 수 있지만, 이벤트 기록은 90일까지만 보관된다. 더 오래된 자원은 별도 로그 보관이 필요하고, 생성자가 이미 퇴사했을 수도 있다. AWS 자원 생성자 확인 방법

LiteLLM 사고에서는 악성 패키지가 AWS·Azure·Google Cloud 자격 증명 캐시, Kubernetes 서비스 계정 파일, Docker 설정, 마운트된 비밀정보를 노렸다. AWS와 GCP 메타데이터 서비스에서 자격 증명을 가져오려 했고, 프록시 호스트에서는 LiteLLM 데이터베이스의 제공업체 키도 읽었다. 따라서 패키지 버전 확인에 그치지 말고, 해당 환경이 어떤 역할로 실행됐으며 그 역할이 무엇에 접근할 수 있었는지 조사해야 한다.

마이크로소프트는 깨끗한 장비에서 조사하고, 검증된 산출물로 다시 빌드한 뒤, 접근 가능했던 모든 자격 증명을 교체하라고 안내했다. 컨테이너 이미지는 latest 같은 움직이는 태그 대신 다이제스트로 고정하라고도 권고했다. 악성 코드가 Python 시작 시 실행되므로 의심스러운 환경에서 패키지를 확인하려고 pip를 실행하면 악성 코드가 작동할 수 있다는 점도 유의해야 한다.

인증서 폐기도 연쇄 영향을 살펴야 한다. AWS Certificate Manager의 인증서는 리전별로 확인해야 하며, CloudFront에 쓰는 인증서는 us-east-1에 있다. 브라우저가 인증기관을 차단해도 자체 신뢰 저장소를 쓰는 소프트웨어는 저장소가 갱신될 때까지 해당 인증기관을 신뢰할 수 있다. 오래된 AMI, 기본 이미지, 자체 인증서 목록을 포함한 런타임이 이런 사례에 해당한다.

스캐너만으로는 부족한 이유

Amazon Inspector와 Defender for Cloud는 특정 장비의 패키지 버전이 취약한지 확인하는 데 유용하다. 하지만 활성화하지 않은 계정이나 장비는 보이지 않고, 취약점 발견 결과만으로 담당자나 자산의 접근 권한, 다른 서비스와의 연결 관계까지 알 수는 없다. 담당자가 없는 경보는 실제 조치로 이어지지 않을 수 있으며, LiteLLM 사고처럼 호스트의 권한과 연결 정보가 대응 범위를 좌우하기도 한다.

이 글은 최신 자산 목록에 자산의 존재 여부뿐 아니라 담당자와 연결 관계까지 기록해야 한다고 주장한다. 마지막으로 제시한 한 시간 점검에서는 Node.js와 PHP가 실행되는 모든 위치를 나열하고, 자체 관리형 GitLab의 인터넷 노출 여부와 담당자를 확인하며, LiteLLM 1.107.0을 설치한 환경의 역할과 접근 가능 자격 증명을 조사하라고 권한다. AI 제공업체 키의 저장 위치와 사용 워크로드, eMudhra 또는 emSign 인증기관이 발급한 인증서도 확인 대상이다.

부록: 원문 예시

text
# Node.js and container-image Lambda functions in every enabled region
for region in $(aws ec2 describe-regions --query "Regions[].RegionName" --output text); do
 echo "== $region"
 aws lambda list-functions --region "$region" \
 --query "Functions[?PackageType=='Image' || (Runtime && starts_with(Runtime, 'nodejs'))].[FunctionName, Runtime || PackageType]" \
 --output text
done

원문에 실린 예시 2

text
az graph query -q "
resources
| where type =~ 'microsoft.web/sites'
| mv-expand setting = properties.siteProperties.properties
| where setting.name =~ 'LinuxFxVersion' and tostring(setting.value) startswith 'PHP'
| project name, resourceGroup, subscriptionId, stack = tostring(setting.value)
" --first 1000 -o table

원문에 실린 예시 3

text
# Instances that look like GitLab, with public IPs and security groups
aws ec2 describe-instances \
 --filters "Name=tag:Name,Values=*gitlab*,*GitLab*" \
 --query "Reservations[].Instances[].[InstanceId, State.Name, PublicIpAddress, join(',', SecurityGroups[].GroupId)]" \
 --output text

# Security groups with an inbound rule open to the whole internet
aws ec2 describe-security-groups \
 --filters Name=ip-permission.cidr,Values=0.0.0.0/0 \
 --query "SecurityGroups[].[GroupId, GroupName, VpcId]" \
 --output text

원문에 실린 예시 4

text
aws cloudtrail lookup-events \
 --lookup-attributes AttributeKey=ResourceName,AttributeValue=i-0abc123example \
 --query "Events[].[EventTime, EventName, Username]" \
 --output table

원문에 실린 예시 5

text
# The instance profile behind an EC2 instance
aws ec2 describe-instances --instance-ids i-0abc123example \
 --query "Reservations[].Instances[].IamInstanceProfile.Arn" --output text

# The role inside that profile, and the policies attached to it
aws iam get-instance-profile --instance-profile-name litellm-proxy \
 --query "InstanceProfile.Roles[].RoleName" --output text
aws iam list-attached-role-policies --role-name litellm-proxy-role
aws iam list-role-policies --role-name litellm-proxy-role

원문에 실린 예시 6

text
aws secretsmanager list-secrets \
 --query "SecretList[].[Name, LastAccessedDate, LastRotatedDate]" \
 --output table

원문에 실린 예시 7

text
for arn in $(aws acm list-certificates \
 --includes keyTypes=RSA_2048,RSA_3072,RSA_4096,EC_prime256v1,EC_secp384r1,EC_secp521r1 \
 --query "CertificateSummaryList[].CertificateArn" --output text); do
 aws acm describe-certificate --certificate-arn "$arn" \
 --query "Certificate.[DomainName, Issuer, Type]" --output text
done | grep -i -E "emudhra|emsign"

원문에 실린 예시 8

text
az graph query -q "
resources
| where type =~ 'microsoft.web/certificates'
| where tostring(properties.issuer) contains 'emSign' or tostring(properties.issuer) contains 'eMudhra'
| project name, resourceGroup, subscriptionId, issuer = tostring(properties.issuer), expires = properties.expirationDate
" -o table

원문에 실린 예시 9

text
$ sleuth ask "gitlab servers the internet can reach, and who owns them"

ec2 scm-legacy-01 account acquired-prod · eu-west-1
 image GitLab CE 13.12 AMI, launched 2021-06-02
 exposure internet → alb-scm (public) → target group scm-https → :443
 owner team-devex confidence 88%
 created by role/terraform-ci (CloudTrail, 2021-06-02)
 managed by github.com/acme/legacy-infra (terraform state)
 role scm-legacy-instance → read on 4 S3 buckets, 2 secrets
 findings no owner tag · public ingress

원문에 실린 예시 10

text
select cloud, account, type, name, runtime, owner
from resources
where runtime in ('nodejs18.x', 'nodejs20.x')
order by owner;