Inventorying a legacy app's outbound TLS exposure

Every legacy system we look at makes outbound calls nobody remembers writing. A VB6 EXE posting to a carrier's rate API with MSXML. A Classic ASP page pulling a credit decision through WinHTTP. A WebForms app calling a payment gateway with HttpWebRequest on .NET Framework 4.5. They all worked for a decade, so they are invisible.

They stop being invisible when the operating system underneath them drops the old TLS versions, or when the other end does. TLS 1.0 and 1.1 are deprecated, Windows Server 2025 ships with them off by default, and gateways keep tightening cipher requirements on their own schedule. The failure mode is the same every time: a call that has always returned data starts returning an unhelpful error, usually during a month-end run, usually with no logging.

This tutorial is about measuring that exposure before something forces the issue. You are not fixing anything yet. You are producing a list: every outbound endpoint, the stack that calls it, and the protocol the call actually negotiates.

Find the callers

Grep the source for the four client libraries

Legacy Microsoft code reaches the network through a small number of APIs. Search for all of them, across every project and script directory:

grep -rniE "MSXML2\.(Server)?XMLHTTP|WinHttp\.WinHttpRequest|ServerXMLHTTP" --include=*.bas --include=*.cls --include=*.frm --include=*.asp --include=*.vbs .
grep -rniE "HttpWebRequest|WebClient|ServicePointManager|SmtpClient|SqlConnection.*Encrypt" --include=*.cs --include=*.vb --include=*.aspx --include=*.config .
grep -rniE "https?://" --include=*.config --include=*.asp --include=*.bas . | grep -viE "schemas\.|w3\.org|localhost"

The third search is the one that finds the endpoints. Strip out XML namespace URLs and you are usually left with twenty to sixty real hostnames: gateways, ERP endpoints, state tax portals, an FTP host, a couple of internal servers.

Put them in a table as you go. Hostname, protocol, calling module, what breaks if it fails. That table is the deliverable.

Include the things that are not code

Outbound TLS also lives in places grep will not reach:

  • Scheduled tasks and SQL Agent jobs that invoke curl, bitsadmin, or a small utility EXE.
  • SSIS and DTS packages with HTTP or FTP tasks.
  • Linked servers and OPENROWSET calls to remote SQL instances, where Encrypt=true now means a TLS handshake you never thought about.
  • The mail path. An app that relays through an internal SMTP server is fine until the relay requires STARTTLS with TLS 1.2.
  • Certificate validation callbacks. ServerCertificateValidationCallback returning true unconditionally is common in this code, and it hides expiry problems until it does not.

Measure what is actually negotiated

Source tells you who calls out. It does not tell you what version gets used, because that is decided at runtime by SChannel, the .NET Framework configuration and the remote server.

Turn on SChannel event logging

On the server, for a bounded window:

# 1 = errors, 2 = warnings, 3 = informational (noisy), 7 = everything
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL' `
  -Name EventLogging -Value 3 -PropertyType DWord -Force

Then watch the System log for SChannel events, especially 36874 (client offered no acceptable protocols) and 36888 (fatal alert). Set it back to 1 when you are done; level 3 is loud.

Read the current protocol settings

The registry is the ground truth for what the machine will negotiate:

$base = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols'
Get-ChildItem -Path $base -Recurse |
  Where-Object { $_.PSChildName -in 'Client','Server' } |
  ForEach-Object {
    [pscustomobject]@{
      Protocol = Split-Path (Split-Path $_.Name) -Leaf
      Side     = $_.PSChildName
      Enabled  = (Get-ItemProperty $_.PSPath).Enabled
      Default  = (Get-ItemProperty $_.PSPath).DisabledByDefault
    }
  } | Format-Table

A missing key is not "disabled". It means the OS default applies, which differs by Windows version — that is exactly how an application survives a decade and then breaks on a new server build.

Check the .NET Framework side

.NET Framework 4.x applications have their own switch that overrides the OS preference. Both the 64-bit and the 32-bit (Wow6432Node) hives matter, because a VB6-era app pool often runs 32-bit:

foreach ($p in @(
  'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319',
  'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319')) {
  Get-ItemProperty $p |
    Select-Object @{n='Hive';e={$p}}, SystemDefaultTlsVersions, SchUseStrongCrypto
}

If SystemDefaultTlsVersions is not 1, the framework is picking protocols on its own, and code that hard-codes ServicePointManager.SecurityProtocol = SecurityProtocolType.Ssl3 Or Tls — which you found in the grep above — will keep offering exactly those.

Probe each endpoint

For every hostname in the table, ask what the far end still accepts:

foreach ($proto in 'Tls','Tls11','Tls12','Tls13') {
  try {
    $c = [Net.Sockets.TcpClient]::new('gateway.example.com', 443)
    $s = [Net.Security.SslStream]::new($c.GetStream(), $false, { $true })
    $s.AuthenticateAsClient('gateway.example.com', $null, [Net.SecurityProtocolType]::$proto, $false)
    "{0,-6} ok   cipher {1}" -f $proto, $s.CipherAlgorithm
  } catch { "{0,-6} refused" -f $proto }
  finally { if ($s) { $s.Dispose() }; if ($c) { $c.Dispose() } }
}

Run it from the legacy server itself, not from your laptop. The answer depends on the machine's cipher suite list and its trusted roots, and those are the two things most likely to be stale.

Where a call is not HTTPS at all — and there will be some, an FTP transfer or an internal POST over port 80 — write it down as its own risk. Plaintext to a partner is an audit finding waiting to happen, and the fix is rarely a code change on your side.

What the finished inventory tells you

Each row lands in one of four places:

  1. TLS 1.2 or better already, no hard-coded protocol. Nothing to do. Most rows land here and that is worth knowing.
  2. Fine today only because of a registry setting or an OS default. Fixable in place: set SystemDefaultTlsVersions and SchUseStrongCrypto, remove hard-coded SecurityProtocol lines, re-run the probe. Hours, not weeks.
  3. Cannot do TLS 1.2 in its current form. VB6 code using the old MSXML2.XMLHTTP progid, or .NET 3.5 components, or a WinHTTP call on an unpatched Server 2008 R2. These are migration triggers with a date attached, and they are the rows that should shape slice order.
  4. Endpoint is the problem, not you. An internal partner that only offers TLS 1.0. Your inventory is the evidence for that conversation.

Keep the whole thing in one file in the repository next to the characterization harness, with the date of the last probe. Re-run the probe script quarterly; it takes minutes and the answers change without anyone telling you.

What to watch for

  • 32-bit and 64-bit disagree. An app pool in 32-bit mode reads a different registry hive and often a different .NET config. Test in the bitness the application actually runs in.
  • A grep is not an inventory. Reflection, config-driven endpoints and strings assembled at runtime all hide from search. Cross-check the grep against a day of firewall or proxy logs; the deltas are the interesting part.
  • Certificate chains fail like protocol mismatches. An expired intermediate on an old machine produces the same vague error as a rejected handshake. The probe script above trusts everything, so run it once with real validation to separate the two.
  • Do not fix while measuring. Changing registry protocol settings mid-inventory invalidates every probe you already ran, and on a shared server it can break an application you were not looking at. Finish the table first.

AI tooling helps with the first half of this and not the second. A coding agent will classify a few hundred grep hits by caller and endpoint quickly and accurately. It cannot tell you what a handshake negotiates on a particular server, and it will happily invent a plausible answer. Measure the runtime yourself.