
롯데카드 결제 서버에는 2017년에 나온 WebLogic 패치가 2025년까지 빠져 있었습니다. 2022년 6월 이후 거래가 끊긴 서버였고 보안관제에서도 빠져 있었는데, 그 서버를 거쳐 297만 명의 정보가 빠져나갔습니다(롯데카드 297만 명). 2026년 3월에는 EU 집행위원회 CI에서 실행된 오염된 보안 스캐너가 클라우드 키를 밖으로 보냈고, 압축 91.7GB가 공개됐습니다(CERT-EU, EU 집행위원회). 두 사고에는 실행 중인 서버와 도구가 무엇인지 확인하는 단계가 빠져 있었다는 공통점이 있습니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 7-1 ~ 7-7

| 위험 | 어디서 생기나 | 막는 법 한 줄 | OWASP |
|---|---|---|---|
| 알려진 취약 부품 | 오래된 의존성, 목록에서 빠진 서버 | SBOM을 만들고 악용 중 취약점(KEV) 목록과 자동 대조 | A03 |
| 바뀔 수 있는 CI 도구와 장기 키 | 버전 태그로 받은 외부 Action, CI에 둔 클라우드 키 | 전체 커밋 해시로 고정, 실행할 때만 받는 단기 자격증명 | A03 |
| 확인 없이 배포되는 이미지 | 이름과 태그만 보고 받는 배포 | 빌드 직후 서명, 배포 스크립트에서 서명 확인 | A08 |
| 신뢰할 수 없는 데이터 역직렬화 | 요청 본문을 Java 객체로 그대로 복원 | 형식을 고정한 DTO, 허용 타입 필터 | A08 |
서비스 하나에는 직접 쓴 코드보다 가져다 쓴 부품이 훨씬 많습니다. 어떤 부품의 어떤 버전이 들어 있는지 모르면, 취약점 공지가 나와도 우리 서비스가 해당되는지 판단할 수 없습니다. 목록에 없는 서버와 부품은 아무도 패치하지 않습니다.
SBOM(Software Bill of Materials)은 서비스에 들어간 부품과 버전을 적은 목록입니다. KEV(Known Exploited Vulnerabilities)는 미국 CISA가 "실제로 악용되고 있다"고 확인한 취약점 목록입니다. 취약점 공지는 매일 나오니, KEV에 오른 것부터 먼저 고치면 순서가 정해집니다.
롯데카드 사고의 서버는 WebLogic 미들웨어였습니다. 앱 의존성으로 만든 SBOM만으로는 이런 서버를 잡지 못합니다. 쓰지 않는 서버까지 자산 목록에 올리는 일이 함께 필요합니다.
특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다.
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<executions>
<execution><phase>package</phase><goals><goal>makeAggregateBom</goal></goals></execution>
</executions>
</plugin>
// CI 단계: SBOM에서 찾은 CVE 가운데 KEV에 오른 것이 있으면 빌드 실패
Set<String> kev = KevCatalog.load(Path.of("kev.json")).cveIds();
List<Finding> hits = findings.stream() // 취약점 스캐너가 SBOM에서 찾은 결과
.filter(f -> kev.contains(f.cveId()))
.toList();
if (!hits.isEmpty()) {
hits.forEach(f -> log.error("KEV {} in {}", f.cveId(), f.component()));
System.exit(1);
}
KevCatalog와 Finding은 설명용 이름입니다.
알려진 취약 버전을 일부러 넣은 브랜치에서 빌드가 실패하는지 확인합니다. KEV 목록이 갱신된 날부터 반영까지 걸린 시간을 기록해 둡니다.
CI 설정에서 외부 도구를 @v4 같은 버전 태그로 받으면, 태그가 가리키는 코드는 바뀔 수 있습니다. 설정 파일은 그대로인데 다음 실행부터 다른 코드가 돕니다. EU 집행위원회 사고 때는 trivy-action의 태그 76개 중 75개가 악성 코드를 가리키도록 바뀌었습니다(위즈 분석). 그 CI 환경에는 오래 쓰는 AWS 액세스 키가 있었고, 그 키에는 다른 집행위 AWS 계정을 관리할 권한까지 있었습니다(CERT-EU). CERT-EU 권고의 첫 번째 항목은 "전체 커밋 해시로 고정"입니다.
# CI 워크플로(GitHub Actions 문법). 해시 값은 가짜
permissions:
contents: read
id-token: write # 실행할 때만 받는 단기 자격증명(OIDC)
steps:
- uses: actions/checkout@0000000000000000000000000000000000000001 # 전체 커밋 해시
- uses: actions/setup-java@0000000000000000000000000000000000000002 # 태그 대신 해시
- run: ./mvnw -B verify
단기 자격증명은 CI가 실행될 때 클라우드에 "이 저장소의 이 워크플로"임을 증명하고 짧은 수명의 키를 받는 방식입니다. CI 설정에 장기 키를 저장하지 않으니 도구가 오염돼도 들고 나갈 키의 수명이 짧습니다. 받는 역할의 권한도 배포에 필요한 범위로 줄입니다.
uses: 줄 가운데 40자리 해시가 아닌 것이 있으면 CI가 실패하도록 검사 단계를 둡니다. CI 시크릿 목록에 장기 클라우드 키가 남아 있지 않은지 봅니다.
배포할 때는 보통 이미지 이름과 태그로 이미지를 받아 옵니다. 태그는 덮어쓸 수 있습니다. 레지스트리 계정 하나가 새면 같은 이름의 다른 이미지가 운영에 올라갑니다. 확인하는 단계가 없으면 배포 스크립트는 바뀐 이미지도 그대로 실행합니다.
# deploy.sh: 서명이 맞아야 배포한다 (레지스트리 주소는 가짜)
IMAGE=registry.example.com/daon:1.4.2
cosign verify --key cosign.pub "$IMAGE" || exit 1
docker compose up -d app
빌드 직후 CI에서 이미지에 서명하고, 배포 스크립트는 공개키로 그 서명을 확인합니다. 서명용 개인키는 시크릿 저장소에 두거나 키 없는 서명(OIDC 기반)을 씁니다. 태그 대신 이미지 다이제스트(@sha256:…)로 배포하면 확인한 이미지와 실행하는 이미지가 같다는 점도 보장됩니다.
서명하지 않은 테스트 이미지를 같은 태그로 올린 뒤 배포가 멈추는지 확인합니다.
역직렬화(deserialization)는 바이트나 문자열을 프로그램 객체로 되돌리는 일입니다. Java의 ObjectInputStream은 데이터 안에 적힌 클래스 이름대로 객체를 만듭니다. 받는 쪽 코드가 Cart로 형변환하기 전에, 클래스패스에 있는 다른 클래스의 객체가 이미 만들어지고 그 클래스의 코드가 실행될 수 있습니다. 요청 본문, 쿠키, 메시지 큐처럼 밖에서 오는 데이터를 이렇게 복원하면 서버에서 의도하지 않은 코드가 돌 수 있습니다. JSON 라이브러리에서 "데이터가 클래스 이름을 정하는" 기능(Jackson의 기본 타이핑 등)을 켜도 같은 위험이 생깁니다.
// 위험한 모양: 요청 본문을 Java 객체로 그대로 복원
@PostMapping("/cart/import")
public void importCart(HttpServletRequest req) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(req.getInputStream())) {
Cart cart = (Cart) in.readObject(); // 어떤 클래스를 만들지 데이터가 정한다
}
}
// 고친 모양: 형식을 고정한 DTO로만 받는다(JSON, 기본 타이핑 끔)
public record CartImport(@Size(max = 100) List<Item> items) {
public record Item(long productId, @Min(1) int qty) {}
}
@PostMapping("/cart/import")
public void importCart(@Valid @RequestBody CartImport body) { cartService.importItems(body); }
// Java 직렬화를 피할 수 없다면 허용 타입만 받는다
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
"maxdepth=5;com.example.cart.*;java.util.ArrayList;!*"));
업데이트 파일이나 원격 설정을 받는 경우에는 서명을 확인한 뒤에만 읽습니다.
코드에서 ObjectInputStream, readObject, activateDefaultTyping, @JsonTypeInfo(use = Id.CLASS)를 검색해 밖에서 오는 데이터에 쓰인 곳이 없는지 봅니다. 허용 목록에 없는 타입이 들어오면 InvalidClassException이 나는지 테스트로 확인합니다.
목록에 없는 서버와 부품은 아무도 패치하지 않습니다. SBOM과 KEV 대조로 무엇이 들어 있고 무엇을 먼저 고칠지 정합니다. CI 도구는 확인한 그 버전만 실행하도록 커밋 해시로 고정하고, CI에 오래 사는 키를 두지 않습니다. 운영에는 서명을 확인한 이미지만 올리고, 실행 중에는 밖에서 온 데이터가 만들 객체의 종류를 서버가 정합니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다. EU 집행위원회 사고의 경로는 CERT-EU가 "높은 확신"으로 평가한 것이고, 태그 변경 수는 위즈의 분석입니다.
이전 글 코드 밖에 둘 것: 시크릿 저장소, 열린 저장소, 오류 응답과 보안 헤더
다음 글 7개월을 줄이는 기록: 감사 로그, 대량 조회 탐지, 마스킹, 지울 수 없는 로그
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록