snuck.me, an open-source service detecting SSL man-in-the-middle

February 20, 2017

snuck.me is an open-source web service for querying an arbitrary site’s SSL certificate. A user can compare the results of this query with the certificate that her browser is reporting to help determine if there is a man in the middle:

snuck.me Infographic

How it works

snuck.me works by embedding a public key directly into the website’s source:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jsencrypt/2.3.1/jsencrypt.min.js" integrity="sha256-WgvkBqG9+UolqdFC1BJOPcy961WTzXj7C9I034ndc4k=" crossorigin="anonymous"></script>
const encrypt = new JSEncrypt();
encrypt.setPublicKey(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsvcZU2It/Cjv12DcLUfE
BXrm+DH2v4x+dyH45Rka95JIAMrmu4OdMjSQQbqhb2pYFVOpRfhUoCu50mOKrmGe
f+ILjBnDtpyTpKf+9QsgmVSfeFnlf6Tew0qgKyUiO9E4cmm14BbqjJrYWGR/0Qas
OSRAWX1SoVzho/sSMBwuadekdaC77Pfvk5uMJUkgck5BzQBLCuPXmLsDsNoAmGck
cfTuEF+s2ae+PeHjhH6g2VaIgqVSaOTe3e2O8Dfukw8GQ5q03kmvA5N0sA+9kk07
ntve3xIBZOUpmB7xEHkG8hHjI6j3oVESo2/K764my0F3JZ9iVSH0jnVASQQ0nmAh
HQIDAQAB
-----END PUBLIC KEY-----`);

When the user wants to check a certificate, she generates a JSON payload to send to the snuck.me server. This payload contains the URL corresponding to the certificate that the user wants to retrieve, and a random 20 character password:

const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function randomString(length) {
    let result = '';
    for (let i = length; i > 0; --i) result += alphabet[Math.floor(Math.random() * alphabet.length)];
    return result;
}
let password = randomString(20);
const optionsObj = {
    url: $("#url").val(),
    password: password
};
const options = JSON.stringify(optionsObj);

All of this is done in the browser, i.e. on the user’s machine. The payload is encrypted using snuck.me’s public key:

const options = JSON.stringify(optionsObj);
const encryptedEncodedOptions = encrypt.encrypt(options).replace(/\//g, "_").replace(/\+/g, "-");

This payload is then sent to the remote snuck.me server for processing:

// urlPrefix is set to a CORS-enabled Amazon API Gateway URL
const url = urlPrefix + encryptedEncodedOptions;
$.ajax({url: url,
  // ...
);

snuck.me decrypts the payload using its (secret) private key. It queries the requested URL for certificate information, then uses the user provided password to encrypt the certificate using AES-256. This payload is sent back in the body of the HTTP response to the client.

The client simply decrypts the results with the password it provided to snuck.me and displays the results:

<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.min.js" integrity="sha256-u6BamZiW5tCemje2nrteKC2KoLIKX9lKPSpvCkOhamw=" crossorigin="anonymous"></script>

$.ajax({url: url,
    success: function(response){
        const plainbytes = CryptoJS.AES.decrypt(response, password);
        const plaintext = plainbytes.toString(CryptoJS.enc.Utf8);
        const crt = JSON.parse(plaintext);
        // Render the certificate.
    },
    error: function(result) {
        // Report issues to the user.
    }
});

Server side

You can set up your own snuck.me server very easily. Here’s a template for a node.js application:

"use strict";

const express = require("express");
const app = express();
const NodeRSA = require("node-rsa");
const sslCertificate = require('get-ssl-certificate');
const AES = require("crypto-js/aes");
const cors = require('cors');

const corsOptions = {
    origin: 'https://your.domain.here'
};

const rsa = new NodeRSA(`-----BEGIN RSA PRIVATE KEY-----
***
YOUR_PRIVATE_KEY_HERE
***
-----END RSA PRIVATE KEY-----`, {
    encryptionScheme: 'pkcs1'
});

app.get('/in/:opt', cors(corsOptions), function(req, res){
    try {
        const optionsJsonEncryptedEncoded = req.params.opt.replace(/\_/g, "/").replace(/\-/g, "+");
        const optionsJsonEncoded = rsa.decrypt(optionsJsonEncryptedEncoded, 'base64');
        const optionsJson = new Buffer(optionsJsonEncoded, 'base64').toString();
        const options = JSON.parse(optionsJson);
        const remoteUrl = options.url;
        const password = options.password;
        if(!password || !remoteUrl) {
            throw new Error("Bad input");
        }
        sslCertificate.get(remoteUrl)
                .then(function(certificate) {
                certificate.success = true;
                certificate.message = `Found certificate for ${remoteUrl}`;
                const plaintext = JSON.stringify(certificate);
                const ciphertext = AES.encrypt(plaintext, password).toString();
                res.status(200).send(ciphertext);
            }).catch(function(reason){
                const plaintext = JSON.stringify({
                    success: false,
                    message: `Unable to find certificate for ${remoteUrl}`
                });
                const ciphertext = AES.encrypt(plaintext, password).toString();
                res.status(200).send(ciphertext);
            });
    } catch (ex) {
        res.status(400).send();
    }
});

app.listen(8000);

You’ll obviously want to copy the source from snuck.me and change urlPrefix to point to your server’s /in/ route:

const urlPrefix = "https://my.domain.here/in/"
const url = urlPrefix + encryptedEncodedOptions;

Then serve the snuck.me html from some route!

Why does this work?

This technique works because the man in the middle cannot modify your query. It is encrypted with snuck.me’s public key, and only snuck.me can decrypt it. Since the man in the middle cannot know the password you provided in your query, it cannot return bogus results to you by encrypting a spoofed response.

If the man in the middle knows about snuck.me, he could modify snuck.me’s source. If you want to protect against this attack, you’ll need to verify the source out of band. One way to do this is to visit snuck.me from a known good location and hash the source. You can then hash the source of snuck.me in the at-risk setting and compare hashes.

Of course, all bets are off if the man in the middle is also a man in your device. You should give up any expectations of privacy in this case.

Example

We’ll wrap this post up with an example where we inspect Google’s certificate with snuck.me using Firefox 51.0.1:

Example 1

  • Click the lock:

Example 2

  • Click the right arrow to see additional information about the certificate:

Example 3

  • Click “More Information”:

Example 4

  • Click on the security tab, then “View Certificate”:

Example 5

  • Keep this open. Now visit snuck.me and query www.google.com:

Example 6

Compare the SHA1 Fingerprints of the results! If there’s a mismatch, you’ve probably got a man in the middle.

Feedback

Please email me (snuckme at lospi dot net) with any bugs!