Segurium Research Depicter
Depicter 4.8.1 fixes stored SQL injection in lead exports
- Plugin
- Depicter
depicter - Affected versions
- 4.8.0 and earlier
- Fixed in
4.8.1- Class
- SQL injection
- Severity
- high Segurium Research assessment. No CVSS score published yet.
- CVE
- None assigned at the time of writing
- Installs
- 80,000 active
- Patch released
- Sources
Depicter shipped 4.8.1 on 2026-08-27. The plugin builds sliders and popups. It also collects form leads and stores them in its own tables. wordpress.org lists it on ~80,000 sites.
The vendor changelog names two security fixes. The first says “a specially crafted form submission could be stored and later executed as part of a database query”. The second says “the sort parameters on the Leads, Dashboard screens” were “passed into the database”. Both lines are specific, and the code backs both.
The diff shows where. The lead export built a raw SQL fragment out of stored form field names. Those names come from visitors. Three repository classes also passed request sort values into ORDER BY with no check on either the column or the direction.
What the release fixes
The export query in LeadRepository wrote each lead field name into the SQL text by hand. It used SqlRaw on purpose, to skip the query builder’s identifier filter. The name went in twice, both times between single quotes, with no escaping on either. 4.8.1 binds the compared value through $wpdb->prepare() and rebuilds the alias from a fixed character set. Three repositories also gained column allowlists for sorting.
- Affected versions: 4.8.0 and earlier
- Fixed in: 4.8.1
- Class: SQL injection (
sqli) - Installs: ~80,000 sites, per wordpress.org/plugins/depicter/
How the bug works
The export query wrote visitor input into SQL text
Leads live in two tables. One row per lead, and one row per field value in a join table aliased lf. To export leads as a flat grid, the code pivots the field rows into columns. Here is that loop in 4.8.0:
foreach( $leadFieldTableColumns as $leadFieldTableColumn ){
// using sqlRaw to bypass 'tickSqlName' filter
$columns[] = new SqlRaw("MAX(IF(lf.name = '{$leadFieldTableColumn}', lf.value, NULL)) AS '{$leadFieldTableColumn}'");
}
SqlRaw marks a string as finished SQL. The query builder does not touch it again. The comment states the reason: the author wanted to skip the tickSqlName filter, which is the thing that would normally quote an identifier. So $leadFieldTableColumn reaches MySQL exactly as stored. It appears in two places. Once as a compared value inside IF(lf.name = '...'). Once as a column alias after AS '...'.
Where does that string come from? The patch answers in its own comments. This one sits above the new loop:
// prefix and add leadField columns for joint query.
// Field names come from public form submissions, so they are never interpolated
// into SQL: the compared value is bound and the alias is rebuilt from a safe
// character set.
And the docblock of the new fieldAlias() helper repeats it:
* Lead field names are supplied by visitors submitting a form, so only a known
* safe character set is allowed through.
That is the vendor saying the field name is attacker input. A visitor submits a form. The field name from that submission is stored in lf.name. Nothing runs at that moment. The stored string sits in the table.
Later, someone exports leads. The export reads the distinct stored names and runs the loop above. Now the string is part of the query text. A single quote inside the stored name closes the string literal early. Everything after that quote is parsed by MySQL as SQL, not as data. The same happens in the alias position after AS.
This is second-order injection. The write and the execution are two different requests. They can be days apart, by two different people. The attacker needs no account to write. The person who triggers it is the site owner running an export.
There was no broken check to fool on this path. There was no check. No escape function ran on $leadFieldTableColumn between the form and the query.
What 4.8.1 does instead
The loop now splits the two uses apart:
$fieldAliases = [];
foreach( $leadFieldTableColumns as $leadFieldTableColumn ){
$alias = $this->fieldAlias( $leadFieldTableColumn );
// drop unusable or colliding aliases instead of emitting broken SQL
if( '' === $alias || isset( $fieldAliases[ $alias ] ) ){
continue;
}
$fieldAliases[ $alias ] = true;
// using sqlRaw to bypass 'tickSqlName' filter
$columns[] = new SqlRaw( sprintf(
"MAX(IF(lf.name = %s, lf.value, NULL)) AS `%s`",
$this->quote( $leadFieldTableColumn ),
$alias
) );
}
The compared value goes through quote(). The alias goes through fieldAlias() and moves from single quotes to backticks.
quote() is new:
private function quote( $value ): string {
global $wpdb;
return $wpdb->remove_placeholder_escape( $wpdb->prepare( '%s', (string) $value ) );
}
$wpdb->prepare( '%s', $value ) escapes the value and adds the surrounding quotes itself. That is why the new template has no quotes around %s. Leaving them would double them.
The remove_placeholder_escape() call handles a second problem. prepare() rewrites every % in its output as a random placeholder hash. That stops a second prepare() call from reading an escaped value as a new format spec. This fragment never goes through prepare() again. The query builder runs the string as is. So the hash has to come back out as a plain %, or a field name holding a percent sign would export as a hash. The docblock says the same thing:
* Mirrors TypeRocket\Database\Query::prepareValue(). The placeholder escape is
* removed because the returned fragment is spliced into SQL that the query builder
* runs itself, rather than being passed back through $wpdb::prepare().
fieldAlias() handles the identifier:
private function fieldAlias( $name ): string {
$alias = preg_replace( '/[^\p{L}\p{N}_\- ]/u', '', (string) $name );
// preg_replace() returns null on malformed UTF-8, fall back to an ASCII only pass
if( null === $alias ){
$alias = preg_replace( '/[^A-Za-z0-9_\- ]/', '', (string) $name );
}
return trim( (string) $alias );
}
This is an allowlist, not a blocklist. It keeps Unicode letters, Unicode digits, underscore, hyphen and space. It deletes everything else. The single quote goes. The backtick goes. Semicolons, parentheses and comment markers go. Hyphen and space survive, which is why the alias needs backticks around it in the template.
The /u modifier makes PCRE validate the subject as UTF-8. On invalid bytes preg_replace returns null instead of a string. Without the fallback, that null would cast to an empty string and the column would drop out of the export for no visible reason. The fallback runs the same allowlist byte by byte with no /u. Both paths only ever remove characters, so neither one lets an unsafe byte through.
The caller then drops two cases. An empty alias means the name held nothing usable, so no column is emitted. A duplicate alias means two different field names reduced to the same string, so the second is skipped. Without that check, the query would carry two columns with one name. This is also the “Improved” line in the changelog about export column headers.
Sort parameters reached ORDER BY
The second half of the patch is a different bug in the same file, plus two more repositories.
Here is the list query in 4.8.0:
if( ! empty( $args['orderBy'] ) && ! empty( $args['order'] ) ){
// Check if we are sorting by a known column in the main lead table
if ( in_array( $args['orderBy'], $this->lead()->getTableColumns() ) ) {
$leads->orderBy( "{$leadTable}.{$args['orderBy']}", $args['order'] );
} else {
$leads->orderBy( $args['orderBy'], $args['order'] );
}
}
A check is there, and it is worth reading closely, because it looks like a gate and is not one. in_array() decides which of two branches runs. It never rejects anything. If orderBy matches a real lead table column, the code adds the table prefix. If it does not match, the else branch passes the value through as given. That branch exists on purpose: a user can sort by a custom field column, and a custom field name is not a lead table column. So the design needed an escape hatch, and the escape hatch accepted any string.
$args['order'] is worse. Neither branch looks at it. It goes to the builder in both paths.
4.8.1 replaces both branches with one call:
// Resolve ordering against known columns only, never raw request input.
list( $orderByColumn, $orderDirection ) = $this->resolveOrder( $args, $leadTable, [ 'fieldName', 'fieldValue' ] );
$leads->orderBy( $orderByColumn, $orderDirection );
And resolveOrder() is the new gate:
private function resolveOrder( array $args, string $tableAlias, array $extraAllowed = [] ): array {
$direction = 'ASC' === strtoupper( (string) ( $args['order'] ?? '' ) ) ? 'ASC' : 'DESC';
$orderBy = (string) ( $args['orderBy'] ?? '' );
if( in_array( $orderBy, $this->lead()->getTableColumns(), true ) ){
return [ "{$tableAlias}.{$orderBy}", $direction ];
}
// aliases already use a safe character set, but may contain characters that
// tickSqlName() would strip, so quote them explicitly
if( in_array( $orderBy, $extraAllowed, true ) ){
return [ new SqlRaw( '`' . $orderBy . '`' ), $direction ];
}
return [ "{$tableAlias}.id", $direction ];
}
Three things changed. The direction collapses to the two literal strings ASC and DESC. Anything that is not ASC becomes DESC, so no request text survives. The in_array() calls now pass true for strict comparison, which stops PHP type juggling from matching a non-string against a column name. And the unknown case no longer falls through to raw input. It returns {$tableAlias}.id.
The export query got the same treatment, with one difference in the third argument:
// Resolve ordering against lead columns and the aliases actually emitted above.
list( $orderByColumn, $orderDirection ) = $this->resolveOrder( $args, 'l', array_keys( $fieldAliases ) );
array_keys( $fieldAliases ) is the set of aliases the loop just emitted. So sorting by a custom field still works, and the only strings accepted are ones that already passed fieldAlias(). The list query passes a fixed pair, [ 'fieldName', 'fieldValue' ], which are join columns rather than dynamic names.
One limit here. The builder’s own orderBy() runs a tickSqlName filter on column names, according to both the old comment and the new one. TypeRocket’s query builder is not in this diff, so how much of a raw column string that filter stripped in 4.8.0 is not established from these bytes. The direction argument is the clear part. The fix hardcodes it, and the vendor states that sort parameters reached the database.
DocumentRepository had the same shape and got the same fix, with a static column list:
private const ALLOWED_ORDER_BY = [
'id', 'name', 'slug', 'type', 'author',
'sections_count', 'created_at', 'modified_at', 'status', 'parent'
];
if ( !empty( $args['orderBy'] ) && !empty( $args['order'] ) ) {
- $documents = $documents->orderBy( $args['orderBy'], $args['order'] );
+ // Resolve ordering against a known column list, never raw request input.
+ $order = 'ASC' === strtoupper( (string) $args['order'] ) ? 'ASC' : 'DESC';
+ $orderBy = in_array( $args['orderBy'], self::ALLOWED_ORDER_BY, true ) ? $args['orderBy'] : 'modified_at';
+
+ $documents = $documents->orderBy( $orderBy, $order );
}
DocumentAnalyticsRepository got three constants and four guards:
private const ALLOWED_ORDER_BY = ['created_at', 'id', 'source_id', 'event_type'];
private const ALLOWED_ORDER = ['ASC', 'DESC'];
private const ALLOWED_EVENT_TYPES = ['view', 'click', 'impression', 'submission'];
In getByDocumentId() the sort block lost its branch and its fallback:
- if (!empty($args['orderBy']) && !empty($args['order'])) {
- $query->orderBy($args['orderBy'], $args['order']);
- } else {
- $query->orderBy('created_at', 'DESC');
- }
+ $orderBy = in_array( $args['orderBy'] ?? '', self::ALLOWED_ORDER_BY, true ) ? $args['orderBy'] : 'created_at';
+ $order = in_array( strtoupper( $args['order'] ?? '' ), self::ALLOWED_ORDER, true ) ? strtoupper($args['order']) : 'DESC';
+ $query->orderBy($orderBy, $order);
The same file also constrains event_type at three points. create() now returns false for any type outside the list, so an unknown type is never stored. getByDocumentId() and getCount() both check the type before using it in a where() clause. Those where() calls go through the builder rather than SqlRaw, so this reads as defence in depth on a stored value. The diff does not show where create() is called from, so whether a visitor can set eventType is not established here.
One more hunk sits in AnalyticsAjaxController:
$metaData = $sanitizedMeta;
+ } else {
+ $metaData = [];
}
This gives $metaData a defined value when the request carries no meta. The diff does not show the lines above the if, so what the variable held before is not visible. Treat this as housekeeping, not as part of the injection fix.
What an attacker gets
The injection sits inside a SELECT that the export runs and returns as rows. A stored field name that breaks out of its literal can change what that query reads. That means the rest of the database on the same connection: the users table, password hashes, plugin options, anything the site stores. The entry point needs no account. The trigger is an ordinary export by someone who already has access to leads.
The database user is WordPress’s own, so its rights are whatever the host granted. On most shared hosting that is read and write on the site’s own schema and nothing else.
Who is exposed
Sites on Depicter 4.8.0 and earlier. 4.8.1 carries the fix.
The stored injection needs Depicter forms in use and leads being collected. A site that runs Depicter for sliders only, with no form and no rows in the lead field table, has no path here. The attacker needs no account and no privileged role to write the value. They need a published Depicter form that accepts submissions.
Nothing runs when the submission arrives. The stored string only reaches SQL when the export query builds. So the second half of the chain depends on a site owner or an editor exporting leads. That is routine work, not a rare event, but it does mean a site can hold a stored payload that has never fired.
The ORDER BY paths are separate. They need a request that reaches the Leads or Dashboard screens with orderBy and order set. The vendor names both screens, and both sit in wp-admin. The diff does not include the controllers, the hook registrations or the capability checks for those screens. So which role can reach them is not established from these bytes, and neither is whether the request is an AJAX action or a normal admin page load.
What to do
Update Depicter to 4.8.1. That is the whole fix for both issues.
On a site that ran 4.8.0 or earlier with forms live, a few checks are worth running.
Read the stored lead field names. The column is name in the lead field table, the one aliased lf in the export query. Field names on a real form are short and plain. A name holding a single quote, a comment marker, a parenthesis or a SQL keyword is a probe, and it was stored by a form submission rather than typed by you.
Compare an export before and after the update. 4.8.1 drops any field whose name reduces to an empty alias, and drops the second of two names that reduce to the same alias. A column that disappears after updating points at a name that fieldAlias() stripped, which is worth reading in full.
Check your database error log around the times of past lead exports. A stored value that breaks the query text produces a MySQL syntax error, and a failed attempt shows up there before a working one does.
Check your administrator accounts and your wp_options rows for entries you did not create. A read through a SELECT pulls data out. Writing a new admin needs a further step, and the diff does not show one, so treat an unexpected account as a separate problem rather than a proven follow-on.
The patch works on three points. Lead field names are escaped by $wpdb->prepare() before they reach the comparison, and rebuilt from a letters-digits-underscore-hyphen-space set before they become an alias. Sort columns resolve against real table columns or the aliases the query itself emitted, and fall back to the row ID when they match neither. Sort direction is now one of two hardcoded strings. If you have updated, you are done.