Add upload of files to HttpClient & update a module to use it

This commit is contained in:
sjanusz 2022-04-11 16:34:26 +01:00
parent 253f791478
commit 4ec4b89d00
No known key found for this signature in database
GPG Key ID: 62086A0F9E2BB842
3 changed files with 628 additions and 9 deletions

View File

@ -149,6 +149,39 @@ class Client
opts['ssl'] = self.ssl
opts['ctype'] ||= 'application/x-www-form-urlencoded' if opts['method'] == 'POST'
if opts['files']
unless opts['files'].is_a?(::Array)
raise ::ArgumentError, "request_cgi: The provided `files` option is not valid. Expected: Array, Got: #{opts['files'].class}"
end
file_data = Rex::MIME::Message.new
opts['files'].each do |file_hash|
# The name of the HTTP form field
field_name = file_hash['name'].is_a?(::String) ? file_hash['name'] : nil
# Should we default to 'application/octet-stream', nil, or HttpClient's default of 'text/plain'?
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
mime_type = file_hash.fetch('mime_type', 'text/plain')
# Default to HttpClient's default option of '8bit'
encoding = file_hash.fetch('encoding', '8bit')
file_contents = get_file_data(file_hash['data'])
filename = file_hash.key?('filename') ? file_hash['filename'] : get_filename(file_hash['data']) || field_name
content_disposition = 'form-data'
content_disposition << "; name=\"#{field_name}\"" if field_name
content_disposition << "; filename=\"#{::CGI.escape(filename)}\"" if filename
file_data.add_part(file_contents, mime_type, encoding, content_disposition)
end
opts['data'] ? opts['data'] << file_data.to_s : opts['data'] = file_data.to_s
opts['ctype'] = "multipart/form-data; boundary=#{file_data.bound}"
end
ClientRequest.new(opts)
end
@ -731,6 +764,13 @@ protected
#
attr_accessor :ntlm_client
def get_file_data(file)
file.respond_to?('read') ? (contents = file.read; file.rewind; contents) : file.to_s
end
def get_filename(data)
data.is_a?(::Pathname) || data.is_a?(::File) ? ::File.basename(data) : nil
end
end
end

View File

@ -203,20 +203,18 @@ class MetasploitModule < Msf::Exploit::Remote
end
def upload_root_shell
mime = Rex::MIME::Message.new
mime.add_part(@csrf_token, nil, nil, 'form-data; name="nsp"')
mime.add_part('1', nil, nil, 'form-data; name="upload"')
mime.add_part('1000000', nil, nil, 'form-data; name="MAX_FILE_SIZE"')
mime.add_part(payload_zip, 'application/zip', 'binary',
'form-data; name="uploadedfile"; ' \
"filename=\"#{zip_filename}\"")
files = [
{ 'name' => 'nsp', 'data' => @csrf_token, 'encoding' => nil, 'filename' => nil, 'mime_type' => nil },
{ 'name' => 'upload', 'data' => 1, 'encoding' => nil, 'filename' => nil, 'mime_type' => nil },
{ 'name' => 'MAX_FILE_SIZE', 'data' => 1000000, 'encoding' => nil, 'filename' => nil, 'mime_type' => nil },
{ 'name' => 'uploadedfile', 'data' => payload_zip, 'mime_type' => 'application/zip', 'encoding' => 'binary', 'filename' => zip_filename }
]
res = send_request_cgi!(
'method' => 'POST',
'uri' => '/nagiosxi/admin/components.php',
'cookie' => @admin_cookie,
'ctype' => "multipart/form-data; boundary=#{mime.bound}",
'data' => mime.to_s
'files' => files
)
if res && res.code != 200

View File

