CVE-2026-12793: JetFormBuilder, unauthenticated RCE
JetFormBuilder 3.6.2 accepts any post id as a form. parse_blocks on a regular post plus a Register User action is an administrator. The default submit hook is not the router. A 200 with a WordPress theme is not the bug.
- Name
- CVE-2026-12793: JetFormBuilder, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-12793
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- Crocoblock
- Affected
- JetFormBuilder Dynamic Blocks Form Builder (jetformbuilder), all versions through 3.6.2; patched in 3.6.2.1
- Published
- 18 Sept 2026
- Updated
- 18 Sept 2026
- Tags
- n-day, wordpress, privilege-escalation, cwe-269, unauthenticated, jetformbuilder
The advisory named a field
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-12793 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.
CVE-2026-12793 (NVD, GHSA-579w-q4cr-j8hc) is unauthenticated privilege escalation in JetFormBuilder 3.6.2, slug jetformbuilder. Wordfence scored it 9.8 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). Finder: daroo. CWE-269. Patched in 3.6.2.1.
The advisory names _jet_engine_booking_form_id. That is a POST field (Form_Handler::$form_key). It is not an HTTP action=. If you POST jet_form_builder_submit=submit because that string is in the source as a default, you get the theme back, tens of kilobytes of HTML, status 200, and a very convincing sense that you have done something. You have not. The option table rewrote the router pair on first load.
This is the map I used to get from that 200 to an administrator. Isolated lab, loopback only. I am not publishing a shell. An unauthenticated admin account is RCE on WordPress. The stack is in the CVE repo so you can run it at home. The client is the repo above.
What an attacker can do
Unauthenticated POST / with the live hook pair, that post id, and login/email/password. JSON status: success plus a numeric user_id. That user is an administrator. Creating the user is the proof.
The lab (run this at home)
Source of truth is lab/ on GitHub. The Dockerfile pins wordpress:6.4-php8.2-apache. Compose adds mysql:8.0 and wordpress:cli. Port on loopback only. Bind 3.6.2 of the plugin next to compose as ./jetformbuilder (SVN tag).
# Loopback lab image pin for CVE-2026-12793. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# CVE-2026-12793 local WordPress lab. Loopback only.
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: root
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
wordpress:
image: wordpress:6.4-php8.2-apache
ports:
- "127.0.0.1:8088:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DEBUG: "1"
WORDPRESS_CONFIG_EXTRA: |
define('WP_DEBUG_LOG', '/var/log/lab/debug.log');
define('WP_DEBUG_DISPLAY', false);
volumes:
- wp_data:/var/www/html
- ./jetformbuilder:/var/www/html/wp-content/plugins/jetformbuilder:ro
depends_on:
db:
condition: service_healthy
wpcli:
image: wordpress:cli
user: "33:33"
volumes:
- wp_data:/var/www/html
- ./jetformbuilder:/var/www/html/wp-content/plugins/jetformbuilder:ro
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
depends_on:
wordpress:
condition: service_started
entrypoint: ["sleep", "infinity"]
volumes:
wp_data:docker-compose.override.yml only routes logs. It is not required to hit the sink.
# App processes should log to stdout and/or /var/log/lab.
services:
db:
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
- ./logs/db:/var/log/lab
- ./logs/db-mysql:/var/log/mysql
wordpress:
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
- ./logs/wordpress:/var/log/lab
- ./logs/wordpress-apache:/var/log/apache2The CVE is "any post id is a form." A real jet-form-builder CPT is intended behavior. The fixture is a regular published post whose Gutenberg content is JetForms field blocks, plus _jf_actions that register an administrator. The randomized router pair is written into the post body so REST will hand it back the way a rendered form would.
<?php
/**
* Lab fixture for JetFormBuilder 3.6.2 (CVE-2026-12793).
* Creates a regular post (not jet-form-builder CPT) whose content is form
* schema. The plugin accepts that id as _jet_engine_booking_form_id.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$opts = json_decode( (string) get_option( 'jet_form_builder_settings__options-tab', '' ), true );
$hook_key = is_array( $opts ) ? (string) ( $opts['gfb_request_args_key'] ?? '' ) : '';
$hook_val = is_array( $opts ) ? (string) ( $opts['gfb_request_args_value'] ?? '' ) : '';
if ( $hook_key === '' || $hook_val === '' ) {
WP_CLI::error( 'gfb_request_args_key/value missing · activate jetformbuilder first' );
}
$slug = 'jfb-lab-carrier';
$content = implode(
"\n",
array(
'<!-- wp:paragraph -->',
'<p>JFB_HOOK_KEY=' . esc_html( $hook_key ) . ' JFB_HOOK_VAL=' . esc_html( $hook_val ) . '</p>',
'<!-- /wp:paragraph -->',
'<!-- wp:jet-forms/text-field {"name":"login","label":"login"} /-->',
'<!-- wp:jet-forms/text-field {"name":"email","field_type":"email","label":"email"} /-->',
'<!-- wp:jet-forms/text-field {"name":"password","field_type":"password","label":"password"} /-->',
)
);
$existing = get_page_by_path( $slug, OBJECT, 'post' );
if ( $existing ) {
$post_id = (int) $existing->ID;
wp_update_post(
array(
'ID' => $post_id,
'post_content' => $content,
'post_status' => 'publish',
)
);
} else {
$post_id = wp_insert_post(
array(
'post_type' => 'post',
'post_status' => 'publish',
'post_title' => 'jfb lab carrier',
'post_name' => $slug,
'post_content' => $content,
),
true
);
if ( is_wp_error( $post_id ) ) {
WP_CLI::error( $post_id->get_error_message() );
}
}
$actions = array(
array(
'id' => 1,
'type' => 'register_user',
'is_execute' => true,
'settings' => array(
'register_user' => array(
'fields_map' => array(
'login' => 'login',
'email' => 'email',
'password' => 'password',
),
'user_role' => 'administrator',
'allow_register' => false,
'add_user_id' => true,
),
),
),
);
$args = array(
'load_nonce' => 'hide',
'use_csrf' => false,
);
update_post_meta( $post_id, '_jf_actions', wp_json_encode( $actions ) );
update_post_meta( $post_id, '_jf_args', wp_json_encode( $args ) );
WP_CLI::success(
'carrier post_id=' . (int) $post_id
. ' slug=' . $slug
. ' type=' . get_post_type( $post_id )
. ' hook=' . $hook_key . '=' . $hook_val
);Bring-up from the CVE repo lab/:
git clone https://github.com/abraxas/CVE-2026-12793
cd CVE-2026-12793/lab
svn export https://plugins.svn.wordpress.org/jetformbuilder/tags/3.6.2 jetformbuilder
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
--url=http://127.0.0.1:8088 \
--title='CVE-2026-12793 Lab' \
--admin_user=admin \
--admin_password=labadmin \
--admin_email=lab@localhost.invalid \
--skip-email
docker compose exec -T wpcli wp plugin activate jetformbuilder
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-12793-Abraxas-Labs.pyThe seed is the fixture above; it is not in lab/ on GitHub. Activate the plugin before the seed: the seed reads gfb_request_args_key / gfb_request_args_value from options, and those only exist after first load. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.
What the tree actually registers
Request_Router::listen does not look for action=. It looks for $_REQUEST[hook_key] === hook_val, then method=ajax or reload.
protected function is_request(): Request_Router {
$hook_value = sanitize_text_field( wp_unslash( $_REQUEST[ $this->get_hook_name() ] ?? '' ) );
if ( $this->get_hook_value() !== $hook_value ) {
throw new Not_Router_Request( 'Request is not matched' );
}
return $this;
}Those hook names are not the source defaults jet_form_builder_submit / submit. Form_Handler::set_jfb_request_args overwrites them from option jet_form_builder_settings__options-tab (gfb_request_args_key, gfb_request_args_value): random 6+12 characters on first load. A POST that still sends the string from GitHub never matches. WordPress renders the theme. Eighty kilobytes of HTML. Status 200. Router miss.
The form id field is still _jet_engine_booking_form_id.
Any post is a form
Form_Handler::set_form_id is the whole CVE in one line:
public function set_form_id( $form_id ) {
$this->form_id = absint( $form_id );
return $this;
}absint. No post_type === 'jet-form-builder'. No capability check. A number is a form.
Then Block_Helper::get_blocks_by_post does what the name says: get_post, parse_blocks on post_content. Any post.
public static function get_blocks_by_post( $post_id ): array {
$post = get_post( $post_id );
if ( ! is_a( $post, \WP_Post::class ) ) {
return array();
}
return array_map(
function ( $block ) {
self::walk_by_reusable( $block );
return $block;
},
parse_blocks( $post->post_content )
);
}Action_Handler::set_form_actions loads _jf_actions for that id. If the meta says register_user with user_role administrator, Register_User_Action::do_action calls wp_insert_user. Unauthenticated, if you found the router pair.
There is a second tooth on the same schema: Advanced Validation Server_Side_Rule will call_user_func a PHP function named in the field rule if it is not in the not-allowed list. The witness I used was the Register User action, not a callback. Same missing type check.
Do not send this at REST validate-field. On 3.6.2 that route already type-checks. The live path is POST / with the randomized pair and method=ajax.
The 200 that was a homepage
The first client I pointed at this was polite. It used jet_form_builder_submit=submit because that is what a grep of the plugin finds. Function names and source defaults are not the runtime router. You get 200. You get the theme. You get no user.
A few other ways to lose without learning anything:
- POST
/200, ~80k HTML. Still sending the default hook. The option table won. - REST
/jet-form-builder/v1/validate-fieldreturning Invalid form ID or Invalid security signature. Wrong door on this build. nonce_failed/csrf_failed. The lab fixture setsload_nonce=hideanduse_csrf=falsebecause a rendered form would. Fighting nonce is not the CVE.- A subscriber.
_jf_actionsuser_rolewas not administrator. That is a seed miss, not a patch. - Submitting a real
jet-form-builderCPT id. That is the product working. This CVE is "any post."
The tell, once the router actually matches: the response shrinks. Theme HTML is tens of kilobytes. The JSON I wanted was tens of bytes and had "status":"success" plus a numeric user_id. If you are still reading a DOCTYPE, you are still lost.
What I actually did
Treat the on-disk product as the spec. Wordfence already named _jet_engine_booking_form_id. I read set_form_id, then get_blocks_by_post, then the action handler. Proof of concept: CVE-2026-12793-Abraxas-Labs.py.
Discover the pair like a visitor. GET the public REST document for the carrier post. The body has the post id and the hook pair, because the fixture printed them the way a real form's hidden fields would. Hard-coding jet_form_builder_submit=submit is how you collect homepages.
POST /, not admin-ajax. method=ajax, the live hook pair, _jet_engine_booking_form_id set to that post id, plus login / email / password. Unique login is the marker.
Witness in the body. JSON status success and a numeric user_id. Follow-up GET /wp/v2/users/<id> (or a later list) showing that login as administrator. Creating a user is the proof. A reverse shell is theatre.
Last lab run, trimmed:
carrier GET status=200
status=200 len=154
{"user_id":2,"status":"success"}
user_id 2
SUCCESS CVE-2026-12793Small JSON. Then an administrator. That is the whole argument. The client that produced it is on GitHub.
Wrong turns: jet_form_builder_submit=submit returns ~80k theme HTML; REST /jet-form-builder/v1/validate-field is the wrong door on 3.6.2; fighting nonce (load_nonce=hide, use_csrf=false on the fixture); a subscriber because _jf_actions user_role was not administrator (seed miss, not a patch); submitting a real jet-form-builder CPT id (the product working).
What this is not
It is not "JetFormBuilder has a register-user action." Of course it does. The bug is that set_form_id never asks whether the id is a form. A regular post with the right blocks and the right meta is enough.
Update to 3.6.2.1 or newer. Re-run the loopback client against the patched build: the witness must not appear.
I am not going to print a POST recipe you can paste at someone else's front page. The call chain and the missing type check are the useful part. If you own the box, run the proof of concept against loopback.
Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like the plugin was fine.
References
- Proof of concept: abraxas/CVE-2026-12793 · CVE-2026-12793-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-12793 · NVD · GHSA-579w-q4cr-j8hc · CWE-269
- Wordfence intel
- Trac 3.6.2: set_form_id, Request_Router::listen, get_blocks_by_post, set_form_actions, Register_User_Action
- Patch: changeset 3575346 (3.6.2.1)
- Source: SVN tags · Trac browser