Research/CVE-2026-12793
N-dayCVE-2026-12793CriticalPublic

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).

Dockerfile
# Loopback lab image pin for CVE-2026-12793. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# 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.

YAML
# 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/apache2

The 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
<?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/:

Plain text
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.py

The 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.

PHP
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:

PHP
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.

PHP
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-field returning Invalid form ID or Invalid security signature. Wrong door on this build.
  • nonce_failed / csrf_failed. The lab fixture sets load_nonce=hide and use_csrf=false because a rendered form would. Fighting nonce is not the CVE.
  • A subscriber. _jf_actions user_role was not administrator. That is a seed miss, not a patch.
  • Submitting a real jet-form-builder CPT 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:

Plain text
carrier GET status=200
status=200 len=154
{"user_id":2,"status":"success"}
user_id 2
SUCCESS CVE-2026-12793

Small 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