协商了哪个TLS版本?

32

我有一个在.NET 4.7中运行的应用程序。默认情况下,它会尝试使用TLS1.2。
在执行例如以下HTTP请求时,是否可以知道已经协商了哪个TLS版本?

HttpWebRequest request = (HttpWebRequest)WebRequest.Create(decodedUri);
if (requestPayload.Length > 0)
{
    using (Stream requestStream = request.GetRequestStream())
    {
        requestStream.Write(requestPayload, 0, requestPayload.Length);
    }
}

我只需要这些信息用于日志记录/调试目的,因此在写入请求流或接收响应之前拥有这些信息并不重要。我不希望解析网络跟踪日志以获取这些信息,也不想创建第二个连接(使用SslStream或类似方式)。


2
希望有更简单的方法,但如果你启用详细的 System.Net 跟踪,它将记录这些信息,然后你可能可以解析出来,例如:System.Net 信息: 0 : [18984] EndProcessAuthentication(Protocol=Tls12, Cipher=Aes256 256 bit strength,....等等.. - Crowcoder
@Crowcoder:可能可以查看框架源代码,看看冗长日志记录从哪里获取信息。 - Robert Harvey
1
我认为没有其他方法可以不经过深入思考(如上所述)就到达那里,不确定您是否认为这是一种黑客行为。 - Evk
也许可以使用 Fiddler 或 Wireshark 工具,尝试分析客户端和服务器的 hello 消息。 - Johnny
3
通过文档化API呈现这些信息似乎非常有用,而不仅仅是通过跟踪或者通过脆弱的反射方式,或者试图从证书中推断出来,或者通过在SslStream之上自己重新实现HTTP的方式来获取。这可能值得为其提交一个问题(issue) - Jeroen Mostert
显示剩余4条评论
4个回答

31
您可以使用反射来获取TlsStream->SslState->SslProtocol属性的值。
这些信息可以从由HttpWebRequest.GetRequestStream()HttpWebRequest.GetResponseStream()返回的流中提取。
ExtractSslProtocol()还处理启用了WebRequest AutomaticDecompression时返回的压缩GzipStreamDeflateStream
验证将在ServerCertificateValidationCallback中进行,在使用request.GetRequestStream()初始化请求时调用。
注意SecurityProtocolType.Tls13包含在 .Net Framework 4.8+和.Net Core 3.0+ 中。
using System.IO.Compression;
using System.Net;
using System.Net.Security;
using System.Reflection;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

//(...)
// Allow all, to then check what the Handshake will agree upon
ServicePointManager.SecurityProtocol = SecurityProtocolType.Ssl3 | 
                                       SecurityProtocolType.Tls | 
                                       SecurityProtocolType.Tls11 | 
                                       SecurityProtocolType.Tls12 | 
                                       SecurityProtocolType.Tls13;

// Handle the Server certificate exchange, to inspect the certificates received
ServicePointManager.ServerCertificateValidationCallback += TlsValidationCallback;

Uri requestUri = new Uri("https://somesite.com");
var request = WebRequest.CreateHttp(requestUri);

request.Method = WebRequestMethods.Http.Post;
request.ServicePoint.Expect100Continue = false;
request.AllowAutoRedirect = true;
request.CookieContainer = new CookieContainer();

request.ContentType = "application/x-www-form-urlencoded";
var postdata = Encoding.UTF8.GetBytes("Some postdata here");
request.ContentLength = postdata.Length;

request.UserAgent = "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident / 7.0; rv: 11.0) like Gecko";
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
request.Headers.Add(HttpRequestHeader.AcceptEncoding, "gzip, deflate;q=0.8");
request.Headers.Add(HttpRequestHeader.CacheControl, "no-cache");

using (var requestStream = request.GetRequestStream()) {
    //Here the request stream is already validated
    SslProtocols sslProtocol = ExtractSslProtocol(requestStream);
    if (sslProtocol < SslProtocols.Tls12)
    {
        // Refuse/close the connection
    }
}
//(...)

private SslProtocols ExtractSslProtocol(Stream stream)
{
    if (stream is null) return SslProtocols.None;

    BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic;
    Stream metaStream = stream;

    if (stream.GetType().BaseType == typeof(GZipStream)) {
        metaStream = (stream as GZipStream).BaseStream;
    }
    else if (stream.GetType().BaseType == typeof(DeflateStream)) {
        metaStream = (stream as DeflateStream).BaseStream;
    }

    var connection = metaStream.GetType().GetProperty("Connection", bindingFlags).GetValue(metaStream);
    if (!(bool)connection.GetType().GetProperty("UsingSecureStream", bindingFlags).GetValue(connection)) {
        // Not a Https connection
        return SslProtocols.None;
    }
    var tlsStream = connection.GetType().GetProperty("NetworkStream", bindingFlags).GetValue(connection);
    var tlsState = tlsStream.GetType().GetField("m_Worker", bindingFlags).GetValue(tlsStream);
    return (SslProtocols)tlsState.GetType().GetProperty("SslProtocol", bindingFlags).GetValue(tlsState);
}

RemoteCertificateValidationCallback 包含一些关于所使用的安全协议的有用信息(参见:传输层安全(TLS)参数(IANA)RFC 5246)。
所使用的安全协议类型足以提供足够的信息,因为每个协议版本支持哈希和加密算法的子集。
Tls 1.2 引入了 HMAC-SHA256 并停用了 IDEADES 密码(所有变量均列在链接的文档中)。

在这里,我插入了一个 OIDExtractor,它列出了所使用的算法。
请注意,TcpClient() 和 WebRequest() 都将到达此处。

private bool TlsValidationCallback(object sender, X509Certificate CACert, X509Chain CAChain, SslPolicyErrors sslPolicyErrors)
{
    List<Oid> oidExtractor = CAChain
                             .ChainElements
                             .Cast<X509ChainElement>()
                             .Select(x509 => new Oid(x509.Certificate.SignatureAlgorithm.Value))
                             .ToList();
    // Inspect the oidExtractor list

    var certificate = new X509Certificate2(CACert);

    //If you needed/have to pass a certificate, add it here.
    //X509Certificate2 cert = new X509Certificate2(@"[localstorage]/[ca.cert]");
    //CAChain.ChainPolicy.ExtraStore.Add(cert);
    CAChain.Build(certificate);
    foreach (X509ChainStatus CACStatus in CAChain.ChainStatus)
    {
        if ((CACStatus.Status != X509ChainStatusFlags.NoError) &
            (CACStatus.Status != X509ChainStatusFlags.UntrustedRoot))
            return false;
    }
    return true;
}

更新2:
secur32.dll -> QueryContextAttributesW() 方法,允许查询已初始化流的连接安全上下文。

[DllImport("secur32.dll", CharSet = CharSet.Auto, ExactSpelling=true, SetLastError=false)]
private static extern int QueryContextAttributesW(
    SSPIHandle contextHandle,
    [In] ContextAttribute attribute,
    [In] [Out] ref SecPkgContext_ConnectionInfo ConnectionInfo
);

根据文档,该方法返回一个引用SecPkgContext_ConnectionInfo结构的void*缓冲区

private struct SecPkgContext_ConnectionInfo
{
    public SchProtocols dwProtocol;
    public ALG_ID aiCipher;
    public int dwCipherStrength;
    public ALG_ID aiHash;
    public int dwHashStrength;
    public ALG_ID aiExch;
    public int dwExchStrength;
}

SchProtocols dwProtocol成员是SslProtocol。

然而,引用连接上下文句柄的TlsStream.Context.m_SecurityContext._handle并不是公开的。因此,您只能通过反射或通过TcpClient.GetStream()返回的System.Net.Security.AuthenticatedStream派生类(System.Net.Security.SslStreamSystem.Net.Security.NegotiateStream)获取它。

不幸的是,由WebRequest/WebResponse返回的流无法转换为这些类。连接和流类型仅通过非公开属性和字段引用。

我正在发布组装好的文档,它可能会帮助您找到另一条路径来获取到该上下文句柄。

声明、结构体和枚举列表可以在QueryContextAttributesW (PASTEBIN)中找到。

Microsoft TechNet
身份验证结构

MSDN
使用Schannel创建安全连接

获取有关Schannel连接的信息

查询Schannel上下文的属性

QueryContextAttributes (Schannel)

代码库(部分)

.NET参考源代码

Internals.cs

内部结构体SSPIHandle { }

内部枚举类型ContextAttribute { }


更新 1:

我看到你在对另一个答案的评论中提到使用TcpClient()的解决方案对你来说不可接受。我还是把它留在这里,以便Ben Voigt在此回答中的评论对其他人有用。此外,三种可能的解决方案总比两种好。

在提供的上下文中,关于TcpClient()SslStream的一些实现细节。

如果在初始化WebRequest之前需要协议信息,则可以在同一上下文中使用与TLS连接所需相同的工具建立TcpClient()连接。即,使用ServicePointManager.SecurityProtocol定义支持的协议和ServicePointManager.ServerCertificateValidationCallback验证服务器证书。

TcpClient()和WebRequest都可以使用这些设置:

  • 启用所有协议,让TLS握手确定将使用哪个协议。
  • 定义RemoteCertificateValidationCallback()委托以验证服务器传递的X509Certificates。

在实践中,建立TcpClient或WebRequest连接时,TLS握手相同。
这种方法可以让您知道您的HttpWebRequest与同一服务器协商哪种Tls协议。

设置一个TcpClient()来接收和评估SslStream
checkCertificateRevocation标志设置为false,因此该过程不会浪费时间查找吊销列表。
证书验证回调与ServicePointManager中指定的相同。

TlsInfo tlsInfo = null;
IPHostEntry dnsHost = await Dns.GetHostEntryAsync(HostURI.Host);
using (TcpClient client = new TcpClient(dnsHost.HostName, 443))
{
    using (SslStream sslStream = new SslStream(client.GetStream(), false, 
                                               TlsValidationCallback, null))
    {
        sslstream.AuthenticateAsClient(dnsHost.HostName, null, 
                                      (SslProtocols)ServicePointManager.SecurityProtocol, false);
        tlsInfo = new TlsInfo(sslStream);
    }
}

//The HttpWebRequest goes on from here.
HttpWebRequest httpRequest = WebRequest.CreateHttp(HostURI);

//(...)

TlsInfo类收集建立安全连接时的一些信息:

  • TLS协议版本
  • 密码和散列算法
  • 用于SSL握手的服务器证书

public class TlsInfo
{
    public TlsInfo(SslStream secStream)
    {
        this.ProtocolVersion = secStream.SslProtocol;
        this.CipherAlgorithm = secStream.CipherAlgorithm;
        this.HashAlgorithm = secStream.HashAlgorithm;
        this.RemoteCertificate = secStream.RemoteCertificate;
    }

    public SslProtocols ProtocolVersion { get; set; }
    public CipherAlgorithmType CipherAlgorithm { get; set; }
    public HashAlgorithmType HashAlgorithm { get; set; }
    public X509Certificate RemoteCertificate { get; set; }
}

1
你绝不能使用单独的连接来确定这些信息;那会打开一个巨大的 TOCTOU 漏洞。 - Ben Voigt
因为你需要连接到的服务器必须要求这样做?(就我个人而言,我不会对我的网络做出任何假设。我已经有了保障措施)。我仍然不知道这与OP请求有何关联。如果他的网络受到攻击,他将无论如何得到错误的响应。也许使用两个不同的连接并获得不同的结果可以为他提供一些提示。 - Jimi
@Jimi 干得好!我喜欢你简化的解决方案,最后的方法更简短,没有递归调用。 - Frederic
@Frederic 谢谢。我不喜欢使用反射来解决这个问题。我知道密码/哈希“分析”可能很烦人。我回答是因为我觉得这项研究很有趣。我希望能想出更“优雅”的解决方案。 - Jimi
@Frederic 嗯,这是一件好事。--使用字段而不是属性似乎缩短了一步到达m_Worker的路径。但是,正如我所说,它确实是同一个对象。如果你想仅引用字段,出于“命名约定”的考虑,我不认为你不应该这样做。m_NetworkStream是相关属性返回的同一对象。当然,你不能拥有所有的属性名称。 - Jimi
显示剩余9条评论

2
下面的解决方案肯定是一种“hack”,因为它使用了反射,但它目前涵盖了大多数与HttpWebRequest相关的情况。如果无法确定Tls版本,它将返回null。它还在同一请求中验证Tls版本,在您写入任何内容到请求流之前。如果在调用该方法时还没有发生流Tls握手,它将触发它。
您的示例用法如下:
HttpWebRequest request = (HttpWebRequest)WebRequest.Create("...");
request.Method = "POST";
if (requestPayload.Length > 0)
{
    using (Stream requestStream = request.GetRequestStream())
    {
        SslProtocols? protocol = GetSslProtocol(requestStream);
        requestStream.Write(requestPayload, 0, requestPayload.Length);
    }
}

方法如下:

public static SslProtocols? GetSslProtocol(Stream stream)
{
    if (stream == null)
        return null;

    if (typeof(SslStream).IsAssignableFrom(stream.GetType()))
    {
        var ssl = stream as SslStream;
        return ssl.SslProtocol;
    }

    var flags = BindingFlags.NonPublic | BindingFlags.Instance;

    if (stream.GetType().FullName == "System.Net.ConnectStream")
    {
        var connection = stream.GetType().GetProperty("Connection", flags).GetValue(stream);
        var netStream = connection.GetType().GetProperty("NetworkStream", flags).GetValue(connection) as Stream;
        return GetSslProtocol(netStream);
    }

    if (stream.GetType().FullName == "System.Net.TlsStream")
    {
        // type SslState
        var ssl = stream.GetType().GetField("m_Worker", flags).GetValue(stream);

        if (ssl.GetType().GetProperty("IsAuthenticated", flags).GetValue(ssl) as bool? != true)
        {
            // we're not authenticated yet. see: https://referencesource.microsoft.com/#System/net/System/Net/_TLSstream.cs,115
            var processAuthMethod = stream.GetType().GetMethod("ProcessAuthentication", flags);
            processAuthMethod.Invoke(stream, new object[] { null });
        }

        var protocol = ssl.GetType().GetProperty("SslProtocol", flags).GetValue(ssl) as SslProtocols?;
        return protocol;
    }

    return null;
}

1
就像我对来这里的一半人提到的那样,不,我并不试图强制使用某种协议进行通信,我已经知道如何做了,谢谢,请仔细阅读问题。 - Frederic
3
@Frederic 对啊,你要求的是不可能的事情,而且你没有告诉我们你为什么需要它。你在问题或评论中都没有解释清楚。我们猜测你想要这样做的一个可能原因是如果使用不太安全的TLS版本,放弃连接或者做一些不同的事情,这就是我提出这个想法的原因。这并不是我的回答的全部内容,我已经花了相当多的时间和精力来测试发布的解决方案。接受或放弃都可以。 - caesay
2
你是否真的需要知道我为什么需要它?那么这就是原因:每次我连接到一个安全的URL时,我想记录使用的TLS版本,以便在将来进行故障排除时可以使用它(了解我连接到的每个安全URL使用的TLS版本可能会有所帮助)。 如果你告诉我这是不可能的,那就这样吧,这就是我提出问题的原因。如果我知道这是不可能的,我就不会问了。现在我知道了,谢谢! - Frederic
1
@Frederic:对我们来说了解你为什么需要它非常重要,因为这可以让我们建议或淘汰其他选项。当你没有明确说明,并且因为别人建议替代方案而生气时,这并不好。我已经更新了你的问题。 - caesay
我想你误解了,我什么时候生气了?我只是提醒你,你是第四个告诉我我们可以指定要使用的TLS版本的人(你看不到这一点,因为他们现在都删除了他们的答案)。因此,我提醒你,这不是我想要实现的目标。我一点也不生气,如果让你有这种感觉,很抱歉。谢谢你更新我的问题。 和平! - Frederic
显示剩余2条评论

1

我将一些想法汇集起来,编写了一个简单的方法来测试每种可用的协议,每次尝试强制使用一种特定类型的连接。

最终,我会得到一个结果列表,可以根据需要使用。

附注:此测试仅在您知道该网站在线时有效-您可以进行先前的测试以检查此内容。

    public static IEnumerable<T> GetValues<T>()
    {
        return Enum.GetValues(typeof(T)).Cast<T>();
    }

    private Dictionary<SecurityProtocolType, bool> ProcessProtocols(string address)
    {   
        var protocolResultList = new Dictionary<SecurityProtocolType, bool>();
        var defaultProtocol = ServicePointManager.SecurityProtocol;

        ServicePointManager.Expect100Continue = true;
        foreach (var protocol in GetValues<SecurityProtocolType>())
        {
            try
            {
                ServicePointManager.SecurityProtocol = protocol;

                var request = WebRequest.Create(address);
                var response = request.GetResponse();

                protocolResultList.Add(protocol, true);
            }
            catch
            {
                protocolResultList.Add(protocol, false);
            }
        }

        ServicePointManager.SecurityProtocol = defaultProtocol;

        return protocolResultList;
    }

希望这会有所帮助。

0

我能想到的唯一方法是使用SslStream进行测试连接,然后检查SslProtocol属性。

TcpClient client = new TcpClient(decodedUri.DnsSafeHost, 443);
SslStream sslStream = new SslStream(client.GetStream());

// use this overload to ensure SslStream has the same scope of enabled protocol as HttpWebRequest
sslStream.AuthenticateAsClient(decodedUri.Host, null,
    (SslProtocols)ServicePointManager.SecurityProtocol, true);

// Check sslStream.SslProtocol here

client.Close();
sslStream.Close();

我已经确认了sslStream.SslProtocl始终与TlsStream.m_worker.SslProtocol相同,这是由HttpWebRequestConnection使用的。


干得好。还有另一种方法,可以使用HttpWebRequest证书回调。你能想明白吗? - Jimi
@Jimi 在 TLS 连接中,RemoteCertificateValidationCallback 只提供了发送方、需要验证的证书、证书链和由默认验证器检测到的验证错误,但未提供任何其他的 TLS 连接信息。很遗憾,这是个死胡同。 - Alex.Wei
你走在正确的道路上。这是一条非常神秘的信息(只是其他点状数字中的一个数字)。这里有一个提示:WebSockets SSPIWrapper。看看证书链中出现的证书的属性 :) - Jimi
你需要检查HTTP请求使用的流,而不是单独的连接。仅仅因为两个连接在你的良好测试网络中具有相同的TLS特征,并不能让你得出它们在威胁模型下“始终相同”的结论。 - Ben Voigt
@BenVoigt 正确,我确实想知道我的实际HTTP请求流中使用了哪个TLS版本,而不是另一个。虽然这个解决方案提供了很好的知识,但它仍然需要对URI进行单独调用,这在技术上可能会返回不同的结果?(我不知道) - Frederic
@Frederic 实际上,http连接和SslStream都在内部使用SslState,只有一些微小的差异,不会影响ssl/tls连接的行为。但如果您对此不满意,您必须通过RequestStream/ResponseStream.m_Connection.m_NetworkStream.m_Worker.SslProtocol进行反射。这是我从一开始就知道的方法,也是我试图避免的方法,因为它涉及太多内部类和私有成员。 - Alex.Wei

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接