Decrypting SSH traffic with Wireshark

2025-03-02

We were facing a strange bug at work in one of our tests, where a character seemingly went missing in a command sent over SSH. To ensure that the character was indeed properly sent by the client and not the result of some sort of corruption, I wanted to look at the raw traffic. SSH is of course encrypted which makes it harder.

Wireshark (4.2+) has the ability to decrypt SSH traffic. It requires dumping the shared secret resulting from the SSH key exchange, and the cookie linked to that key exchange, so that Wireshark knows which stream to decrypt using which shared secret. These need to be dumped in a file with a specific format: <cookie> SHARED_SECRED <secret_hex>.

For SSL/TLS, many implementations allow to dump the shared secret using the SSLKEYLOGFILE environment variable. There is unfortunately no such "standard" for SSH, and OpenSSH has no way to dump the secret even in debug mode. We mostly test with Paramiko as the SSH client, which does not support dumping the secret either, but luckily Paramiko being implemented in Python makes it easy to hack something in.

from paramiko.transport import Transport

old_set_K_H = Transport._set_K_H

def sshkeylogfile(self, k, h):
    logpath_str = os.environ.get("SSHKEYLOGFILE", None)
    if not logpath_str:
        return
    logpath = Path(logpath_str)
    cookie = self._latest_kex_init[1:17].hex()
    key_hex = hex(k)[2:]
    key_hex = ((len(key_hex) % 2) * "0") + key_hex
    with open(logpath, "a+") as handle:
        print(
            f"{cookie} SHARED_SECRET {key_hex}",
            file=handle,
        )

def patched_set_k_h(self, k, h):
    sshkeylogfile(self, k, h)
    old_set_K_H(self, k, h)

Transport._set_K_H = patched_set_k_h

To test this I ran the example server from the asyncssh library, captured the traffic with wireshark and started paramiko as the client with the above patch.

tshark -f 'port 8022' -i lo -w capture.pcap
import paramiko
from paramiko.client import SSHClient

client = SSHClient()
client.load_system_host_keys()
client.set_missing_host_key_policy(paramiko.WarningPolicy())
client.connect('localhost', port=8022, allow_agent=False, look_for_keys=True, username="guest", password="")
stdin, stdout, stderr = client.exec_command('')
print(stdout.read())
client.close()

The client logs the secret to the keylog file:

7d313898b4cfeb9c179d68e542dc2f4c SHARED_SECRET 743acb263818045c693347b8f9234214aa1b2af17a58ec7b5cf81827af2dce71

and looking at the capture, you can find the above cookie starting with 7d31 in the key exchange message:

key exchange cookie
Key exchange cookie in plaintext

We can inject the secret into the pcap using editcap:

editcap --inject-secrets ssh,keylog capture.pcap capture-with-secrets.pcap

and opening capture-with-secrets.pcap in Wireshark, we can see the decrypted SSH messages! (If using a non-standard SSH port like here, it has to be added to the Wireshark settings.)

decrypted SSH message
Decrypted SSH message

Success!

I implemented the above in our test framework, which allowed to look at the SSH traffic the next time we reproduced the missing character bug. The character was present in the raw messages, which confirmed that the problem was on the server side. The exact cause of the bug is still elluding us, so to be continued!