취약점 스캐너가 “이 패키지에 CVE가 있다”고 보고하면 대개 apt upgrade 한 번으로 끝냈다고 여기기 쉽다. 하지만 패치가 실제로 설치됐는지, 그리고 그 패치가 해당 CVE의 익스플로잇 경로를 실제로 막았는지는 별개의 질문이다. 패키지 버전 문자열만 보고 “패치됨”이라 판단했다가 배포판이 백포트한 보안 수정이 버전 번호에 반영 안 된 경우를 놓치거나, 반대로 버전은 올라갔는데 설정 문제로 취약점이 여전히 살아있는 경우도 있다. 이 글에서는 (1) CVE 피드 조회 (2) 설치된 패키지와 매칭 (3) 패치 적용 (4) 실제 익스플로잇 지문(fingerprint) 검증까지 네 단계를 하나의 파이프라인으로 엮고, sudo의 CVE-2021-3156(Baron Samedit) 하나를 예로 삼아 실제로 스크립트를 짜고 실행한 결과를 정리한다.
자동화 파이프라인 설계
전체 흐름은 네 단계다. 각 단계에 실제로 쓸 수 있는 도구를 함께 적었다.
- CVE 피드 조회 — OSV.dev API, Ubuntu Security Notices JSON 피드, NVD API 2.0
- 설치된 패키지와 매칭 —
dpkg --compare-versions, 또는 Trivy/Grype 같은 SBOM 기반 스캐너 - 패치 적용 —
apt install --only-upgrade,unattended-upgrades, Ansible 플레이북 - Exploit 검증 — 알려진 PoC를 안전한 지문(fingerprint) 형태로 변환해 패치 전/후 동작 차이를 확인
핵심은 3단계와 4단계를 분리하는 것이다. “패치를 적용했다”와 “그 결과 취약점이 실제로 닫혔다”는 서로 다른 검증이고, CI 파이프라인이라면 4단계가 실패하면 배포를 막는 게이트로 써야 한다.
1단계 — CVE 피드 조회
OSV.dev는 패키지 이름과 버전으로 알려진 취약점을 조회할 수 있는 무료 API를 제공한다. 인증이 필요 없어 파이프라인에 넣기 쉽다.
$ curl -s -X POST -d \
'{"package": {"name": "nokogiri", "ecosystem": "RubyGems"}, "version": "1.18.2"}' \
"https://api.osv.dev/v1/query" | python3 -m json.tool | head -10
{
"vulns": [
{
"id": "GHSA-353f-x4gh-cqq8",
"summary": "Nokogiri patches vendored libxml2 to resolve multiple CVEs",
...
"database_specific": {
"severity": "CRITICAL"
}
}
]
}
Ubuntu 패키지는 ecosystem에 "Ubuntu:24.04" 같은 배포판별 값을 넣어 조회한다. 다만 실제로 지금 테스트 서버에 설치된 sudo 1.9.15p5-3ubuntu5.24.04.2 같은 배포판 고유 마이크로 버전으로 조회하면 빈 결과({})가 돌아오는 경우가 있었다 — OSV의 Ubuntu 커버리지가 모든 백포트 버전 문자열까지 촘촘하게 따라가지는 못한다는 뜻이다. CVE 하나를 놓치지 않으려면 OSV 하나에만 의존하지 말고 Ubuntu Security Notices JSON 피드(ubuntu.com/security/notices.json)를 함께 조회하는 게 안전하다.
2단계·3단계 — 버전 매칭과 패치 적용
피드에서 받은 “수정 버전”과 현재 설치된 버전을 비교하는 로직은 직접 짜는 것보다 dpkg --compare-versions에 맡기는 게 안전하다. 처음에는 버전 문자열에서 grep -oE로 숫자 부분만 잘라 비교하려 했는데, 정규식이 1.9.15p5-3ubuntu5.24.04.2에서 1.9만 잘라내는 바람에 실제로는 패치된 버전을 취약 버전으로 오탐하는 버그를 만들었다. 원본 버전 문자열을 그대로 dpkg --compare-versions에 넘기니 바로 해결됐다 — Debian 계열 버전 비교 규칙(에포크, 틸드, 리비전 등)은 이미 검증된 도구에 맡기고 직접 파싱하려 들지 않는 게 맞다.
# 잘못된 방법 — 정규식으로 버전을 잘라내다 오탐 발생
$ echo "1.9.15p5-3ubuntu5.24.04.2" | grep -oE '^[0-9]+\.[0-9]+p?[0-9]*'
1.9 # 의도와 다르게 잘림
# 올바른 방법 — 원본 문자열을 그대로 비교
$ dpkg --compare-versions "1.9.15p5-3ubuntu5.24.04.2" ge "1.9.5p2" && echo "패치됨"
패치됨
패치 적용 자체는 apt install --only-upgrade <package> 한 줄이거나, 여러 서버에 일괄 적용한다면 Ansible의 apt 모듈로 감싸면 된다. 이 부분은 이미 잘 알려진 영역이라 이 글에서는 깊이 다루지 않고, 검증되지 않은 3단계 다음에 4단계 게이트를 반드시 붙이는 것에 집중한다.
4단계 — Exploit 검증 자동화
CVE-2021-3156은 sudoedit가 커맨드라인 인자를 파싱하는 과정에서 힙 버퍼 오버플로우가 발생해 루트 권한 획득으로 이어지는 취약점이다. 전체 익스플로잇 체인은 힙 그루밍까지 필요한 복잡한 과정이지만, 취약 여부만 확인하는 데는 Qualys 원본 권고문에 실린 안전한 한 줄짜리 지문(fingerprint) 명령으로 충분하다 — 이 명령 자체는 실제 권한 상승까지 가지 않고, 취약한 버전에서는 힙 손상으로 크래시하고 패치된 버전에서는 정상적인 사용법 에러로 끝난다는 차이만 이용한다.
#!/usr/bin/env bash
set -euo pipefail
CVE_ID="CVE-2021-3156"
FIXED_VERSION="1.9.5p2"
PACKAGE="sudo"
current_version=$(dpkg-query -W -f='${Version}' "$PACKAGE")
echo "[*] 설치된 ${PACKAGE} 버전: ${current_version}"
if dpkg --compare-versions "$current_version" ge "$FIXED_VERSION"; then
echo "[*] 버전 비교: 패치된 버전 이상 (>= ${FIXED_VERSION})"
else
echo "[!] 버전 비교: 취약 버전으로 의심됨 (< ${FIXED_VERSION})"
fi
echo "[*] Exploit 지문(fingerprint) 검증: sudoedit -s /"
if sudoedit -s / > /tmp/fingerprint.log 2>&1; then
echo "[!] 비정상: 명령이 성공 종료됨 — 수동 확인 필요"
exit_code=2
else
if grep -qi "invalid option" /tmp/fingerprint.log; then
echo "[+] PASS — ${CVE_ID} 지문 없음 (패치된 동작)"
exit_code=0
else
echo "[-] FAIL — 비정상 종료 패턴 감지, 수동 확인 필요"
cat /tmp/fingerprint.log
exit_code=1
fi
fi
echo "[*] 종료 코드: ${exit_code}"
exit "$exit_code"실제 테스트 서버(Ubuntu 24.04.4, sudo 1.9.15p5)에서 실행한 결과다.
$ ./verify_cve.sh
[*] 설치된 sudo 버전: 1.9.15p5-3ubuntu5.24.04.2
[*] 버전 비교: 패치된 버전 이상 (>= 1.9.5p2)
[*] Exploit 지문(fingerprint) 검증: sudoedit -s /
[+] PASS — CVE-2021-3156 지문 없음 (패치된 동작)
[*] 종료 코드: 0
버전 비교와 지문 검증 두 층 모두 “패치됨”으로 일치했다. 만약 취약한 버전(1.9.5p2 이전)에서 같은 스크립트를 돌리면 sudoedit -s /가 malloc(): invalid size (unsorted) 같은 메시지와 함께 비정상 종료하고, 스크립트는 grep -qi "invalid option"에 걸리지 않아 FAIL 분기로 빠지면서 종료 코드 1을 반환한다 — CI에서 이 종료 코드를 그대로 배포 게이트로 쓸 수 있다.
하나로 묶기 — 파이프라인 형태
실제 운영에서는 이 네 단계를 cron이나 CI 스케줄러가 주기적으로 돌리는 형태가 된다.
- 매일 새벽: OSV/USN 피드 조회 → 설치된 패키지와 매칭되는 신규 CVE 목록 생성
- 매칭되는 CVE가 있으면: 스테이징 환경에 패치 적용 → 해당 CVE 전용 지문 스크립트 실행
- 지문 스크립트가 PASS(exit 0)면: 프로덕션에 동일 패치 롤아웃
- FAIL이면: 배포 중단 + 알림, 수동 확인 대기
지문 스크립트는 CVE마다 따로 관리해야 한다는 점에서 규모가 커지면 부담이 되는데, 이 부분은 CVE ID를 키로 하는 스크립트 저장소(디렉터리 하나에 CVE-2021-3156.sh처럼)를 만들어두고 패치 대상 CVE 목록에 맞는 스크립트만 순회 실행하는 식으로 확장하면 된다.
주의사항
- 지문 검증은 화이트박스 방식이다. 이미 알려진 CVE에만 쓸 수 있고, 제로데이나 아직 지문이 만들어지지 않은 취약점은 이 방식으로 잡을 수 없다.
- 실제 익스플로잇 코드(힙 그루밍까지 포함한 완전한 PoC)는 프로덕션 서버에서 절대 실행하지 않는다. 반드시 격리된 검증 환경에서, 가능하면 이 글처럼 크래시/에러 메시지 차이만 확인하는 안전한 지문 형태로 축소해서 쓴다.
- 버전 문자열은 절대 직접 정규식으로 파싱하지 않는다. 이번에 실제로 겪었듯, 배포판 버전 문자열(리비전·백포트 표기 포함)을 임의로 자르면 소리 없이 오탐/미탐이 생긴다.
dpkg --compare-versions처럼 이미 검증된 비교 도구를 쓴다. - OSV 같은 공개 CVE 데이터베이스는 배포판 고유 마이크로 버전까지 완벽하게 커버하지 못할 수 있다. 배포판 자체의 보안 공지(USN, Debian Security Tracker 등)를 함께 조회해 교차 확인해야 한다.
마무리
“패치를 적용했다”까지만 자동화하고 끝내면 절반짜리 파이프라인이다. 버전 문자열만 보고 안심했다가 실제로는 백포트가 안 됐거나 설정이 어긋나 취약점이 여전히 열려 있는 경우를 놓칠 수 있다. CVE 하나를 예로 직접 스크립트를 짜고 실행해보니, 버전 비교 로직 자체에서도 실수가 나올 수 있다는 걸 확인했다 — 자동화 파이프라인의 신뢰도는 결국 4단계(실제 익스플로잇/지문 검증)가 게이트로 얼마나 꼼꼼히 붙어 있느냐에 달려 있다.