From 4f30bf590ce6049932dd6a5994767fbb1d25dc43 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:52:13 +0530 Subject: [PATCH 01/12] Add SSLPeerCertTrace mixin and HTTP peer certificate hook Introduces Msf::Exploit::Remote::SslPeerCertTrace with an SSLPeerCertTrace enum datastore option (off/metadata/full). HttpClient includes the mixin and calls ssl_peer_cert_trace after each send_recv; the helper reads conn.peer_cert, deduplicates per host:port, and formats the server certificate through the existing CertificateTracePresenter. --- lib/msf/core/exploit/remote/http_client.rb | 6 + .../exploit/remote/ssl_peer_cert_trace.rb | 84 ++++++++ .../remote/ssl_peer_cert_trace_spec.rb | 193 ++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb create mode 100644 spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb diff --git a/lib/msf/core/exploit/remote/http_client.rb b/lib/msf/core/exploit/remote/http_client.rb index 6a3ab6c79f064..062f24dcb858a 100644 --- a/lib/msf/core/exploit/remote/http_client.rb +++ b/lib/msf/core/exploit/remote/http_client.rb @@ -17,6 +17,7 @@ module Exploit::Remote::HttpClient include Msf::Auxiliary::LoginScanner include Msf::Exploit::Remote::Kerberos::Ticket::Storage include Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Options + include Msf::Exploit::Remote::SslPeerCertTrace # # Initializes an exploit module that exploits a vulnerability in an HTTP @@ -422,6 +423,11 @@ def send_request_raw(opts = {}, timeout = 20, disconnect = false) res = c.send_recv(r, actual_timeout) + if c.conn&.respond_to?(:peer_cert) + raw_cert = c.conn.peer_cert + ssl_peer_cert_trace(raw_cert, opts['rhost'] || rhost, (opts['rport'] || rport).to_i) if raw_cert + end + disconnect(c) if disconnect res diff --git a/lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb b/lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb new file mode 100644 index 0000000000000..43fb155205220 --- /dev/null +++ b/lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb @@ -0,0 +1,84 @@ +# -*- coding: binary -*- + +module Msf + # Shared helper for printing the server TLS peer certificate when Metasploit + # opens an SSL connection. Registers the +SSLPeerCertTrace+ advanced option as + # an enum that controls whether and how verbosely the certificate is printed. + # Reuses CertificateTracePresenter for formatting so output is consistent with + # the existing certificate trace facility. + # + # Include this mixin in any mixin that opens SSL connections, then call + # #ssl_peer_cert_trace with the raw peer certificate after the TLS handshake. + module Exploit::Remote::SslPeerCertTrace + def initialize(info = {}) + super + + register_advanced_options( + [ + OptEnum.new('SSLPeerCertTrace', [ + false, + 'Print the server TLS peer certificate (off/metadata/full)', + 'off', + ['off', 'metadata', 'full'] + ]) + ], Msf::Exploit::Remote::SslPeerCertTrace + ) + end + + # Returns true when peer certificate tracing is active. + # + # @return [Boolean] + def ssl_peer_cert_trace_enabled? + return false unless defined?(Msf::Trace::CertificateTracePresenter) + return false unless respond_to?(:datastore) && datastore + + datastore['SSLPeerCertTrace'] != 'off' + end + + # Prints the server TLS peer certificate at the configured verbosity level. + # Deduplicates per host:port so a module that sends many requests does not + # repeat the certificate on every one. + # + # @param raw_cert [OpenSSL::X509::Certificate, String] peer certificate or DER/PEM bytes + # @param host [String] remote hostname or IP (used as the dedup key) + # @param port [Integer] remote port (used as the dedup key) + # @return [void] + def ssl_peer_cert_trace(raw_cert, host, port) + return unless ssl_peer_cert_trace_enabled? + return if raw_cert.nil? + + @_ssl_peer_cert_seen ||= {} + key = "#{host}:#{port}" + return if @_ssl_peer_cert_seen[key] + + @_ssl_peer_cert_seen[key] = true + + mode = datastore['SSLPeerCertTrace'] + presenter = Msf::Trace::CertificateTracePresenter.new(raw_cert) + output = mode == 'full' ? presenter.to_s_full : presenter.to_s_metadata + return unless output + + print_line(ssl_peer_cert_trace_colorize(output)) + end + + private + + # Wraps +text+ in the response-side color from +HttpTraceColors+ when that + # option is present (HTTP context), or defaults to the red/blu pair used by + # the rest of the certificate trace facility. + # + # @param text [String] + # @return [String] + def ssl_peer_cert_trace_colorize(text) + colors = datastore['HttpTraceColors'].to_s + colors = 'red/blu' if colors.blank? + colors += '/' unless colors.include?('/') + + _req, resp = colors.gsub('/', ' / ').split('/').map do |c| + c&.strip.blank? ? '' : "%bld%#{c.strip}" + end + + resp.blank? ? text : "%clr#{resp}#{text}%clr" + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb b/spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb new file mode 100644 index 0000000000000..c48a789135971 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/core/trace/certificate_trace_presenter' + +RSpec.describe Msf::Exploit::Remote::SslPeerCertTrace do + include_context 'Msf::Simple::Framework' + + let(:key) { OpenSSL::PKey::RSA.generate(2048) } + + let(:cert) do + c = OpenSSL::X509::Certificate.new + c.subject = OpenSSL::X509::Name.parse('/CN=example.com') + c.issuer = OpenSSL::X509::Name.parse('/CN=Test CA') + c.public_key = key.public_key + c.serial = OpenSSL::BN.new('1') + c.version = 2 + c.not_before = Time.utc(2026, 1, 1) + c.not_after = Time.utc(2027, 1, 1) + c.sign(key, OpenSSL::Digest::SHA256.new) + c + end + + subject do + klass = Class.new(Msf::Exploit) do + include Msf::Exploit::Remote::SslPeerCertTrace + end + mod = klass.new + allow(mod).to receive(:framework).and_return(framework) + mod + end + + # ── option registration ────────────────────────────────────────────────────── + + describe 'option registration' do + it 'registers the SSLPeerCertTrace advanced option' do + expect(subject.datastore.has_key?('SSLPeerCertTrace')).to be true + end + + it 'defaults to off' do + expect(subject.datastore['SSLPeerCertTrace']).to eq('off') + end + + it 'accepts metadata as a valid value' do + subject.datastore['SSLPeerCertTrace'] = 'metadata' + expect(subject.datastore['SSLPeerCertTrace']).to eq('metadata') + end + + it 'accepts full as a valid value' do + subject.datastore['SSLPeerCertTrace'] = 'full' + expect(subject.datastore['SSLPeerCertTrace']).to eq('full') + end + + it 'rejects unknown values' do + expect { subject.datastore['SSLPeerCertTrace'] = 'verbose' }.to raise_error(Msf::OptionValidateError) + end + end + + # ── #ssl_peer_cert_trace_enabled? ─────────────────────────────────────────── + + describe '#ssl_peer_cert_trace_enabled?' do + it 'returns false when mode is off' do + subject.datastore['SSLPeerCertTrace'] = 'off' + expect(subject.ssl_peer_cert_trace_enabled?).to be false + end + + it 'returns true when mode is metadata' do + subject.datastore['SSLPeerCertTrace'] = 'metadata' + expect(subject.ssl_peer_cert_trace_enabled?).to be true + end + + it 'returns true when mode is full' do + subject.datastore['SSLPeerCertTrace'] = 'full' + expect(subject.ssl_peer_cert_trace_enabled?).to be true + end + end + + # ── #ssl_peer_cert_trace ──────────────────────────────────────────────────── + + describe '#ssl_peer_cert_trace' do + context 'when tracing is off' do + it 'produces no output' do + subject.datastore['SSLPeerCertTrace'] = 'off' + expect(subject).not_to receive(:print_line) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + end + + context 'when raw_cert is nil' do + it 'produces no output' do + subject.datastore['SSLPeerCertTrace'] = 'metadata' + expect(subject).not_to receive(:print_line) + subject.ssl_peer_cert_trace(nil, '192.168.1.1', 443) + end + end + + context 'when mode is metadata' do + before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } + + it 'prints a line containing [CertificateTrace]' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + + it 'includes the subject CN' do + expect(subject).to receive(:print_line).with(include('example.com')) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + + it 'does not include Serial (metadata omits extended fields)' do + expect(subject).to receive(:print_line).with(satisfy { |s| !s.include?('Serial') }) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + end + + context 'when mode is full' do + before { subject.datastore['SSLPeerCertTrace'] = 'full' } + + it 'prints a line containing [CertificateTrace]' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + + it 'includes Serial in full mode' do + expect(subject).to receive(:print_line).with(include('Serial')) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + + it 'includes Version in full mode' do + expect(subject).to receive(:print_line).with(include('Version')) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + end + + context 'when given raw DER bytes' do + before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } + + it 'parses and prints the certificate without raising' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.ssl_peer_cert_trace(cert.to_der, '192.168.1.1', 443) + end + end + + context 'deduplication' do + before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } + + it 'prints only once for repeated calls to the same host:port' do + expect(subject).to receive(:print_line).once + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + + it 'prints again for a different host:port' do + expect(subject).to receive(:print_line).twice + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + subject.ssl_peer_cert_trace(cert, '192.168.1.2', 443) + end + + it 'treats the same host on a different port as a separate entry' do + expect(subject).to receive(:print_line).twice + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 8443) + end + end + + context 'color wrapping' do + before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } + + it 'wraps output in the response color escape sequences by default' do + expect(subject).to receive(:print_line).with(satisfy { |s| + s.include?('%bld%blu') && s.include?('%clr') + }) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + + it 'uses HttpTraceColors when the option is set' do + subject.datastore.import_options_from_hash({ 'HttpTraceColors' => 'red/grn' }) + expect(subject).to receive(:print_line).with(satisfy { |s| + s.include?('%bld%grn') && s.include?('%clr') + }) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + + it 'omits color when the response side of HttpTraceColors is blank' do + subject.datastore.import_options_from_hash({ 'HttpTraceColors' => 'red/' }) + expect(subject).to receive(:print_line).with(satisfy { |s| + !s.include?('%bld%') && s.include?('[CertificateTrace]') + }) + subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) + end + end + end +end From b989a56718960b3521498b3335fb3274375a270b Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:43:20 +0530 Subject: [PATCH 02/12] Consolidate SSLPeerCertTrace into CertificateTrace per jheysel feedback Removes the standalone SSLPeerCertTrace mixin and SSLPeerCertTrace datastore option. Peer SSL certificate tracing now lives in CertificateTrace as #certificate_peer_cert_trace, reusing the existing CertificateTrace enum and CertificateTraceColors option so all cert output (issued, CSR, peer) is controlled by a single operator-facing option. Deduplication now uses the database when active (ssl.peer_cert_seen note per host:port, update: :unique) so the same server certificate is not reprinted across module runs. Falls back to in-memory dedup when no DB is connected. HttpClient is updated to include CertificateTrace instead of SslPeerCertTrace and calls certificate_peer_cert_trace after send_recv. The ssl_peer_cert_trace mixin file and its spec are removed; the 20 new peer cert examples are added to certificate_trace_spec.rb (46 examples, 0 failures). --- .../core/exploit/remote/certificate_trace.rb | 44 ++++ lib/msf/core/exploit/remote/http_client.rb | 4 +- .../exploit/remote/ssl_peer_cert_trace.rb | 84 -------- .../exploit/remote/certificate_trace_spec.rb | 164 +++++++++++++++ .../remote/ssl_peer_cert_trace_spec.rb | 193 ------------------ 5 files changed, 210 insertions(+), 279 deletions(-) delete mode 100644 lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb delete mode 100644 spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb diff --git a/lib/msf/core/exploit/remote/certificate_trace.rb b/lib/msf/core/exploit/remote/certificate_trace.rb index afe2d35f0e349..9eea5d18bb451 100644 --- a/lib/msf/core/exploit/remote/certificate_trace.rb +++ b/lib/msf/core/exploit/remote/certificate_trace.rb @@ -62,6 +62,50 @@ def certificate_trace(cert) print_line(certificate_trace_colorize(output)) end + # Surfaces the server TLS peer certificate after a TLS handshake. Uses + # +CertificateTrace+ and +CertificateTraceColors+ so peer cert output is + # consistent with issued-certificate and CSR output. Deduplicates per + # host:port using the database when active, falling back to in-memory dedup + # within a single module run when the database is not connected. + # + # @param cert [OpenSSL::X509::Certificate, String] peer certificate or DER bytes + # @param host [String] remote hostname or IP + # @param port [Integer] remote port + # @return [void] + def certificate_peer_cert_trace(cert, host, port) + return unless certificate_trace_enabled? + return if cert.nil? + + @_peer_cert_seen ||= {} + key = "#{host}:#{port}" + return if @_peer_cert_seen[key] + + if respond_to?(:framework) && framework&.db&.active + already_noted = framework.db.notes(ntype: 'ssl.peer_cert_seen').any? do |n| + n.host&.address == host && n.service&.port == port + end + if already_noted + @_peer_cert_seen[key] = true + return + end + framework.db.report_note( + host: host, port: port, proto: 'tcp', + ntype: 'ssl.peer_cert_seen', + data: {}, + update: :unique + ) + end + + @_peer_cert_seen[key] = true + + mode = datastore['CertificateTrace'] + presenter = Msf::Trace::CertificateTracePresenter.new(cert) + output = mode == 'full' ? presenter.to_s_full : presenter.to_s_metadata + return unless output + + print_line(certificate_trace_colorize(output)) + end + private # Wraps +text+ in the configured certificate trace color escape sequences. diff --git a/lib/msf/core/exploit/remote/http_client.rb b/lib/msf/core/exploit/remote/http_client.rb index 062f24dcb858a..6e0438fdd01c8 100644 --- a/lib/msf/core/exploit/remote/http_client.rb +++ b/lib/msf/core/exploit/remote/http_client.rb @@ -17,7 +17,7 @@ module Exploit::Remote::HttpClient include Msf::Auxiliary::LoginScanner include Msf::Exploit::Remote::Kerberos::Ticket::Storage include Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Options - include Msf::Exploit::Remote::SslPeerCertTrace + include Msf::Exploit::Remote::CertificateTrace # # Initializes an exploit module that exploits a vulnerability in an HTTP @@ -425,7 +425,7 @@ def send_request_raw(opts = {}, timeout = 20, disconnect = false) if c.conn&.respond_to?(:peer_cert) raw_cert = c.conn.peer_cert - ssl_peer_cert_trace(raw_cert, opts['rhost'] || rhost, (opts['rport'] || rport).to_i) if raw_cert + certificate_peer_cert_trace(raw_cert, opts['rhost'] || rhost, (opts['rport'] || rport).to_i) if raw_cert end disconnect(c) if disconnect diff --git a/lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb b/lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb deleted file mode 100644 index 43fb155205220..0000000000000 --- a/lib/msf/core/exploit/remote/ssl_peer_cert_trace.rb +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: binary -*- - -module Msf - # Shared helper for printing the server TLS peer certificate when Metasploit - # opens an SSL connection. Registers the +SSLPeerCertTrace+ advanced option as - # an enum that controls whether and how verbosely the certificate is printed. - # Reuses CertificateTracePresenter for formatting so output is consistent with - # the existing certificate trace facility. - # - # Include this mixin in any mixin that opens SSL connections, then call - # #ssl_peer_cert_trace with the raw peer certificate after the TLS handshake. - module Exploit::Remote::SslPeerCertTrace - def initialize(info = {}) - super - - register_advanced_options( - [ - OptEnum.new('SSLPeerCertTrace', [ - false, - 'Print the server TLS peer certificate (off/metadata/full)', - 'off', - ['off', 'metadata', 'full'] - ]) - ], Msf::Exploit::Remote::SslPeerCertTrace - ) - end - - # Returns true when peer certificate tracing is active. - # - # @return [Boolean] - def ssl_peer_cert_trace_enabled? - return false unless defined?(Msf::Trace::CertificateTracePresenter) - return false unless respond_to?(:datastore) && datastore - - datastore['SSLPeerCertTrace'] != 'off' - end - - # Prints the server TLS peer certificate at the configured verbosity level. - # Deduplicates per host:port so a module that sends many requests does not - # repeat the certificate on every one. - # - # @param raw_cert [OpenSSL::X509::Certificate, String] peer certificate or DER/PEM bytes - # @param host [String] remote hostname or IP (used as the dedup key) - # @param port [Integer] remote port (used as the dedup key) - # @return [void] - def ssl_peer_cert_trace(raw_cert, host, port) - return unless ssl_peer_cert_trace_enabled? - return if raw_cert.nil? - - @_ssl_peer_cert_seen ||= {} - key = "#{host}:#{port}" - return if @_ssl_peer_cert_seen[key] - - @_ssl_peer_cert_seen[key] = true - - mode = datastore['SSLPeerCertTrace'] - presenter = Msf::Trace::CertificateTracePresenter.new(raw_cert) - output = mode == 'full' ? presenter.to_s_full : presenter.to_s_metadata - return unless output - - print_line(ssl_peer_cert_trace_colorize(output)) - end - - private - - # Wraps +text+ in the response-side color from +HttpTraceColors+ when that - # option is present (HTTP context), or defaults to the red/blu pair used by - # the rest of the certificate trace facility. - # - # @param text [String] - # @return [String] - def ssl_peer_cert_trace_colorize(text) - colors = datastore['HttpTraceColors'].to_s - colors = 'red/blu' if colors.blank? - colors += '/' unless colors.include?('/') - - _req, resp = colors.gsub('/', ' / ').split('/').map do |c| - c&.strip.blank? ? '' : "%bld%#{c.strip}" - end - - resp.blank? ? text : "%clr#{resp}#{text}%clr" - end - end -end diff --git a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb index f3bb48c76609f..ecccc5dbeb729 100644 --- a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb +++ b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb @@ -181,4 +181,168 @@ end end end + + # ── #certificate_peer_cert_trace ──────────────────────────────────────────── + + describe '#certificate_peer_cert_trace' do + let(:peer_key) { OpenSSL::PKey::RSA.generate(2048) } + + let(:peer_cert) do + c = OpenSSL::X509::Certificate.new + c.subject = OpenSSL::X509::Name.parse('/CN=example.com/O=MSF Lab/C=US') + c.issuer = OpenSSL::X509::Name.parse('/CN=Test CA') + c.public_key = peer_key.public_key + c.serial = OpenSSL::BN.new('42') + c.version = 2 + c.not_before = Time.utc(2026, 1, 1) + c.not_after = Time.utc(2027, 1, 1) + c.sign(peer_key, OpenSSL::Digest::SHA256.new) + c + end + + let(:inactive_db) { double('db', active: false) } + + before do + allow(framework).to receive(:db).and_return(inactive_db) + end + + context 'when tracing is off' do + it 'produces no output' do + subject.datastore['CertificateTrace'] = 'off' + expect(subject).not_to receive(:print_line) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + end + + context 'when cert is nil' do + it 'produces no output' do + subject.datastore['CertificateTrace'] = 'metadata' + expect(subject).not_to receive(:print_line) + subject.certificate_peer_cert_trace(nil, '192.168.1.1', 443) + end + end + + context 'when mode is metadata' do + before { subject.datastore['CertificateTrace'] = 'metadata' } + + it 'prints a line containing [CertificateTrace]' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'includes the subject CN' do + expect(subject).to receive(:print_line).with(include('example.com')) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'does not include Serial (metadata omits extended fields)' do + expect(subject).to receive(:print_line).with(satisfy { |s| !s.include?('Serial') }) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + end + + context 'when mode is full' do + before { subject.datastore['CertificateTrace'] = 'full' } + + it 'prints a line containing [CertificateTrace]' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'includes Serial in full mode' do + expect(subject).to receive(:print_line).with(include('Serial')) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'includes Version in full mode' do + expect(subject).to receive(:print_line).with(include('Version')) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + end + + context 'with raw DER bytes' do + before { subject.datastore['CertificateTrace'] = 'metadata' } + + it 'parses and prints without raising' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.certificate_peer_cert_trace(peer_cert.to_der, '192.168.1.1', 443) + end + end + + context 'in-memory deduplication (no DB)' do + before { subject.datastore['CertificateTrace'] = 'metadata' } + + it 'prints only once for repeated calls to the same host:port' do + expect(subject).to receive(:print_line).once + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'prints again for a different host' do + expect(subject).to receive(:print_line).twice + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.2', 443) + end + + it 'treats the same host on a different port as a separate entry' do + expect(subject).to receive(:print_line).twice + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 8443) + end + end + + context 'DB-backed deduplication' do + let(:host_dbl) { double('host', address: '192.168.1.1') } + let(:service_dbl) { double('service', port: 443) } + let(:note_dbl) { double('note', host: host_dbl, service: service_dbl) } + let(:active_db) { double('db', active: true) } + + before do + subject.datastore['CertificateTrace'] = 'metadata' + allow(framework).to receive(:db).and_return(active_db) + allow(active_db).to receive(:report_note) + end + + it 'skips printing when a matching note already exists in the DB' do + allow(active_db).to receive(:notes).with(ntype: 'ssl.peer_cert_seen').and_return([note_dbl]) + expect(subject).not_to receive(:print_line) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'prints and records a note when the host:port has not been seen before' do + allow(active_db).to receive(:notes).with(ntype: 'ssl.peer_cert_seen').and_return([]) + expect(active_db).to receive(:report_note).with(hash_including( + host: '192.168.1.1', port: 443, ntype: 'ssl.peer_cert_seen' + )) + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'uses the in-memory cache so the DB is not queried on repeated calls' do + allow(active_db).to receive(:notes).with(ntype: 'ssl.peer_cert_seen').and_return([]).once + expect(subject).to receive(:print_line).once + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + end + + context 'color wrapping' do + before { subject.datastore['CertificateTrace'] = 'metadata' } + + it 'uses CertificateTraceColors for peer cert output' do + subject.datastore['CertificateTraceColors'] = 'red/grn' + expect(subject).to receive(:print_line).with(satisfy { |s| + s.include?('%bld%grn') && s.include?('%clr') + }) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + + it 'defaults to red/blu when CertificateTraceColors is not customized' do + expect(subject).to receive(:print_line).with(satisfy { |s| + s.include?('%bld%blu') && s.include?('%clr') + }) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end + end + end end diff --git a/spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb b/spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb deleted file mode 100644 index c48a789135971..0000000000000 --- a/spec/lib/msf/core/exploit/remote/ssl_peer_cert_trace_spec.rb +++ /dev/null @@ -1,193 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'msf/core/trace/certificate_trace_presenter' - -RSpec.describe Msf::Exploit::Remote::SslPeerCertTrace do - include_context 'Msf::Simple::Framework' - - let(:key) { OpenSSL::PKey::RSA.generate(2048) } - - let(:cert) do - c = OpenSSL::X509::Certificate.new - c.subject = OpenSSL::X509::Name.parse('/CN=example.com') - c.issuer = OpenSSL::X509::Name.parse('/CN=Test CA') - c.public_key = key.public_key - c.serial = OpenSSL::BN.new('1') - c.version = 2 - c.not_before = Time.utc(2026, 1, 1) - c.not_after = Time.utc(2027, 1, 1) - c.sign(key, OpenSSL::Digest::SHA256.new) - c - end - - subject do - klass = Class.new(Msf::Exploit) do - include Msf::Exploit::Remote::SslPeerCertTrace - end - mod = klass.new - allow(mod).to receive(:framework).and_return(framework) - mod - end - - # ── option registration ────────────────────────────────────────────────────── - - describe 'option registration' do - it 'registers the SSLPeerCertTrace advanced option' do - expect(subject.datastore.has_key?('SSLPeerCertTrace')).to be true - end - - it 'defaults to off' do - expect(subject.datastore['SSLPeerCertTrace']).to eq('off') - end - - it 'accepts metadata as a valid value' do - subject.datastore['SSLPeerCertTrace'] = 'metadata' - expect(subject.datastore['SSLPeerCertTrace']).to eq('metadata') - end - - it 'accepts full as a valid value' do - subject.datastore['SSLPeerCertTrace'] = 'full' - expect(subject.datastore['SSLPeerCertTrace']).to eq('full') - end - - it 'rejects unknown values' do - expect { subject.datastore['SSLPeerCertTrace'] = 'verbose' }.to raise_error(Msf::OptionValidateError) - end - end - - # ── #ssl_peer_cert_trace_enabled? ─────────────────────────────────────────── - - describe '#ssl_peer_cert_trace_enabled?' do - it 'returns false when mode is off' do - subject.datastore['SSLPeerCertTrace'] = 'off' - expect(subject.ssl_peer_cert_trace_enabled?).to be false - end - - it 'returns true when mode is metadata' do - subject.datastore['SSLPeerCertTrace'] = 'metadata' - expect(subject.ssl_peer_cert_trace_enabled?).to be true - end - - it 'returns true when mode is full' do - subject.datastore['SSLPeerCertTrace'] = 'full' - expect(subject.ssl_peer_cert_trace_enabled?).to be true - end - end - - # ── #ssl_peer_cert_trace ──────────────────────────────────────────────────── - - describe '#ssl_peer_cert_trace' do - context 'when tracing is off' do - it 'produces no output' do - subject.datastore['SSLPeerCertTrace'] = 'off' - expect(subject).not_to receive(:print_line) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - end - - context 'when raw_cert is nil' do - it 'produces no output' do - subject.datastore['SSLPeerCertTrace'] = 'metadata' - expect(subject).not_to receive(:print_line) - subject.ssl_peer_cert_trace(nil, '192.168.1.1', 443) - end - end - - context 'when mode is metadata' do - before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } - - it 'prints a line containing [CertificateTrace]' do - expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - - it 'includes the subject CN' do - expect(subject).to receive(:print_line).with(include('example.com')) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - - it 'does not include Serial (metadata omits extended fields)' do - expect(subject).to receive(:print_line).with(satisfy { |s| !s.include?('Serial') }) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - end - - context 'when mode is full' do - before { subject.datastore['SSLPeerCertTrace'] = 'full' } - - it 'prints a line containing [CertificateTrace]' do - expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - - it 'includes Serial in full mode' do - expect(subject).to receive(:print_line).with(include('Serial')) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - - it 'includes Version in full mode' do - expect(subject).to receive(:print_line).with(include('Version')) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - end - - context 'when given raw DER bytes' do - before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } - - it 'parses and prints the certificate without raising' do - expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) - subject.ssl_peer_cert_trace(cert.to_der, '192.168.1.1', 443) - end - end - - context 'deduplication' do - before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } - - it 'prints only once for repeated calls to the same host:port' do - expect(subject).to receive(:print_line).once - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - - it 'prints again for a different host:port' do - expect(subject).to receive(:print_line).twice - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - subject.ssl_peer_cert_trace(cert, '192.168.1.2', 443) - end - - it 'treats the same host on a different port as a separate entry' do - expect(subject).to receive(:print_line).twice - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 8443) - end - end - - context 'color wrapping' do - before { subject.datastore['SSLPeerCertTrace'] = 'metadata' } - - it 'wraps output in the response color escape sequences by default' do - expect(subject).to receive(:print_line).with(satisfy { |s| - s.include?('%bld%blu') && s.include?('%clr') - }) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - - it 'uses HttpTraceColors when the option is set' do - subject.datastore.import_options_from_hash({ 'HttpTraceColors' => 'red/grn' }) - expect(subject).to receive(:print_line).with(satisfy { |s| - s.include?('%bld%grn') && s.include?('%clr') - }) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - - it 'omits color when the response side of HttpTraceColors is blank' do - subject.datastore.import_options_from_hash({ 'HttpTraceColors' => 'red/' }) - expect(subject).to receive(:print_line).with(satisfy { |s| - !s.include?('%bld%') && s.include?('[CertificateTrace]') - }) - subject.ssl_peer_cert_trace(cert, '192.168.1.1', 443) - end - end - end -end From 28e9929bb616563ad9fbc7ade98b8325d378f451 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:03:24 +0530 Subject: [PATCH 03/12] Add SSL peer cert tracing to LDAP mixin (Phase 2) Includes CertificateTrace in Exploit::Remote::LDAP so LDAP over TLS connections surface the server certificate through the same CertificateTrace option and CertificateTraceColors that HTTP uses. No new operator-facing option is introduced. ldap_open is updated for both paths: - keep_open: false (Rex::Proto::LDAP::Client.open) -- wraps the caller block and reads peer_cert from the @open_connection socket before yielding to the caller. - keep_open: true (Rex::Proto::LDAP::Client._open) -- reads peer_cert from ldap.socket after the connection is established and returned. A private _ldap_trace_peer_cert helper centralises the socket guard and the certificate_peer_cert_trace call so both paths share the same nil-safe check. Plain LDAP connections (no TLS, no peer_cert) are silently ignored. Dedup and DB recording are handled by certificate_peer_cert_trace in the CertificateTrace mixin. Spec: 14 examples (7 new for peer cert tracing), 0 failures. --- lib/msf/core/exploit/remote/ldap.rb | 26 +++- spec/lib/msf/core/exploit/remote/ldap_spec.rb | 134 +++++++++++++++++- 2 files changed, 153 insertions(+), 7 deletions(-) diff --git a/lib/msf/core/exploit/remote/ldap.rb b/lib/msf/core/exploit/remote/ldap.rb index aa31a9ec15cd4..f004423cd12e9 100644 --- a/lib/msf/core/exploit/remote/ldap.rb +++ b/lib/msf/core/exploit/remote/ldap.rb @@ -11,6 +11,7 @@ module Msf module Exploit::Remote::LDAP include Msf::Exploit::Remote::Kerberos::Ticket::Storage include Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Options + include Msf::Exploit::Remote::CertificateTrace include Metasploit::Framework::LDAP::Client # Initialize the LDAP client and set up the LDAP specific datastore @@ -149,9 +150,15 @@ def ldap_connect(opts = {}, &block) def ldap_open(connect_opts, keep_open: false, &block) opts = resolve_connect_opts(connect_opts) if keep_open - Rex::Proto::LDAP::Client._open(opts, &block) + ldap = Rex::Proto::LDAP::Client._open(opts) + _ldap_trace_peer_cert(ldap.socket) + ldap else - Rex::Proto::LDAP::Client.open(opts, &block) + Rex::Proto::LDAP::Client.open(opts) do |ldap| + conn = ldap.instance_variable_get(:@open_connection) + _ldap_trace_peer_cert(conn&.socket) + block.call(ldap) + end end end @@ -346,5 +353,20 @@ def validate_query_result!(query_result, filter=nil) def ldap_escape_filter(string) Net::LDAP::Filter.escape(string) end + + private + + # Traces the TLS peer certificate on an LDAP socket when CertificateTrace + # is enabled. Delegates to #certificate_peer_cert_trace so dedup and + # formatting are handled consistently with the HTTP path. + # + # @param socket [Rex::Socket, nil] the LDAP socket after TLS handshake + # @return [void] + def _ldap_trace_peer_cert(socket) + return unless socket&.respond_to?(:peer_cert) + + raw_cert = socket.peer_cert + certificate_peer_cert_trace(raw_cert, rhost, rport.to_i) if raw_cert + end end end diff --git a/spec/lib/msf/core/exploit/remote/ldap_spec.rb b/spec/lib/msf/core/exploit/remote/ldap_spec.rb index 0a556621979fd..9e4e7960adaa6 100644 --- a/spec/lib/msf/core/exploit/remote/ldap_spec.rb +++ b/spec/lib/msf/core/exploit/remote/ldap_spec.rb @@ -57,12 +57,136 @@ end describe '#ldap_open' do - it do + let(:ldap_dbl) do + dbl = instance_double(Rex::Proto::LDAP::Client) + allow(dbl).to receive(:instance_variable_get).with(:@open_connection).and_return(nil) + dbl + end + + it 'delegates to Rex::Proto::LDAP::Client.open and forwards the caller block' do opts = {} - expect(Net::LDAP).to receive(:open).with(opts, &ldap_proc) - expect(subject).not_to receive(:get_connect_opts) - expect(subject).to receive(:resolve_connect_opts).and_return(opts) - subject.ldap_open(opts, &ldap_proc) + yielded = nil + allow(subject).to receive(:resolve_connect_opts).and_return(opts) + expect(Rex::Proto::LDAP::Client).to receive(:open).with(opts).and_yield(ldap_dbl) + subject.ldap_open(opts) { |ldap| yielded = ldap } + expect(yielded).to eq(ldap_dbl) + end + + context 'when keep_open is true' do + it 'calls _open and returns the client directly without yielding' do + opts = {} + allow(subject).to receive(:resolve_connect_opts).and_return(opts) + allow(ldap_dbl).to receive(:socket).and_return(nil) + expect(Rex::Proto::LDAP::Client).to receive(:_open).with(opts).and_return(ldap_dbl) + result = subject.ldap_open(opts, keep_open: true) + expect(result).to eq(ldap_dbl) + end + end + + context 'peer cert tracing (keep_open: false)' do + let(:tls_socket) do + dbl = double('socket') + allow(dbl).to receive(:respond_to?).with(:peer_cert).and_return(true) + allow(dbl).to receive(:peer_cert).and_return(tls_cert) + dbl + end + + let(:tls_conn) do + dbl = double('connection') + allow(dbl).to receive(:socket).and_return(tls_socket) + dbl + end + + let(:tls_ldap_dbl) do + dbl = instance_double(Rex::Proto::LDAP::Client) + allow(dbl).to receive(:instance_variable_get).with(:@open_connection).and_return(tls_conn) + dbl + end + + let(:tls_cert) do + key = OpenSSL::PKey::RSA.generate(2048) + c = OpenSSL::X509::Certificate.new + c.subject = OpenSSL::X509::Name.parse('/CN=ldap.example.com') + c.issuer = OpenSSL::X509::Name.parse('/CN=Test CA') + c.public_key = key.public_key + c.serial = OpenSSL::BN.new('1') + c.version = 2 + c.not_before = Time.utc(2026, 1, 1) + c.not_after = Time.utc(2027, 1, 1) + c.sign(key, OpenSSL::Digest::SHA256.new) + c + end + + before do + subject.datastore['CertificateTrace'] = 'metadata' + allow(subject).to receive(:framework).and_return(framework) + allow(framework).to receive(:db).and_return(double('db', active: false)) + allow(Rex::Proto::LDAP::Client).to receive(:open).and_yield(tls_ldap_dbl) + end + + it 'prints the peer cert when CertificateTrace is enabled and connection is TLS' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.ldap_open({}) { |_ldap| } + end + + it 'produces no output when CertificateTrace is off' do + subject.datastore['CertificateTrace'] = 'off' + expect(subject).not_to receive(:print_line) + subject.ldap_open({}) { |_ldap| } + end + end + + context 'peer cert tracing (keep_open: true)' do + let(:tls_socket) do + dbl = double('socket') + allow(dbl).to receive(:respond_to?).with(:peer_cert).and_return(true) + allow(dbl).to receive(:peer_cert).and_return(tls_cert) + dbl + end + + let(:keep_open_ldap_dbl) do + dbl = instance_double(Rex::Proto::LDAP::Client) + allow(dbl).to receive(:socket).and_return(tls_socket) + dbl + end + + let(:tls_cert) do + key = OpenSSL::PKey::RSA.generate(2048) + c = OpenSSL::X509::Certificate.new + c.subject = OpenSSL::X509::Name.parse('/CN=ldap.example.com') + c.issuer = OpenSSL::X509::Name.parse('/CN=Test CA') + c.public_key = key.public_key + c.serial = OpenSSL::BN.new('1') + c.version = 2 + c.not_before = Time.utc(2026, 1, 1) + c.not_after = Time.utc(2027, 1, 1) + c.sign(key, OpenSSL::Digest::SHA256.new) + c + end + + before do + subject.datastore['CertificateTrace'] = 'metadata' + allow(subject).to receive(:framework).and_return(framework) + allow(framework).to receive(:db).and_return(double('db', active: false)) + allow(Rex::Proto::LDAP::Client).to receive(:_open).and_return(keep_open_ldap_dbl) + end + + it 'prints the peer cert when CertificateTrace is enabled and connection is TLS' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.ldap_open({}, keep_open: true) + end + + it 'produces no output when CertificateTrace is off' do + subject.datastore['CertificateTrace'] = 'off' + expect(subject).not_to receive(:print_line) + subject.ldap_open({}, keep_open: true) + end + + it 'produces no output when the socket has no peer_cert (plain LDAP)' do + allow(tls_socket).to receive(:respond_to?).with(:peer_cert).and_return(false) + expect(subject).not_to receive(:print_line) + subject.ldap_open({}, keep_open: true) + end end end From dd05b109c77af76c9733910a986383b521db0c1a Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:15:14 +0530 Subject: [PATCH 04/12] Add SSL peer cert tracing to RDP mixin (Phase 2) Includes CertificateTrace in Exploit::Remote::RDP so the server TLS certificate is surfaced when an operator sets CertificateTrace to metadata or full. The hook is in swap_sock_plain_to_ssl, immediately after ssl.connect succeeds -- the OpenSSL socket has a valid peer_cert at that point and before it is wrapped into the Rex SslTcp layer. No new operator-facing option is introduced; RDP re-uses the same CertificateTrace enum as HTTP and LDAP. A nil peer_cert (e.g. when the server sends no certificate) is a silent no-op. Spec: 3 new examples covering TLS cert printed, off mode silent, and nil peer_cert silent. --- lib/msf/core/exploit/remote/rdp.rb | 3 + spec/lib/msf/core/exploit/remote/rdp_spec.rb | 76 ++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 spec/lib/msf/core/exploit/remote/rdp_spec.rb diff --git a/lib/msf/core/exploit/remote/rdp.rb b/lib/msf/core/exploit/remote/rdp.rb index 966614b822594..ed75c10c67c41 100644 --- a/lib/msf/core/exploit/remote/rdp.rb +++ b/lib/msf/core/exploit/remote/rdp.rb @@ -10,6 +10,7 @@ module Msf module Exploit::Remote::RDP require 'rc4' include Msf::Exploit::Remote::Tcp + include Msf::Exploit::Remote::CertificateTrace # # Creates an instance of a RDP exploit module. @@ -1181,6 +1182,8 @@ def swap_sock_plain_to_ssl raise end + certificate_peer_cert_trace(ssl.peer_cert, rhost, rport.to_i) + self.rdp_sock.extend(Rex::Socket::SslTcp) self.rdp_sock.sslsock = ssl self.rdp_sock.sslctx = ctx diff --git a/spec/lib/msf/core/exploit/remote/rdp_spec.rb b/spec/lib/msf/core/exploit/remote/rdp_spec.rb new file mode 100644 index 0000000000000..0a8a3114f5905 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/rdp_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::RDP do + include_context 'Msf::Simple::Framework' + + subject do + mod = ::Msf::Exploit.new + mod.extend described_class + mod + end + + before do + allow(subject).to receive(:framework).and_return(framework) + allow(subject).to receive(:rhost).and_return('192.168.1.10') + allow(subject).to receive(:rport).and_return(3389) + end + + describe '#swap_sock_plain_to_ssl' do + let(:tls_cert) do + key = OpenSSL::PKey::RSA.generate(2048) + c = OpenSSL::X509::Certificate.new + c.subject = OpenSSL::X509::Name.parse('/CN=rdp.example.com') + c.issuer = OpenSSL::X509::Name.parse('/CN=Test CA') + c.public_key = key.public_key + c.serial = OpenSSL::BN.new('1') + c.version = 2 + c.not_before = Time.utc(2026, 1, 1) + c.not_after = Time.utc(2027, 1, 1) + c.sign(key, OpenSSL::Digest::SHA256.new) + c + end + + let(:ssl_socket_dbl) do + dbl = double('ssl_socket') + allow(dbl).to receive(:connect) + allow(dbl).to receive(:peer_cert).and_return(tls_cert) + dbl + end + + let(:rdp_sock_dbl) do + dbl = double('rdp_sock') + allow(dbl).to receive(:extend) + allow(dbl).to receive(:sslsock=) + allow(dbl).to receive(:sslctx=) + dbl + end + + before do + subject.instance_variable_set(:@rdp_sock, rdp_sock_dbl) + subject.datastore['RDP_TLS_SECURITY_LEVEL'] = 0 + allow(OpenSSL::SSL::SSLSocket).to receive(:new).and_return(ssl_socket_dbl) + allow(framework).to receive(:db).and_return(double('db', active: false)) + end + + it 'prints the peer cert when CertificateTrace is enabled' do + subject.datastore['CertificateTrace'] = 'metadata' + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.swap_sock_plain_to_ssl + end + + it 'produces no output when CertificateTrace is off' do + subject.datastore['CertificateTrace'] = 'off' + expect(subject).not_to receive(:print_line) + subject.swap_sock_plain_to_ssl + end + + it 'produces no output when the server returns no cert' do + subject.datastore['CertificateTrace'] = 'metadata' + allow(ssl_socket_dbl).to receive(:peer_cert).and_return(nil) + expect(subject).not_to receive(:print_line) + subject.swap_sock_plain_to_ssl + end + end +end From 545569ccc1f4944762e7a8744e079f0b0da67ac0 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:27:20 +0530 Subject: [PATCH 05/12] Add SSL peer cert tracing to Postgres mixin (Phase 3) Includes CertificateTrace in Exploit::Remote::Postgres so the server TLS certificate is surfaced when connecting via SSL (datastore SSL=true). The SSL option was already registered by the included Exploit::Remote::Tcp mixin but was never threaded through to Connection.new. This commit passes it as the sixth positional argument, activating the starttls path in postgres-pr. After a successful login the leaf certificate is read from postgres_conn.conn.peer_cert and forwarded to certificate_peer_cert_trace. A nil peer_cert (plain TCP connection with SSL=false) is a silent no-op. Callers can override with opts[:ssl] to force TLS regardless of the datastore, matching the existing pattern for other opts keys in postgres_login. Spec: 7 new examples covering TLS cert printed, off mode silent, ssl arg threading, plain connection no-op, opts[:ssl] override, and failed login no-op. --- lib/msf/core/exploit/remote/postgres.rb | 5 +- .../msf/core/exploit/remote/postgres_spec.rb | 116 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 spec/lib/msf/core/exploit/remote/postgres_spec.rb diff --git a/lib/msf/core/exploit/remote/postgres.rb b/lib/msf/core/exploit/remote/postgres.rb index ec36c9ed1a65f..941f64eb041f4 100644 --- a/lib/msf/core/exploit/remote/postgres.rb +++ b/lib/msf/core/exploit/remote/postgres.rb @@ -15,6 +15,7 @@ module Exploit::Remote::Postgres require 'metasploit/framework/tcp/client' include Msf::Db::PostgresPR include Exploit::Remote::Tcp + include Msf::Exploit::Remote::CertificateTrace # @!attribute [rw] postgres_conn # @return [::Msf::Db::PostgresPR::Connection] @@ -97,6 +98,7 @@ def postgres_login(opts={}) ip = opts[:server] || datastore['RHOST'] port = opts[:port] || datastore['RPORT'] proxies = opts[:proxies] || datastore['Proxies'] + ssl = opts.key?(:ssl) ? opts[:ssl] : datastore['SSL'] uri = "tcp://#{ip}:#{port}" if Rex::Socket.is_ipv6?(ip) @@ -105,7 +107,7 @@ def postgres_login(opts={}) verbose = opts[:verbose] || datastore['VERBOSE'] begin - self.postgres_conn = Connection.new(db,username,password,uri,proxies) + self.postgres_conn = Connection.new(db, username, password, uri, proxies, ssl) rescue RuntimeError => e case e.to_s.split("\t")[1] when "C3D000" @@ -124,6 +126,7 @@ def postgres_login(opts={}) end if self.postgres_conn print_good "#{self.postgres_conn.peerhost}:#{self.postgres_conn.peerport} Postgres - Logged in to '#{db}' with '#{username}':'#{password}'" if verbose + certificate_peer_cert_trace(postgres_conn.conn&.peer_cert, ip, port.to_i) return :connected end end diff --git a/spec/lib/msf/core/exploit/remote/postgres_spec.rb b/spec/lib/msf/core/exploit/remote/postgres_spec.rb new file mode 100644 index 0000000000000..26fd85877a8d3 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/postgres_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::Postgres do + include_context 'Msf::Simple::Framework' + + subject do + mod = ::Msf::Exploit.new + mod.extend described_class + mod + end + + before do + allow(subject).to receive(:framework).and_return(framework) + subject.datastore['RHOST'] = '192.168.1.20' + subject.datastore['RPORT'] = 5432 + subject.datastore['DATABASE'] = 'template1' + subject.datastore['USERNAME'] = 'postgres' + subject.datastore['PASSWORD'] = 'postgres' + end + + describe '#postgres_login' do + let(:tls_cert) do + key = OpenSSL::PKey::RSA.generate(2048) + c = OpenSSL::X509::Certificate.new + c.subject = OpenSSL::X509::Name.parse('/CN=pg.example.com') + c.issuer = OpenSSL::X509::Name.parse('/CN=Test CA') + c.public_key = key.public_key + c.serial = OpenSSL::BN.new('1') + c.version = 2 + c.not_before = Time.utc(2026, 1, 1) + c.not_after = Time.utc(2027, 1, 1) + c.sign(key, OpenSSL::Digest::SHA256.new) + c + end + + let(:ssl_socket_dbl) do + dbl = double('ssl_socket') + allow(dbl).to receive(:peer_cert).and_return(tls_cert) + dbl + end + + let(:pg_conn_dbl) do + dbl = double('pg_connection') + allow(dbl).to receive(:peerhost).and_return('192.168.1.20') + allow(dbl).to receive(:peerport).and_return(5432) + allow(dbl).to receive(:conn).and_return(ssl_socket_dbl) + dbl + end + + before do + allow(::Msf::Db::PostgresPR::Connection).to receive(:new).and_return(pg_conn_dbl) + allow(framework).to receive(:db).and_return(double('db', active: false)) + end + + context 'SSL peer cert tracing' do + before { subject.datastore['SSL'] = true } + + it 'prints the peer cert when CertificateTrace is enabled' do + subject.datastore['CertificateTrace'] = 'metadata' + expect(subject).to receive(:print_line).with(include('[CertificateTrace]')) + subject.postgres_login + end + + it 'produces no output when CertificateTrace is off' do + subject.datastore['CertificateTrace'] = 'off' + expect(subject).not_to receive(:print_line) + expect(subject.postgres_login).to eq(:connected) + end + + it 'passes ssl: true through to Connection.new' do + expect(::Msf::Db::PostgresPR::Connection).to receive(:new).with( + anything, anything, anything, anything, anything, true + ).and_return(pg_conn_dbl) + subject.postgres_login + end + end + + context 'plain connection (SSL disabled)' do + before { subject.datastore['SSL'] = false } + + it 'produces no output even when CertificateTrace is enabled (no peer_cert)' do + allow(ssl_socket_dbl).to receive(:peer_cert).and_return(nil) + subject.datastore['CertificateTrace'] = 'metadata' + expect(subject).not_to receive(:print_line) + expect(subject.postgres_login).to eq(:connected) + end + + it 'passes ssl: false through to Connection.new' do + expect(::Msf::Db::PostgresPR::Connection).to receive(:new).with( + anything, anything, anything, anything, anything, false + ).and_return(pg_conn_dbl) + subject.postgres_login + end + end + + context 'opts[:ssl] override' do + it 'respects an explicit ssl: true passed in opts even when datastore SSL is false' do + subject.datastore['SSL'] = false + expect(::Msf::Db::PostgresPR::Connection).to receive(:new).with( + anything, anything, anything, anything, anything, true + ).and_return(pg_conn_dbl) + subject.postgres_login(ssl: true) + end + end + + context 'when login fails' do + it 'returns :error_credentials on bad password without calling certificate_peer_cert_trace' do + allow(::Msf::Db::PostgresPR::Connection).to receive(:new).and_raise(RuntimeError, "auth\tC28000") + expect(subject).not_to receive(:certificate_peer_cert_trace) + expect(subject.postgres_login).to eq(:error_credentials) + end + end + end +end From 12fc385e2f389b14ebd2097440bc302c1ab77128 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:36:31 +0530 Subject: [PATCH 06/12] Add full cert chain display in CertificateTrace full mode (Phase 4) When CertificateTrace=full and the server sends a chain during the TLS handshake, the intermediate and root CA certs are now printed after the leaf cert. Each chain cert uses a "Chain N/M" position header in place of the standard [CertificateTrace] separator so operators can visually distinguish chain depth. certificate_peer_cert_trace gains an optional chain: keyword argument (array of OpenSSL::X509::Certificate). chain[0] is the peer cert already printed; chain[1..] are the chain certs. In metadata mode the chain parameter is silently ignored so verbosity stays minimal. Protocol hooks updated to pass peer_cert_chain: - HTTP (HttpClient): c.conn.peer_cert_chain - LDAP (_ldap_trace_peer_cert): socket.peer_cert_chain via respond_to? guard - RDP (swap_sock_plain_to_ssl): ssl.peer_cert_chain (OpenSSL::SSL::SSLSocket) - Postgres (postgres_login): pg_socket.peer_cert_chain via respond_to? guard Spec: 5 new chain examples in certificate_trace_spec; LDAP and RDP specs updated to stub peer_cert_chain on their socket doubles. 75 examples, 0 failures across all Phase 1-4 specs. --- .../core/exploit/remote/certificate_trace.rb | 18 ++++++- lib/msf/core/exploit/remote/http_client.rb | 5 +- lib/msf/core/exploit/remote/ldap.rb | 5 +- lib/msf/core/exploit/remote/postgres.rb | 6 ++- lib/msf/core/exploit/remote/rdp.rb | 2 +- .../exploit/remote/certificate_trace_spec.rb | 51 +++++++++++++++++++ spec/lib/msf/core/exploit/remote/ldap_spec.rb | 4 ++ spec/lib/msf/core/exploit/remote/rdp_spec.rb | 1 + 8 files changed, 87 insertions(+), 5 deletions(-) diff --git a/lib/msf/core/exploit/remote/certificate_trace.rb b/lib/msf/core/exploit/remote/certificate_trace.rb index 9eea5d18bb451..a3f27d3b55b86 100644 --- a/lib/msf/core/exploit/remote/certificate_trace.rb +++ b/lib/msf/core/exploit/remote/certificate_trace.rb @@ -72,7 +72,7 @@ def certificate_trace(cert) # @param host [String] remote hostname or IP # @param port [Integer] remote port # @return [void] - def certificate_peer_cert_trace(cert, host, port) + def certificate_peer_cert_trace(cert, host, port, chain: nil) return unless certificate_trace_enabled? return if cert.nil? @@ -104,6 +104,22 @@ def certificate_peer_cert_trace(cert, host, port) return unless output print_line(certificate_trace_colorize(output)) + + return unless mode == 'full' + + # When chain is provided, print each intermediate / root CA cert that the + # server included in the handshake. chain[0] is the leaf (already printed), + # so we start from chain[1]. + chain_certs = Array(chain).drop(1) + chain_certs.each_with_index do |chain_cert, idx| + chain_presenter = Msf::Trace::CertificateTracePresenter.new(chain_cert) + chain_output = chain_presenter.to_s_full + next unless chain_output + + chain_header = "[CertificateTrace] Chain #{idx + 1}/#{chain_certs.length} #{'-' * 28}" + chain_output = chain_output.sub(Msf::Trace::CertificateTracePresenter::SEPARATOR, chain_header) + print_line(certificate_trace_colorize(chain_output)) + end end private diff --git a/lib/msf/core/exploit/remote/http_client.rb b/lib/msf/core/exploit/remote/http_client.rb index 6e0438fdd01c8..506459c2b82d0 100644 --- a/lib/msf/core/exploit/remote/http_client.rb +++ b/lib/msf/core/exploit/remote/http_client.rb @@ -425,7 +425,10 @@ def send_request_raw(opts = {}, timeout = 20, disconnect = false) if c.conn&.respond_to?(:peer_cert) raw_cert = c.conn.peer_cert - certificate_peer_cert_trace(raw_cert, opts['rhost'] || rhost, (opts['rport'] || rport).to_i) if raw_cert + if raw_cert + raw_chain = c.conn.peer_cert_chain if c.conn.respond_to?(:peer_cert_chain) + certificate_peer_cert_trace(raw_cert, opts['rhost'] || rhost, (opts['rport'] || rport).to_i, chain: raw_chain) + end end disconnect(c) if disconnect diff --git a/lib/msf/core/exploit/remote/ldap.rb b/lib/msf/core/exploit/remote/ldap.rb index f004423cd12e9..b08f56d4f5325 100644 --- a/lib/msf/core/exploit/remote/ldap.rb +++ b/lib/msf/core/exploit/remote/ldap.rb @@ -366,7 +366,10 @@ def _ldap_trace_peer_cert(socket) return unless socket&.respond_to?(:peer_cert) raw_cert = socket.peer_cert - certificate_peer_cert_trace(raw_cert, rhost, rport.to_i) if raw_cert + return unless raw_cert + + chain = socket.peer_cert_chain if socket.respond_to?(:peer_cert_chain) + certificate_peer_cert_trace(raw_cert, rhost, rport.to_i, chain: chain) end end end diff --git a/lib/msf/core/exploit/remote/postgres.rb b/lib/msf/core/exploit/remote/postgres.rb index 941f64eb041f4..04e841425954e 100644 --- a/lib/msf/core/exploit/remote/postgres.rb +++ b/lib/msf/core/exploit/remote/postgres.rb @@ -126,7 +126,11 @@ def postgres_login(opts={}) end if self.postgres_conn print_good "#{self.postgres_conn.peerhost}:#{self.postgres_conn.peerport} Postgres - Logged in to '#{db}' with '#{username}':'#{password}'" if verbose - certificate_peer_cert_trace(postgres_conn.conn&.peer_cert, ip, port.to_i) + pg_socket = postgres_conn.conn + certificate_peer_cert_trace( + pg_socket&.peer_cert, ip, port.to_i, + chain: pg_socket.respond_to?(:peer_cert_chain) ? pg_socket.peer_cert_chain : nil + ) return :connected end end diff --git a/lib/msf/core/exploit/remote/rdp.rb b/lib/msf/core/exploit/remote/rdp.rb index ed75c10c67c41..7c15fcc067c1d 100644 --- a/lib/msf/core/exploit/remote/rdp.rb +++ b/lib/msf/core/exploit/remote/rdp.rb @@ -1182,7 +1182,7 @@ def swap_sock_plain_to_ssl raise end - certificate_peer_cert_trace(ssl.peer_cert, rhost, rport.to_i) + certificate_peer_cert_trace(ssl.peer_cert, rhost, rport.to_i, chain: ssl.peer_cert_chain) self.rdp_sock.extend(Rex::Socket::SslTcp) self.rdp_sock.sslsock = ssl diff --git a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb index ecccc5dbeb729..bb2dbcf099777 100644 --- a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb +++ b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb @@ -344,5 +344,56 @@ subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) end end + + context 'cert chain (full mode)' do + let(:ca_key) { OpenSSL::PKey::RSA.generate(2048) } + + let(:intermediate_cert) do + c = OpenSSL::X509::Certificate.new + c.subject = OpenSSL::X509::Name.parse('/CN=Intermediate CA/O=MSF Lab') + c.issuer = OpenSSL::X509::Name.parse('/CN=Root CA') + c.public_key = ca_key.public_key + c.serial = OpenSSL::BN.new('10') + c.version = 2 + c.not_before = Time.utc(2025, 1, 1) + c.not_after = Time.utc(2030, 1, 1) + c.sign(ca_key, OpenSSL::Digest::SHA256.new) + c + end + + before { subject.datastore['CertificateTrace'] = 'full' } + + it 'prints chain certs when chain has more than one cert' do + chain = [peer_cert, intermediate_cert] + # leaf + 1 chain cert = 2 print_line calls + expect(subject).to receive(:print_line).twice + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443, chain: chain) + end + + it 'uses "Chain N/M" header for intermediate certs' do + chain = [peer_cert, intermediate_cert] + received = [] + allow(subject).to receive(:print_line) { |s| received << s } + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443, chain: chain) + expect(received.last).to include('Chain 1/1') + end + + it 'does not print chain certs in metadata mode' do + subject.datastore['CertificateTrace'] = 'metadata' + chain = [peer_cert, intermediate_cert] + expect(subject).to receive(:print_line).once + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443, chain: chain) + end + + it 'prints only the leaf when chain is nil' do + expect(subject).to receive(:print_line).once + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443, chain: nil) + end + + it 'prints only the leaf when chain has only one cert (the leaf itself)' do + expect(subject).to receive(:print_line).once + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443, chain: [peer_cert]) + end + end end end diff --git a/spec/lib/msf/core/exploit/remote/ldap_spec.rb b/spec/lib/msf/core/exploit/remote/ldap_spec.rb index 9e4e7960adaa6..bc641f7f2c959 100644 --- a/spec/lib/msf/core/exploit/remote/ldap_spec.rb +++ b/spec/lib/msf/core/exploit/remote/ldap_spec.rb @@ -87,7 +87,9 @@ let(:tls_socket) do dbl = double('socket') allow(dbl).to receive(:respond_to?).with(:peer_cert).and_return(true) + allow(dbl).to receive(:respond_to?).with(:peer_cert_chain).and_return(true) allow(dbl).to receive(:peer_cert).and_return(tls_cert) + allow(dbl).to receive(:peer_cert_chain).and_return([tls_cert]) dbl end @@ -140,7 +142,9 @@ let(:tls_socket) do dbl = double('socket') allow(dbl).to receive(:respond_to?).with(:peer_cert).and_return(true) + allow(dbl).to receive(:respond_to?).with(:peer_cert_chain).and_return(true) allow(dbl).to receive(:peer_cert).and_return(tls_cert) + allow(dbl).to receive(:peer_cert_chain).and_return([tls_cert]) dbl end diff --git a/spec/lib/msf/core/exploit/remote/rdp_spec.rb b/spec/lib/msf/core/exploit/remote/rdp_spec.rb index 0a8a3114f5905..9e64916842a8e 100644 --- a/spec/lib/msf/core/exploit/remote/rdp_spec.rb +++ b/spec/lib/msf/core/exploit/remote/rdp_spec.rb @@ -36,6 +36,7 @@ dbl = double('ssl_socket') allow(dbl).to receive(:connect) allow(dbl).to receive(:peer_cert).and_return(tls_cert) + allow(dbl).to receive(:peer_cert_chain).and_return([tls_cert]) dbl end From 760b7711a7f8cfb27b9f717286bd519908480f46 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:44:52 +0530 Subject: [PATCH 07/12] Guard postgres peer cert call with respond_to? for plain TCP connections Plain (non-SSL) connections return a raw Socket which does not have peer_cert, causing a NoMethodError. Match the pattern used in the LDAP and HTTP hooks. --- lib/msf/core/exploit/remote/postgres.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/msf/core/exploit/remote/postgres.rb b/lib/msf/core/exploit/remote/postgres.rb index 04e841425954e..b6405eda749a9 100644 --- a/lib/msf/core/exploit/remote/postgres.rb +++ b/lib/msf/core/exploit/remote/postgres.rb @@ -127,10 +127,12 @@ def postgres_login(opts={}) if self.postgres_conn print_good "#{self.postgres_conn.peerhost}:#{self.postgres_conn.peerport} Postgres - Logged in to '#{db}' with '#{username}':'#{password}'" if verbose pg_socket = postgres_conn.conn - certificate_peer_cert_trace( - pg_socket&.peer_cert, ip, port.to_i, - chain: pg_socket.respond_to?(:peer_cert_chain) ? pg_socket.peer_cert_chain : nil - ) + if pg_socket&.respond_to?(:peer_cert) + certificate_peer_cert_trace( + pg_socket.peer_cert, ip, port.to_i, + chain: pg_socket.respond_to?(:peer_cert_chain) ? pg_socket.peer_cert_chain : nil + ) + end return :connected end end From e4ce5cc0ba8ce5dbab10e32aca36a10379eb49ee Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:09:54 +0530 Subject: [PATCH 08/12] Fix _ldap_trace_peer_cert calling rhost/rport in LoginScanner context LoginScanner::LDAP includes Exploit::Remote::LDAP so it calls _ldap_trace_peer_cert via ldap_open. LoginScanner does not have datastore, rhost, or rport. Guard with certificate_trace_enabled? early so we return before evaluating rhost/rport arguments when tracing is not available (e.g. in scanner context). --- lib/msf/core/exploit/remote/ldap.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/msf/core/exploit/remote/ldap.rb b/lib/msf/core/exploit/remote/ldap.rb index b08f56d4f5325..f78fc19ef270a 100644 --- a/lib/msf/core/exploit/remote/ldap.rb +++ b/lib/msf/core/exploit/remote/ldap.rb @@ -363,6 +363,7 @@ def ldap_escape_filter(string) # @param socket [Rex::Socket, nil] the LDAP socket after TLS handshake # @return [void] def _ldap_trace_peer_cert(socket) + return unless certificate_trace_enabled? return unless socket&.respond_to?(:peer_cert) raw_cert = socket.peer_cert From 91f36cefcc4c235df38ee641c6fe9ef78586a987 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:13:00 +0530 Subject: [PATCH 09/12] Fix _ldap_trace_peer_cert calling rhost/rport in LoginScanner context LoginScanner::LDAP includes Exploit::Remote::LDAP so it calls _ldap_trace_peer_cert via ldap_open. LoginScanner does not have datastore, so calling rhost (which calls datastore['RHOST']) raises NoMethodError, caught as UNABLE_TO_CONNECT in the scanner. Guard with certificate_trace_enabled? at the top so we return before evaluating rhost/rport when tracing is unavailable. --- lib/msf/core/exploit/remote/ldap.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/msf/core/exploit/remote/ldap.rb b/lib/msf/core/exploit/remote/ldap.rb index f004423cd12e9..6fd105b26fb73 100644 --- a/lib/msf/core/exploit/remote/ldap.rb +++ b/lib/msf/core/exploit/remote/ldap.rb @@ -363,10 +363,13 @@ def ldap_escape_filter(string) # @param socket [Rex::Socket, nil] the LDAP socket after TLS handshake # @return [void] def _ldap_trace_peer_cert(socket) + return unless certificate_trace_enabled? return unless socket&.respond_to?(:peer_cert) raw_cert = socket.peer_cert - certificate_peer_cert_trace(raw_cert, rhost, rport.to_i) if raw_cert + return unless raw_cert + + certificate_peer_cert_trace(raw_cert, rhost, rport.to_i) end end end From 8fee1fb0270eaf11f7a61071c8c9215bdb3566c5 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:50:19 +0530 Subject: [PATCH 10/12] Add invalid-cert coverage to certificate_peer_cert_trace spec certificate_trace already tested the invalid-cert no-raise case; add the same coverage for certificate_peer_cert_trace. --- .../lib/msf/core/exploit/remote/certificate_trace_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb index ecccc5dbeb729..010ac8785a2e0 100644 --- a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb +++ b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb @@ -269,6 +269,14 @@ end end + context 'with an invalid certificate' do + it 'produces no output and does not raise' do + subject.datastore['CertificateTrace'] = 'full' + expect(subject).not_to receive(:print_line) + expect { subject.certificate_peer_cert_trace('not a cert', '192.168.1.1', 443) }.not_to raise_error + end + end + context 'in-memory deduplication (no DB)' do before { subject.datastore['CertificateTrace'] = 'metadata' } From 7b529ca5d3aa2e860ca1fc99e76b5d82d26020e5 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:23:49 +0530 Subject: [PATCH 11/12] Label peer certs distinctly from issued certs and CSRs Addresses review feedback that a server's TLS peer certificate printed under the same '[CertificateTrace] x.509' header as an issued/client certificate, making the two indistinguishable. Add an optional label: keyword to CertificateTracePresenter#to_s_metadata and #to_s_full (default 'x.509', backward compatible). certificate_peer_cert_trace now renders peer certs under a 'Peer Cert' header. Issued-cert and CSR output are unchanged. --- lib/msf/core/exploit/remote/certificate_trace.rb | 4 +++- lib/msf/core/trace/certificate_trace_presenter.rb | 11 +++++++---- .../core/exploit/remote/certificate_trace_spec.rb | 12 ++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/msf/core/exploit/remote/certificate_trace.rb b/lib/msf/core/exploit/remote/certificate_trace.rb index e20198760e4a6..cf2d7e99ece16 100644 --- a/lib/msf/core/exploit/remote/certificate_trace.rb +++ b/lib/msf/core/exploit/remote/certificate_trace.rb @@ -100,7 +100,9 @@ def certificate_peer_cert_trace(cert, host, port, chain: nil) mode = datastore['CertificateTrace'] presenter = Msf::Trace::CertificateTracePresenter.new(cert) - output = mode == 'full' ? presenter.to_s_full : presenter.to_s_metadata + # Label peer certs distinctly from issued (x.509) and CSR output so a + # server's TLS cert is not confused with a client/issued certificate. + output = mode == 'full' ? presenter.to_s_full(label: 'Peer Cert') : presenter.to_s_metadata(label: 'Peer Cert') return unless output print_line(certificate_trace_colorize(output)) diff --git a/lib/msf/core/trace/certificate_trace_presenter.rb b/lib/msf/core/trace/certificate_trace_presenter.rb index 4d3d3292afbe8..57be7e92b5886 100644 --- a/lib/msf/core/trace/certificate_trace_presenter.rb +++ b/lib/msf/core/trace/certificate_trace_presenter.rb @@ -120,14 +120,16 @@ def initialize(cert = nil) # Returns a formatted metadata string: subject, issuer, validity, SHA-256 fingerprint. # + # @param label [String] separator header label (e.g. 'x.509' for an issued + # cert, 'Peer Cert' for a server TLS cert, 'Chain 1/2' for a chain member) # @return [String, nil] nil if certificate could not be parsed - def to_s_metadata + def to_s_metadata(label: 'x.509') return nil unless @cert fingerprint = OpenSSL::Digest::SHA256.hexdigest(@cert.to_der) [ - trace_separator('x.509'), + trace_separator(label), " Subject : #{@cert.subject}", " Issuer : #{@cert.issuer}", " Not Before : #{@cert.not_before}", @@ -139,9 +141,10 @@ def to_s_metadata # Returns a formatted full string: metadata + serial, version, public key algorithm, # SAN / EKU / Key Usage as named fields, then remaining extensions. # + # @param label [String] separator header label (see {#to_s_metadata}) # @return [String, nil] nil if certificate could not be parsed - def to_s_full - base = to_s_metadata + def to_s_full(label: 'x.509') + base = to_s_metadata(label: label) return nil unless base lines = [base] diff --git a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb index a83d943cb5c19..91451be25ae80 100644 --- a/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb +++ b/spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb @@ -126,6 +126,11 @@ expect(subject).to receive(:print_line).with(include('Version')) subject.certificate_trace(cert) end + + it 'labels the header x.509 for an issued certificate' do + expect(subject).to receive(:print_line).with(include('[CertificateTrace] x.509')) + subject.certificate_trace(cert) + end end context 'with a PKCS12 bundle' do @@ -256,6 +261,13 @@ expect(subject).to receive(:print_line).with(include('Version')) subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) end + + it 'labels the header Peer Cert (not x.509) so it is distinct from an issued cert' do + expect(subject).to receive(:print_line).with(satisfy { |s| + s.include?('[CertificateTrace] Peer Cert') && !s.include?('x.509') + }) + subject.certificate_peer_cert_trace(peer_cert, '192.168.1.1', 443) + end end context 'with raw DER bytes' do From ef96c4bc413bdacaa2d130929a7ab602cc269690 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:27:21 +0530 Subject: [PATCH 12/12] Render chain certs via presenter label instead of string surgery With to_s_full now accepting a label: keyword, the chain-printing path passes label: "Chain N/M" directly to the presenter instead of building a header string and substituting it into to_s_full's output. This removes the manual first-line sub, drops the hardcoded dash width (headers now pad to the presenter's separator width for any chain length), and no longer depends on any presenter separator internals. --- lib/msf/core/exploit/remote/certificate_trace.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/msf/core/exploit/remote/certificate_trace.rb b/lib/msf/core/exploit/remote/certificate_trace.rb index cf2d7e99ece16..5e08fd367c72e 100644 --- a/lib/msf/core/exploit/remote/certificate_trace.rb +++ b/lib/msf/core/exploit/remote/certificate_trace.rb @@ -115,13 +115,9 @@ def certificate_peer_cert_trace(cert, host, port, chain: nil) chain_certs = Array(chain).drop(1) chain_certs.each_with_index do |chain_cert, idx| chain_presenter = Msf::Trace::CertificateTracePresenter.new(chain_cert) - chain_output = chain_presenter.to_s_full + chain_output = chain_presenter.to_s_full(label: "Chain #{idx + 1}/#{chain_certs.length}") next unless chain_output - chain_header = "[CertificateTrace] Chain #{idx + 1}/#{chain_certs.length} #{'-' * 28}" - # Replace the leading x.509 separator line emitted by to_s_full with a - # chain-specific header so intermediates are visually distinct. - chain_output = chain_output.sub(/\A.*\n/, "#{chain_header}\n") print_line(certificate_trace_colorize(chain_output)) end end