Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions lib/msf/core/exploit/remote/certificate_trace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,52 @@ 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)
# 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))
end

# Dispatches a certificate signing request (CSR) trace at the configured
# verbosity level, mirroring #certificate_trace for the request side of an
# enrollment (e.g. AD CS / MS-ICPR). Intended to be called with the CSR and
Expand Down
6 changes: 6 additions & 0 deletions lib/msf/core/exploit/remote/http_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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::CertificateTrace

#
# Initializes an exploit module that exploits a vulnerability in an HTTP
Expand Down Expand Up @@ -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
certificate_peer_cert_trace(raw_cert, opts['rhost'] || rhost, (opts['rport'] || rport).to_i) if raw_cert
end

disconnect(c) if disconnect

res
Expand Down
29 changes: 27 additions & 2 deletions lib/msf/core/exploit/remote/ldap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
include Msf::Exploit::Remote::CertificateTrace

Expand Down Expand Up @@ -162,9 +163,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

Expand Down Expand Up @@ -358,5 +365,23 @@ 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 certificate_trace_enabled?
return unless socket&.respond_to?(:peer_cert)

raw_cert = socket.peer_cert
return unless raw_cert

certificate_peer_cert_trace(raw_cert, rhost, rport.to_i)
end
end
end
3 changes: 3 additions & 0 deletions lib/msf/core/exploit/remote/rdp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions lib/msf/core/trace/certificate_trace_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand All @@ -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]
Expand Down
182 changes: 182 additions & 0 deletions spec/lib/msf/core/exploit/remote/certificate_trace_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,6 +187,183 @@
end
end

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

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
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 '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' }

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

describe '#certificate_csr_trace' do
let(:csr) do
req = OpenSSL::X509::Request.new
Expand Down
Loading
Loading