@ -1,5 +1,7 @@
# -*- coding:binary -*-
require 'rex/mime'
# Note: Some of these tests require a failed
# connection to 127.0.0.1:1. If you have some crazy local
# firewall that is dropping packets to this, your tests
@ -228,6 +230,7 @@ RSpec.describe Rex::Proto::Http::Client do
# Not super sure why these are protected...
# Me either...
# Same here...
it "should refuse access to its protected accessors" do
expect {cli.ssl}.to raise_error NoMethodError
expect {cli.ssl_version}.to raise_error NoMethodError
@ -235,4 +238,582 @@ RSpec.describe Rex::Proto::Http::Client do
expect {cli.port}.to raise_error NoMethodError
end
context 'with files' do
subject(:cli) do
cli = Rex::Proto::Http::Client.new(ip)
cli.config['data'] = ''
cli.config['method'] = 'POST'
cli
end
let(:file_path) do
::File.join(::Msf::Config.install_root, 'spec', 'file_fixtures', 'string_list.txt')
end
let(:file) do
::File.open(file_path, 'rb')
end
let(:mock_boundary) do
'-----------------------------MockBoundary1234'
end
before(:each) do
file.rewind
allow(Rex::Text).to receive(:rand_text_numeric).with(30).and_return('MockBoundary1234')
end
it 'should parse field name and file object as data' do
files = [
{ 'name' => 'field1', 'data' => file }
]
request = cli.request_cgi({ 'files' => files })
# We are gsub'ing here as HttpClient does this gsub to non-binary file data
file_contents = file.read.gsub("\r", '').gsub("\n", "\r\n")
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 247\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="field1"; filename="string_list.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
#{file_contents}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should parse field name and binary file object as data' do
files = [
{ 'name' => 'field1', 'data' => file, 'encoding' => 'binary' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 247\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="field1"; filename="string_list.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: binary\r
\r
#{file.read}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should parse field name and binary file object as data with filename override' do
files = [
{ 'name' => 'field1', 'data' => file, 'encoding' => 'binary', 'filename' => 'my_file.txt' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 243\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="field1"; filename="my_file.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: binary\r
\r
#{file.read}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should parse data correctly when provided with a string' do
data = 'hello world'
files = [
{ 'name' => 'file1', 'data' => data }
]
request = cli.request_cgi({ 'files' => files })
expect(request.to_s).to include('Content-Disposition: form-data; name="file1"')
expect(request.to_s).to include(data)
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 234\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file1"; filename="file1"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
#{data}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should parse data correctly when provided with a string and mime type' do
data = 'hello world'
files = [
{ 'name' => 'file1', 'data' => data, 'mime_type' => 'text/plain' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 234\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file1"; filename="file1"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
#{data}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should parse data correctly when provided with a string, mime type and filename' do
data = 'hello world'
files = [
{ 'name' => 'file1', 'data' => data, 'mime_type' => 'text/plain', 'filename' => 'my_file.txt' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 240\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file1"; filename="my_file.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
#{data}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should parse data correctly when provided with a number' do
data = 123
files = [
{ 'name' => 'file1', 'data' => data, 'mime_type' => 'text/plain' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 226\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file1"; filename="file1"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
#{data}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should parse dat correctly when provided with an IO object' do
require 'stringio'
str = 'Hello World!'
files = [
{ 'name' => 'file1', 'data' => ::StringIO.new(str), 'mime_type' => 'text/plain', 'filename' => 'my_file.txt' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 241\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file1"; filename="my_file.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
#{str}\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle nil data values correctly' do
files = [
{ 'name' => 'nil_value', 'data' => nil }
]
request = cli.request_cgi({ 'files' => files })
# This could potentially return one less '\r'.
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 231\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="nil_value"; filename="nil_value"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle nil field values correctly' do
files = [
{ 'name' => nil, 'data' => '123' },
{ 'data' => '456' },
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 339\r
\r
#{mock_boundary}\r
Content-Disposition: form-data\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
123\r
#{mock_boundary}\r
Content-Disposition: form-data\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
456\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle nil field values and data correctly' do
files = [
{ 'name' => nil, 'data' => nil }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 191\r
\r
#{mock_boundary}\r
Content-Disposition: form-data\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle non-string field name values correctly' do
files = [
{ 'name' => false, 'data' => '123' },
{ 'name' => true, 'data' => '456' },
{ 'name' => ['hello'], 'data' => '789' },
{ 'name' => { k: 'val' }, 'data' => '101112' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 632\r
\r
#{mock_boundary}\r
Content-Disposition: form-data\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
123\r
#{mock_boundary}\r
Content-Disposition: form-data\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
456\r
#{mock_boundary}\r
Content-Disposition: form-data\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
789\r
#{mock_boundary}\r
Content-Disposition: form-data\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
101112\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle binary correctly' do
files = [
{ 'name' => 'field1', 'data' => "\x05\x00\x68\x65\x6c\x6c\x6f".unpack('Sa*'), 'encoding' => 'binary' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 239\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="field1"; filename="field1"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: binary\r
\r
[5, "hello"]\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle duplicate file and field names correctly' do
files = [
{ 'name' => 'file', 'data' => 'file1_content', 'filename' => 'duplicate.txt' },
{ 'name' => 'file', 'data' => 'file2_content', 'filename' => 'duplicate.txt' },
{ 'name' => 'file', 'data' => 'file2_content', 'filename' => 'duplicate.txt' },
# Note, this won't actually attempt to read a file - the content will be set to 'file.txt'
{ 'name' => 'file', 'data' => 'file.txt', 'filename' => 'duplicate.txt' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 820\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file"; filename="duplicate.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
file1_content\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file"; filename="duplicate.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
file2_content\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file"; filename="duplicate.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
file2_content\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file"; filename="duplicate.txt"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
file.txt\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should escape special characters in file names correctly without encoding' do
files = [
{ 'name' => 'file', 'data' => 'abc', 'filename' => "'t \"e 'st.txt'" }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 242\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file"; filename="#{::CGI.escape(files[0]['filename'])}"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
abc\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should escape special characters in file names correctly with encoding' do
files = [
{ 'name' => 'file', 'data' => 'abc', 'filename' => "'t \"e 'st.txt'", 'encoding' => 'base64' }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 244\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="file"; filename="#{::CGI.escape(files[0]['filename'])}"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: base64\r
\r
abc\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle nil filename values correctly' do
files = [
{ 'name' => 'example_name', 'data' => 'example_data', 'filename' => nil }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 224\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="example_name"\r
Content-Type: text/plain\r
Content-Transfer-Encoding: 8bit\r
\r
example_data\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle nil encoding values correctly' do
files = [
{ 'name' => 'example_name', 'data' => 'example_data', 'encoding' => nil }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 216\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="example_name"; filename="example_name"\r
Content-Type: text/plain\r
\r
example_data\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
it 'should handle nil mime type values correctly' do
files = [
{ 'name' => 'example_name', 'data' => 'example_data', 'mime_type' => nil }
]
request = cli.request_cgi({ 'files' => files })
expected = <<~EOF
POST / HTTP/1.1\r
Host: #{ip}\r
User-Agent: #{request.opts['agent']}\r
Content-Type: multipart/form-data; boundary=#{mock_boundary[2..-1]}\r
Content-Length: 223\r
\r
#{mock_boundary}\r
Content-Disposition: form-data; name="example_name"; filename="example_name"\r
Content-Transfer-Encoding: 8bit\r
\r
example_data\r
#{mock_boundary}--\r
EOF
expect(request.to_s).to eq(expected)
end
end
end