If you've ever clicked the "Decrypt HTTPS Traffic" button in Fiddler, you know how extremely easy it is to initiate a man-in-the-middle attack, and watch (and even modify) the encrypted traffic between an application and a server. You can see passwords and app private information and all kinds of very interesting data that the app authors probably never intended to have viewed or modified.
It's also easy to protect against man-in-the-middle attacks, but few apps do.
For instance, I own a Ring doorbell and have the Ring (UWP) app installed in Windows so I can (among other things) ensure when outgoing Siren of Shame packages are picked up by the post. Here's a recent HTTPS session between the app and the server:
I wonder what would happen if I modified the value of "bypass_account_verification
" to True
upon requests to https://api.ring.com/clients_api/profile?. You can do that type of thing with little effort in the FiddlerScript section, which I show in a supplementary episode of Code Hour:
If you're writing an app, your risk of man-in-the-middle attacks isn't limited to curious developers willing to install a Fiddler root certificate in order to hide all HTTPS snooping errors. Consider this scary and articulate stack overflow answer:
Anyone on the road between client and server can stage a man in the middle attack on https. If you think this is unlikely or rare, consider that there are commercial products that systematically decrypt, scan and re-encrypt all ssl traffic across an internet gateway. They work by sending the client an ssl cert created on-the-fly with the details copied from the "real" ssl cert, but signed with a different certificate chain. If this chain terminates with any of the browser's trusted CA's, this MITM will be invisible to the user.
The under-utilized solution for app developers is: certificate pinning.
UWP Pinning? No Soup For You
Certificate pinning, or public key pinning, is the process of limiting the servers that your application is willing to communicate with, primarily for the purpose of eliminating man in the middle attacks.
If the Ring app above had implemented certificate pinning, then they would have received errors on all HTTPS requests that Fiddler had intercepted and re-signed in transit. My personal banking app in Windows does this and on startup gives the error "We're sorry, we're unable to complete your request. Please try again" if it detects that the signing certificate isn't from whom it should be (even if it is fully trusted).
Implementing certificate pinning is usually pretty easy in .NET. Typically, it involves the ServerCertificateVerificationCallback
method on the ServicePointManager
. It then looks something like this:
public static async void Main(string[] args)
{
ServicePointManager.ServerCertificateValidationCallback = PinPublicKey;
WebRequest request = WebRequest.Create("https://...");
WebResponse response = await request.GetResponseAsync();
}
private static bool PinPublicKey(object sender, X509Certificate certificate,
X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
if (certificate == null || chain == null)
return false;
if (sslPolicyErrors != SslPolicyErrors.None)
return false;
String pk = certificate.GetPublicKeyString();
return pk.Equals(PUB_KEY);
}
That works for all requests in the AppDomain (which, incidentally, is bad for library providers, but convenient for regular app developers). You could also do it on a request by request basis by setting the ServerCertificateCustomValidationCallback
method of the HttpClientHandler
for an HttpClient
(see example below).
Either way, notice the GetPublicKeyString()
method. That's a super-useful method that'll extract out the public key so you can compare it with a known value. As OWASP describes in the Pinning Cheat Sheet, this is safer than pinning the entire certificate because it avoids problems if the server rotates its certificates.
That works beautifully in Xamarin and .NET Core. Unfortunately, there's no ServicePointManager
in Universal Windows Platform (UWP) apps. Also, as you'll see we won't be given an X509Certificate
object so getting the public key is harder. There's also virtually zero documentation on the topic and so the following section represents a fair amount of time I spent fiddling around.
UWP Certificate Pinning Solved (Kinda)
As described by this Windows Apps Team blog, there are two HttpClients in UWP:
Two of the most used and recommended APIs for implementing the HTTP client role in a managed UWP app are System.Net.Http.HttpClient
and Windows.Web.Http.HttpClient
. These APIs should be preferred over older, discouraged APIs such as WebClient
and HttpWebRequest
(although a small subset of HttpWebRequest
is available in UWP for backward compatibility).
If you're tempted to use System.Net.Http.HttpClient
because it's cross platform or because you want to use the ServerCertificateCustomValidationCallback
method I mentioned earlier, then you're in for an unpleasant surprise when you attempt to write the following code:
HttpMessageHandler handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = OnCertificateValidate
};
var httpClient = new System.Net.Http.HttpClient(handler);
UWP will give you this response:
System.PlatformNotSupportedException: The value 'System.Func`5[System.Net.Http.HttpRequestMessage,
System.Security.Cryptography.X509Certificates.X509Certificate2,
System.Security.Cryptography.X509Certificates.X509Chain,System.Net.Security.SslPolicyErrors,
System.Boolean]' is not supported for property 'ServerCertificateCustomValidationCallback'.
Even using Paul Betts' awesome ModernHttpClient doesn't get around the problem. The only solution I've found is to use the Windows.Web.Http.HttpClient
and the ServerCustomValidationRequested
event like this:
using (var filter = new HttpBaseProtocolFilter())
{
filter.CacheControl.ReadBehavior = HttpCacheReadBehavior.NoCache;
filter.ServerCustomValidationRequested += FilterOnServerCustomValidationRequested;
var httpClient = new Windows.Web.Http.HttpClient(filter);
var result = await httpClient.GetStringAsync(new Uri(url));
filter.ServerCustomValidationRequested -= FilterOnServerCustomValidationRequested;
Notice the CacheControl
method. I thought I was going mad for a while when requests stopped showing up in Fiddler. Turns out Windows.Web.Http.HttpClient
's cache is so aggressive that unlike System.Net.Http.HttpClient
, it won't make subsequent requests to a URL it's seen before, it'll just return the previous result.
The last piece of the puzzle is the FilterOnServerCustomValidationRequested
method and how to extract a public key from a certificate without the benefit of an X509Certificate:
private void FilterOnServerCustomValidationRequested(
HttpBaseProtocolFilter sender,
HttpServerCustomValidationRequestedEventArgs args
) {
if (!IsCertificateValid(
args.RequestMessage,
args.ServerCertificate,
args.ServerCertificateErrors))
{
args.Reject();
}
}
private bool IsCertificateValid(
Windows.Web.Http.HttpRequestMessage httpRequestMessage,
Certificate cert,
IReadOnlyList sslPolicyErrors)
{
if (sslPolicyErrors.Count > 0)
{
return false;
}
if (!RequestRequiresCheck(httpRequestMessage.RequestUri)) return false;
var certificateSubject = cert?.Subject;
bool subjectMatches = certificateSubject == CertificateCommonName;
var certificatePublicKeyString = GetPublicKey(cert);
bool publicKeyMatches = certificatePublicKeyString == CertificatePublicKey;
return subjectMatches && publicKeyMatches;
}
private static string GetPublicKey(Certificate cert)
{
var certArray = cert?.GetCertificateBlob().ToArray();
var x509Certificate2 = new X509Certificate2(certArray);
var certificatePublicKey = x509Certificate2.GetPublicKey();
var certificatePublicKeyString = Convert.ToBase64String(certificatePublicKey);
return certificatePublicKeyString;
}
private bool RequestRequiresCheck(Uri uri)
{
return uri.IsAbsoluteUri &&
uri.AbsoluteUri.StartsWith("https://", StringComparison.CurrentCultureIgnoreCase) &&
uri.AbsoluteUri.StartsWith(HttpsBaseUrl, StringComparison.CurrentCultureIgnoreCase
);
}
There may be a less expensive version of the GetPublicKey()
method that involves indexing into the type array, but the above seems pretty clean to me. The only possible issue is you might need to reference the System.Security.Cryptography.X509Certificates
nuget package from Microsoft depending on your UWP version.
You can see my final version in the Maintenance project of the Siren of Shame UWP app I'm building, along with a possible drop-in CertificatePinningHttpClientFactory.
Summary
Hopefully, this clarifies what certificate pinning is, why you'd want it, and how to implement it. If you found it useful or have any questions, please share in the comments or hit me up on twitter.