[Crypto] SSH protocol dissected, part I: toy SSH client in ~1k SLOC

Here a toy SSH client is to be described.

This is a little known fact, but SSH protocol allows to turn off encryption, setting "none" to cipher. It's no more secure than telnet, of course, but can be used for debugging purposes.

This feature is disabled by default, so both OpenSSH server and client are to be patched to turn off encryption.

I found it invaluable for learning, to dissect unencrypted SSH packets.

Preliminaries

What's with AES-CTR?

Many knows about such modes as ECB (that Linux penguin), CBC, CFB, OFB. Except ECB, CBC/CFB/OFB are 'feedback' modes, meaning that each block encryption process depends on previous. If there is no previous block (we encrypt a first block), it depends on IV (Initialization Vector). Also, you can't modify a random block without error propagation.

Unlike 'feedback' modes, CTR (counter mode) encrypts each block with incrementing counter plus key. This offers several advantages. Several blocks can be encrypted/decrypted in parallel, given multicore CPU. Even more, several SSH packets can be encrypted/decrypted in parallel. This is impossible for 'feedback' modes.

Also, particular/random block can be picked as a single and decrypted/modified/encrypted, without traversing all the rest. There is no error propagation. To protect from single block modifications, MAC is to be used.

Same property has GCM (Galois/Counter Mode), but it can also compute MAC during encryption/decryption, as a bonus.

It's widely believed that CTR and GCM offers the same level of security as 'feedback' modes.

What's with SHA?

In fact, there are 3 SHA algorithms.

When you see the 'SHA-256' name, this is usually means SHA2-256, not SHA3-256.

What's with MAC (message authentication code)?

MAC is like checksum. Earlier SSH versions used CRC32 as checksum, which is insecure -- attacker can just recalculate it after modification. This could be true for any SHA hash used instead of CRC32.

But MAC is different - it's "keyed-hash message authentication code", meaning, that to calculate a hash, one must also posses a key. During SSH communication, only client and server have these keys.

In simplest term, you can think that MAC is just HASH(key || message), where '||' is concatenation and HASH is any hash algo you like. Real MACs are slightly more complex (to protect against numerous attacks), but the essence is just this.

What's with DH (Diffie–Hellman key exchange)?

A very important algorithm in cryptography. Used in SSH. It's advised to read my blog post about DH before.

Setup

To turn off encryption, OpenSSH-9.0 is to be patched and recompiled. Also, I added numerous printf's ("debug crumbs") which helped me a great deal in understanding SSH protocol.

I used OpenBSD version of OpenSSH (not 'portable'): openssh-9.0-openbsd.patch

(OpenBSD is not a prerequisite. I just got used to it. All this will work on any Unix or Linux.)

Here is 'portable' OpenSSH patched to support 'none' cipher: openssh-9.0p1.patch.

Also, libssh2 example patched to turn off encryption: libssh2-1.10.0.patch.

libssh2 to be compiled as:

./configure --enable-crypt-none

Two OpenSSH daemons can coexist on the same (virtual) machine. Run the second on different port. Here is how:

/root/openssh/ssh/sshd/sshd -f sshd_config_tmp -D -E log

My minimal config:

Port 669

SyslogFacility DAEMON
LogLevel DEBUG3

PermitRootLogin yes
AuthorizedKeysFile      .ssh/authorized_keys

PidFile /var/run/sshd_669.pid

Subsystem       sftp    /usr/libexec/sftp-server

You can run patched libssh2:

./ssh2_exec 192.168.1.104 dennis megapass "uname -a"

Or patched OpenSSH client:

ssh -v -oCiphers=none -p 669 dennis@192.168.1.104

Example of not encrypted session

Now run my toyssh_v1.py:

./toyssh_v1.py -vv -h 192.168.1.104 -u dennis -pass megapass -port 669 -c uptime -no-encryption

ToySSH will produce a lot of debug info and dumps. And so is patched OpenSSH 9.0.

Let's see all the packets between client and server.

When you first connect to SSH server (even with netcat), you see a banner, like:

got from server:
00000000: 53 53 48 2D 32 2E 30 2D  4F 70 65 6E 53 53 48 5F  SSH-2.0-OpenSSH_
00000010: 39 2E 30 0D 0A                                    9.0..

ToySSH sends his banner:

my_send() sending:
00000000: 53 53 48 2D 32 2E 30 2D  54 6F 79 53 53 48 5F 30  SSH-2.0-ToySSH_0
00000010: 2E 31 0D 0A                                       .1..

The server sends list of supported algorithms:

my_recv_plain() got:
00000000: 00 00 04 3C 05 14 20 F9  EA 42 49 03 41 1B D9 72  ...... ..BI.A..r
00000010: B8 38 28 14 3C 5F 00 00  01 09 73 6E 74 72 75 70  .8(.._....sntrup
00000020: 37 36 31 78 32 35 35 31  39 2D 73 68 61 35 31 32  761x25519-sha512
00000030: 40 6F 70 65 6E 73 73 68  2E 63 6F 6D 2C 63 75 72  @openssh.com,cur
00000040: 76 65 32 35 35 31 39 2D  73 68 61 32 35 36 2C 63  ve25519-sha256,c
00000050: 75 72 76 65 32 35 35 31  39 2D 73 68 61 32 35 36  urve25519-sha256
[...]
000003F0: 31 32 2C 68 6D 61 63 2D  73 68 61 31 00 00 00 15  12,hmac-sha1....
00000400: 6E 6F 6E 65 2C 7A 6C 69  62 40 6F 70 65 6E 73 73  none,zlib@openss
00000410: 68 2E 63 6F 6D 00 00 00  15 6E 6F 6E 65 2C 7A 6C  h.com....none,zl
00000420: 69 62 40 6F 70 65 6E 73  73 68 2E 63 6F 6D 00 00  ib@openssh.com..
00000430: 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................

Which are (as of OpenSSH 9.0):

kex_algorithms: sntrup761x25519-sha512@openssh.com
kex_algorithms: curve25519-sha256
kex_algorithms: curve25519-sha256@libssh.org
kex_algorithms: ecdh-sha2-nistp256
kex_algorithms: ecdh-sha2-nistp384
kex_algorithms: ecdh-sha2-nistp521
kex_algorithms: diffie-hellman-group-exchange-sha256
kex_algorithms: diffie-hellman-group16-sha512
kex_algorithms: diffie-hellman-group18-sha512
kex_algorithms: diffie-hellman-group14-sha256
server_host_algorithms: rsa-sha2-512
server_host_algorithms: rsa-sha2-256
server_host_algorithms: ecdsa-sha2-nistp256
server_host_algorithms: ssh-ed25519
encryption_algorithms: chacha20-poly1305@openssh.com
encryption_algorithms: aes128-ctr
encryption_algorithms: aes192-ctr
encryption_algorithms: aes256-ctr
encryption_algorithms: aes128-gcm@openssh.com
encryption_algorithms: aes256-gcm@openssh.com
encryption_algorithms: none
mac_algorithms: umac-64-etm@openssh.com
mac_algorithms: umac-128-etm@openssh.com
mac_algorithms: hmac-sha2-256-etm@openssh.com
mac_algorithms: hmac-sha2-512-etm@openssh.com
mac_algorithms: hmac-sha1-etm@openssh.com
mac_algorithms: umac-64@openssh.com
mac_algorithms: umac-128@openssh.com
mac_algorithms: hmac-sha2-256
mac_algorithms: hmac-sha2-512
mac_algorithms: hmac-sha1
compression_algorithms: none
compression_algorithms: zlib@openssh.com

Please note "none" in "encryption_algorithms" -- it doesn't exist when a 'real' unpatched SSH server running.

Also, header + padding are added to 'payload'.

The basic SSH packet format is:

[See RFC 4253 6]

ToySSH sends its own list of algorithms. But there is only one algorithm for each function. KEX=diffie-hellman-group-exchange-sha256. Cipher=none. MAC=hmac-sha2-256. Only this is important so far. All the rest is to be ignored yet.

my_send() sending:
00000000: 00 00 00 D4 04 14 11 11  11 11 11 11 11 11 11 11  ................
00000010: 11 11 11 11 11 11 00 00  00 24 64 69 66 66 69 65  .........$diffie
00000020: 2D 68 65 6C 6C 6D 61 6E  2D 67 72 6F 75 70 2D 65  -hellman-group-e
00000030: 78 63 68 61 6E 67 65 2D  73 68 61 32 35 36 00 00  xchange-sha256..
00000040: 00 21 73 73 68 2D 72 73  61 2C 72 73 61 2D 73 68  .!ssh-rsa,rsa-sh
00000050: 61 32 2D 32 35 36 2C 72  73 61 2D 73 68 61 32 2D  a2-256,rsa-sha2-
00000060: 35 31 32 00 00 00 04 6E  6F 6E 65 00 00 00 04 6E  512....none....n
00000070: 6F 6E 65 00 00 00 0D 68  6D 61 63 2D 73 68 61 32  one....hmac-sha2
00000080: 2D 32 35 36 00 00 00 0D  68 6D 61 63 2D 73 68 61  -256....hmac-sha
00000090: 32 2D 32 35 36 00 00 00  15 6E 6F 6E 65 2C 7A 6C  2-256....none,zl
000000A0: 69 62 40 6F 70 65 6E 73  73 68 2E 63 6F 6D 00 00  ib@openssh.com..
000000B0: 00 15 6E 6F 6E 65 2C 7A  6C 69 62 40 6F 70 65 6E  ..none,zlib@open
000000C0: 73 73 68 2E 63 6F 6D 00  00 00 00 00 00 00 00 00  ssh.com.........
000000D0: 00 00 00 00 AA AA AA AA                           ........

Please note four 0xAA bytes at the end. This is padding bytes added by ToySSH.

It's recommended by RFC to use random bytes in padding.

libssh2 uses random padding. So when you see noise between payload and MAC, this may be padding.

OpenSSH 9.0 use zero bytes for padding.

But I use 0xAA bytes so that the padding would be clearly visible in this demonstration.

Also, KEX packet has 'random cookie' field (16 bytes). It's random and used to randomize KEX packet, so that it will be different each time. Again, for demonstration, I use 16 0x11 bytes.

Here we send SSH_MSG_KEX_DH_GEX_REQUEST message. We send 3 integers:

DH_GEX_min, DH_GEX_bits, DH_GEX_max = 512, 512, 8192

We say that we require minumum DH bits 512, maximum 8192 bits. (In fact, real OpenSSH 9.0 client today sends 2048/8192, requiring at least 2048 bits.)

When I set 8k DH, ToySSH can login, but pow() operation is very slow. Login happens after 1-2 seconds on my venerable Intel(R) Xeon(R) CPU E31220 @ 3.10GHz. Yes, this is Python, after all, and all SSH implementations are usually written in optimized pure C. But you got the idea, more secure is more slow. Sysadmins seeks balance between security and performance, and 2k DH is a good choice in 2022.

my_send() sending:
00000000: 00 00 00 14 06 22 00 00  02 00 00 00 02 00 00 00  ....."..........
00000010: 20 00 AA AA AA AA AA AA                            .......

Servers sends DH parameters. Which are also called "DH group". These are two numbers: P and G. See my blog post about DH about it.

my_recv_plain() got:
00000000: 00 00 01 14 08 1F 00 00  01 01 00 C1 17 F4 B6 31  ...............1
00000010: CA 03 2F D2 F0 0A C0 D9  A5 47 3D 8E 56 DA 24 6F  ../......G=.V.$o
00000020: C4 4F D5 94 BF 56 57 D3  99 E4 53 72 83 41 CC 92  .O...VW...Sr.A..
00000030: 0E E9 12 77 29 63 76 83  D2 68 CA 3B 62 F5 CB 61  ...w)cv..h.;b..a
[...]
000000F0: 8A 23 2F AD 4C DC 6E 58  0F EC 73 0D 07 0D 49 E8  .#/.L.nX..s...I.
00000100: 8A 23 C5 28 A4 98 5F 87  DB EA 23 00 00 00 01 02  .#.(.._...#.....
00000110: 00 00 00 00 00 00 00 00                           ........
...
DH GEX modulus (P): 0xc117f4b6[...]87dbea23
binlog(P): 2048
DH GEX base (G): 2

Basically, this is a random number from /etc/ssh/moduli file. (Or /etc/moduli in OpenBSD.) As of OpenSSH 9.0, there are ~400 parameters. Ranging from 2048 bits from 8192.

As we know, attacker can get prepared to a G/P parameters. So SSH increased number of them and picks randomly. Also, you can add your own parameters or remove existing, if they are suspected to insecuriness. Also, they are very slow to generate.

Multi-precision numbers (bignums) are sent as is, prepended by length (encoded as u32).

ToySSH generates DH public key and send it to server:

client_e 0x27606b0[...]a83f1b6
...
my_send() sending:
00000000: 00 00 01 0C 06 20 00 00  01 00 27 60 6B 0F 73 F6  ..... ....'`k.s.
00000010: 51 59 05 2B 5F 76 90 15  EA 7D E2 AE 61 A0 72 C6  QY.+_v...}..a.r.
00000020: A5 CB 7C E7 C8 3B 89 B9  57 A6 BF 68 89 95 45 68  ..|..;..W..h..Eh
00000030: D7 4C C6 61 4A A0 66 44  69 F5 48 82 DF 46 AC 54  .L.aJ.fDi.H..F.T
[...]
000000F0: 45 C4 34 B3 43 96 DF 44  55 94 79 C5 4B 83 7E 10  E.4.C..DU.y.K.~.
00000100: 1C 09 D2 2C 42 DA 1A 83  F1 B6 AA AA AA AA AA AA  ...,B...........

Servers sends a big pack of info, which includes his DH public key, RSA public key and something else, which are to be ignored so far:

my_recv_plain() got:
00000000: 00 00 04 44 0B 21 00 00  01 97 00 00 00 07 73 73  ...D.!........ss
00000010: 68 2D 72 73 61 00 00 00  03 01 00 01 00 00 01 81  h-rsa...........
00000020: 00 DD 85 12 E3 82 7B 61  34 3F E7 ED 2C 21 48 3F  ......{a4?..,!H?
00000030: 27 C7 11 DC D6 62 C2 74  A7 2C 78 42 66 1D B2 CD  '....b.t.,xBf...
00000040: 96 09 E5 32 CB ED 15 E8  1B 9B 60 5F 6E EE C1 C1  ...2......`_n...
[...]
00000400: 63 B8 6B 5A 7A 69 20 4F  A4 40 69 36 30 F9 FF 3B  c.kZzi O.@i60..;
00000410: 33 62 98 FB DB 59 96 79  37 D2 28 39 85 4B 4C 52  3b...Y.y7.(9.KLR
00000420: BB AF 14 D3 FE 59 80 92  17 89 A4 11 69 E3 62 D3  .....Y......i.b.
00000430: 28 D0 8E 06 1F 08 60 99  FE EB CA 19 FC 00 00 00  (.....`.........
00000440: 00 00 00 00 00 00 00 00                           ........

KEX_host_key: will be hashed as blob:
00000000: 00 00 00 07 73 73 68 2D  72 73 61 00 00 00 03 01  ....ssh-rsa.....
00000010: 00 01 00 00 01 81 00 DD  85 12 E3 82 7B 61 34 3F  ............{a4?
00000020: E7 ED 2C 21 48 3F 27 C7  11 DC D6 62 C2 74 A7 2C  ..,!H?'....b.t.,
00000030: 78 42 66 1D B2 CD 96 09  E5 32 CB ED 15 E8 1B 9B  xBf......2......
[...]
00000170: 72 92 32 48 E4 1E E7 80  7A CC B5 D3 A6 B1 F4 2A  r.2H....z......*
00000180: B9 8D E5 39 A2 84 18 D1  6F 10 D4 9E F2 88 E5 3E  ...9....o.......
00000190: 68 C2 1B 8B B0 18 45                              h.....E
host_key_type: ssh-rsa
RSA_e: 65537
RSA_modulus_n: 5027120590[...]44816056389
binlog(RSA_modulus_n): 3072
server_f: 0x4816d8757d8[...]8a763f7a08a
KEX_H_sig:
00000000: 00 00 00 0C 72 73 61 2D  73 68 61 32 2D 32 35 36  ....rsa-sha2-256
00000010: 00 00 01 80 37 7F 98 69  B4 F1 1E E9 83 0C D1 76  ....7..i.......v
00000020: F5 B5 74 11 BC 6E EF DA  EA 38 8B 63 CE 37 64 E5  ..t..n...8.c.7d.
00000030: D2 8B C4 5B 33 02 90 F4  C5 F1 2D 4E F7 E4 DD 41  ...[3.....-N...A
[...]
00000160: 40 69 36 30 F9 FF 3B 33  62 98 FB DB 59 96 79 37  @i60..;3b...Y.y7
00000170: D2 28 39 85 4B 4C 52 BB  AF 14 D3 FE 59 80 92 17  .(9.KLR.....Y...
00000180: 89 A4 11 69 E3 62 D3 28  D0 8E 06 1F 08 60 99 FE  ...i.b.(.....`..
00000190: EB CA 19 FC                                       ....

BTW, RSA public key sent by server is the very same that are located at /etc/ssh/ssh_host_rsa_key.pub, encoded in base64.

Let's see, this is real data from my experimental virtual machine:

~/1# cat /etc/ssh/ssh_host_rsa_key.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDdhRLjgnthND/n7SwhSD8nxxHc1mLCdKcseEJmHbLNlgnlMsvtFegbm2Bfbu7BwQUflOKTd4aqN6A2rMJD23XO+dOLR1tkS48Pd7WDG7cUeRAjtCAajB6hHI38zcdGkdlP1kQjjQ7puo1mpe9ezRVdwppfS0KutZ9fvZw2BfSxeXAD/1hewS9hMaq8IxOg2v5oF0VN4CFY/7vnFUqXCxKwgjgczt4/q7kLXNb62MkZ0bzZS9AdxpSWxlsZletP9rsjQT7ASDy4ATqXk8NYZDxK6a6t+fKENhG1f+aGAs6tboS0+ImS53mDGOAu+tMD6K4cftEv4GP+SxQE0RnYSoKz/xxZ10TzjGk0OFwtXWDCfUBW9putXqH6BiaBaBSqcD+ijldrLyRVOqryU5ijTFOnRIsdU/ArUU8iEScPo2+Py+EDJs9R/e0W/7qL5w1TyYYWp3peWtitmV5ykjJI5B7ngHrMtdOmsfQquY3lOaKEGNFvENSe8ojlPmjCG4uwGEU= root@bersarin.my.domain

~/1# echo "AAAAB3NzaC1yc2EAAAADAQABAAABgQDdhRLjgnthND/n7SwhSD8nxxHc1mLCdKcseEJmHbLNlgnlMsvtFegbm2Bfbu7BwQUflOKTd4aqN6A2rMJD23XO+dOLR1tkS48Pd7WDG7cUeRAjtCAajB6hHI38zcdGkdlP1kQjjQ7puo1mpe9ezRVdwppfS0KutZ9fvZw2BfSxeXAD/1hewS9hMaq8IxOg2v5oF0VN4CFY/7vnFUqXCxKwgjgczt4/q7kLXNb62MkZ0bzZS9AdxpSWxlsZletP9rsjQT7ASDy4ATqXk8NYZDxK6a6t+fKENhG1f+aGAs6tboS0+ImS53mDGOAu+tMD6K4cftEv4GP+SxQE0RnYSoKz/xxZ10TzjGk0OFwtXWDCfUBW9putXqH6BiaBaBSqcD+ijldrLyRVOqryU5ijTFOnRIsdU/ArUU8iEScPo2+Py+EDJs9R/e0W/7qL5w1TyYYWp3peWtitmV5ykjJI5B7ngHrMtdOmsfQquY3lOaKEGNFvENSe8ojlPmjCG4uwGEU=" | base64 -d | xxd -g 1
00000000: 00 00 00 07 73 73 68 2d 72 73 61 00 00 00 03 01  ....ssh-rsa.....
00000010: 00 01 00 00 01 81 00 dd 85 12 e3 82 7b 61 34 3f  ............{a4?
00000020: e7 ed 2c 21 48 3f 27 c7 11 dc d6 62 c2 74 a7 2c  ..,!H?'....b.t.,
[...]
00000170: 72 92 32 48 e4 1e e7 80 7a cc b5 d3 a6 b1 f4 2a  r.2H....z......*
00000180: b9 8d e5 39 a2 84 18 d1 6f 10 d4 9e f2 88 e5 3e  ...9....o.......
00000190: 68 c2 1b 8b b0 18 45                             h.....E

Back to our log. ToySSH computes DH shared secret:

shared_secret: 0x61c646[...]e75ea3d

The same secret known by server, but no one else, thanks to DH coolness.

Now we compute hash of 'hodgepodge'. It contains almost all data exchanged so far (easily visible to attacker), but the most critical is shared_secret (which is not visible).

[See calc_kexgex_hash() in ToySSH or kexgex_hash() in OpenSSH 9.0.]

The resulting hodgepodge is hashed by SHA256 (because ToySSH picked 'rsa-sha2-256' algorithm.

SHA256(hodgepodge):

kexgex_hash:
00000000: 80 E9 F4 B5 F3 86 92 C6  1E 66 7E 39 0F 60 6C D3  .........f~9.`l.
00000010: 30 8C 3E 2B BD 1D A2 CF  09 D0 23 F4 05 42 B1 77  0..+......#..B.w

In SSH lingo, this value is often called just 'H'.

Now derive 6 keys from 'H'. Basically, key_A is just SHA256(H, "A"), where "A" is just ASCII string. All other keys are calculated in the same manner. [See kex.c, derive_key() in OpenSSH 9.0, also RFC 4253 7.2]

derive_keys() begin
key_A:
00000000: 35 23 E8 EA 71 61 27 FB  19 30 17 25 72 24 07 1A  5#..qa'..0.%r$..
00000010: 77 BE D5 E9 4E 36 95 96  A1 9C B6 FB 1B BF 40 A1  w...N6........@.
key_B:
00000000: 6A ED FD 37 FB D9 E1 32  4A 82 03 F0 8E 30 6D E8  j..7...2J....0m.
00000010: F4 27 96 6B 39 A0 73 F5  63 9C A9 C6 85 89 DE D2  .'.k9.s.c.......
key_C:
00000000: BF 23 D4 8E 8A 9B 15 02  9A AA 26 9A 2B 58 E7 F9  .#........&.+X..
00000010: C8 68 9E 71 04 D5 E5 D7  AA 28 2F AD CA E0 0A 07  .h.q.....(/.....
key_D:
00000000: D4 D9 04 2C EB 3D C1 CC  32 3B B7 63 91 8A BA CC  ...,.=..2;.c....
00000010: 77 23 84 9B 4E 98 2F 7B  6F E0 5A B1 3A D5 9E B8  w#..N./{o.Z.:...
key_E:
00000000: A2 41 81 4D A2 22 D4 24  60 F3 B6 0D C7 30 49 E1  .A.M.".$`....0I.
00000010: B9 B1 1C B1 FA 81 0E 4A  96 3B 19 ED 46 73 57 19  .......J.;..FsW.
key_F:
00000000: 20 A2 69 45 88 FC 19 E7  E7 18 A2 8C 82 4A E1 07   .iE.........J..
00000010: 62 B6 46 64 E1 99 A5 1C  01 89 57 5D E6 FD 5A F5  b.Fd......W]..Z.

Six keys are used during SSH session. Three -- for client-to-server and three other -- in backward direction.

(Yes, different keys used for each direction. Even more -- SSH allows to use different algorithsm for each direction, but this is not used.)

One key is used as IV (or initial counter for CTR mode) for encryption. Second key -- as a key for encryption. Third key -- as a key for MAC.

Now service message, nothing special:

expecting SSH_MSG_NEWKEYS
my_recv_plain() got:
00000000: 00 00 00 0C 0A 15 00 00  00 00 00 00 00 00 00 00  ................
...
send_new_keys():
00000000: 00 00 00 0C 0A 15 AA AA  AA AA AA AA AA AA AA AA  ................
my_send() sending:
00000000: 00 00 00 0C 0A 15 AA AA  AA AA AA AA AA AA AA AA  ................

From that moment, all the data encrypted and MAC-ed, since both parties know all the keys.

But since we turned off encryption, only MAC is added to each packet. These are 32 bytes at the end of each packet. Calculated as HMAC-SHA2-256 in our case.

Sending SSH_MSG_SERVICE_REQUEST:

sending ssh-userauth
my_send() sending:
00000000: 00 00 00 1C 0A 05 00 00  00 0C 73 73 68 2D 75 73  ..........ssh-us
00000010: 65 72 61 75 74 68 AA AA  AA AA AA AA AA AA AA AA  erauth..........
00000020: 3A 06 88 36 01 E2 82 21  4D 2F 12 BB BE 47 C7 41  :..6...!M/...G.A
00000030: CC 84 C4 F0 18 9B 2F AA  C1 AB 48 9F F3 3C D2 F4  ....../...H..<..

Receiving SSH_MSG_SERVICE_ACCEPT:

my_recv_MAC() got:
00000000: 00 00 00 1C 0A 06 00 00  00 0C 73 73 68 2D 75 73  ..........ssh-us
00000010: 65 72 61 75 74 68 00 00  00 00 00 00 00 00 00 00  erauth..........
00000020: 0D 80 40 3C 52 E1 DD F7  53 71 E5 5A 87 98 C7 D9  ..@.R...Sq.Z....
00000030: D8 3E 6A 47 59 56 59 CE  8A 34 A6 0D E3 4D 54 0C  ..jGYVY..4...MT.

So far so good. Now we enumerate all possible auth modes. Several strings are sent - username, "ssh-connection", "none":

sending ssh-connection
my_send() sending:
00000000: 00 00 00 2C 06 32 00 00  00 06 64 65 6E 6E 69 73  ...,.2....dennis
00000010: 00 00 00 0E 73 73 68 2D  63 6F 6E 6E 65 63 74 69  ....ssh-connecti
00000020: 6F 6E 00 00 00 04 6E 6F  6E 65 AA AA AA AA AA AA  on....none......
00000030: 8C FF 87 A2 DC 60 20 30  6C DB 27 B8 C2 6E 91 58  .....` 0l.'..n.X
00000040: 63 B5 5C E7 BE A0 9C E8  B0 F1 F5 36 8B 99 68 48  c.\........6..hH

Server replies:

expecting msg_code=0x33 or: SSH_MSG_USERAUTH_FAILURE
my_recv_MAC() got:
00000000: 00 00 00 34 06 33 00 00  00 27 70 75 62 6C 69 63  ...4.3...'public
00000010: 6B 65 79 2C 70 61 73 73  77 6F 72 64 2C 6B 65 79  key,password,key
00000020: 62 6F 61 72 64 2D 69 6E  74 65 72 61 63 74 69 76  board-interactiv
00000030: 65 00 00 00 00 00 00 00  E1 21 1A 27 44 DC 09 BB  e........!.'D...
00000040: B7 7C 01 4F 6E 11 64 D4  F4 05 22 20 97 37 C1 C7  .|.On.d..." .7..
00000050: F7 70 51 B1 37 C7 50 B7                           .pQ.7.P.
allowed auth modes: publickey,password,keyboard-interactive

This is it. Several sysadmins may turn off "keyboard-interactive" and/or "password", leaving only "publickey", which is assumed more secure than password-based.

But so far, we use password-based auth.

Sending several strings, including password. Yes, in plain text, not encrypted. But in real, encrypted SSH, all packets are encrypted anyway.

sending ssh-connection
my_send() sending:
00000000: 00 00 00 3C 05 32 00 00  00 06 64 65 6E 6E 69 73  .....2....dennis
00000010: 00 00 00 0E 73 73 68 2D  63 6F 6E 6E 65 63 74 69  ....ssh-connecti
00000020: 6F 6E 00 00 00 08 70 61  73 73 77 6F 72 64 00 00  on....password..
00000030: 00 00 08 6D 65 67 61 70  61 73 73 AA AA AA AA AA  ...megapass.....
00000040: 4C 71 68 47 CC 8B 73 5D  76 EA 3F EB CA 9F BB 35  LqhG..s]v.?....5
00000050: 54 58 7E 4B D3 AA 5D 90  7D B4 FC E3 2A CE B8 25  TX~K..].}...*..%

Getting the SSH_MSG_USERAUTH_SUCCESS message:

my_recv_MAC() got:
00000000: 00 00 00 0C 0A 34 00 00  00 00 00 00 00 00 00 00  .....4..........
00000010: 82 0D 49 95 8E 51 85 3F  E0 DB 6A 6D A2 DD CE 0E  ..I..Q.?..jm....
00000020: 34 0E 0C BC 9F 98 06 0A  11 7B 44 DF 60 12 B2 81  4........{D.`...
Password correct.

Meaning, password correct.

Service messages SSH_MSG_CHANNEL_OPEN:

my_send() sending:
00000000: 00 00 00 24 0B 5A 00 00  00 07 73 65 73 73 69 6F  ...$.Z....sessio
00000010: 6E 00 00 00 00 00 00 20  00 00 80 00 00 AA AA AA  n...... ........
00000020: AA AA AA AA AA AA AA AA  91 4B 21 9B 4E 37 10 87  .........K!.N7..
00000030: A6 1F F4 78 29 33 1D A7  F0 80 5E 79 D8 2E D6 32  ...x)3....^y...2
00000040: F9 3F 1B 80 D8 70 1B 2C                           .?...p.,

Another service message from server, to be ignored yet:

expecting msg_code=0x50 or: SSH_MSG_GLOBAL_REQUEST
my_recv_MAC() got:
00000000: 00 00 02 64 08 50 00 00  00 17 68 6F 73 74 6B 65  ...d.P....hostke
00000010: 79 73 2D 30 30 40 6F 70  65 6E 73 73 68 2E 63 6F  ys-00@openssh.co
00000020: 6D 00 00 00 01 97 00 00  00 07 73 73 68 2D 72 73  m.........ssh-rs
00000030: 61 00 00 00 03 01 00 01  00 00 01 81 00 DD 85 12  a...............
00000040: E3 82 7B 61 34 3F E7 ED  2C 21 48 3F 27 C7 11 DC  ..{a4?..,!H?'...
00000050: D6 62 C2 74 A7 2C 78 42  66 1D B2 CD 96 09 E5 32  .b.t.,xBf......2
[...]
00000250: DB B0 B2 9E 71 36 AD 21  D7 F0 66 2E 96 A2 2F C6  ....q6.!..f.../.
00000260: 00 00 00 00 00 00 00 00  24 21 4D AD DD F7 52 74  ........$!M...Rt
00000270: 92 82 40 E7 66 EC 91 FE  74 EB D5 A0 B4 6F C0 40  ..@.f...t....o.@
00000280: 7A 1B FE 04 78 7A AE 6A                           z...xz.j

Service message, to be ignored:

expecting msg_code=0x5b or: SSH_MSG_CHANNEL_OPEN_CONFIRMATION
my_recv_MAC() got:
00000000: 00 00 00 1C 0A 5B 00 00  00 00 00 00 00 00 00 00  .....[..........
00000010: 00 00 00 00 80 00 00 00  00 00 00 00 00 00 00 00  ................
00000020: 74 ED 5D 75 5E 38 8B 00  7B A7 EB 7A 2C 0A A1 79  t.]u^8..{..z,..y
00000030: D7 6D CD 2C 3B 9D E1 80  D7 04 46 8E 93 8D 76 AE  .m.,;.....F...v.

Now something real. Sending Unix command, "uptime". Message SSH_MSG_CHANNEL_REQUEST:

send_exec uptime
my_send() sending:
00000000: 00 00 00 24 0B 62 00 00  00 00 00 00 00 04 65 78  ...$.b........ex
00000010: 65 63 01 00 00 00 06 75  70 74 69 6D 65 AA AA AA  ec.....uptime...
00000020: AA AA AA AA AA AA AA AA  E3 2B 01 4C 71 E6 67 20  .........+.Lq.g 
00000030: 6A 19 1A 3E 27 21 AB CB  C3 C6 5E 0C 4B 90 89 B4  j..>'!....^.K...
00000040: 10 E2 D5 2B 55 DF 2A 0D                           ...+U.*.

Several messages received. Two most important "exit-status" which passes exit code of command. And the string from stdout:

my_recv_MAC() got:
00000000: 00 00 00 14 0A 5D 00 00  00 00 00 20 00 00 00 00  .....]..... ....
00000010: 00 00 00 00 00 00 00 00  E6 53 76 3D 63 3B C3 70  .........Sv=c;.p
00000020: F9 09 1F FB AC EC D4 11  82 4C 28 38 DB 7B C6 22  .........L(8.{."
00000030: 1D 1B EF 66 AD FD 00 93                           ...f....
got message 0x5d or SSH_MSG_CHANNEL_WINDOW_ADJUST

my_recv_MAC() got:
00000000: 00 00 00 0C 06 63 00 00  00 00 00 00 00 00 00 00  .....c..........
00000010: 21 AD 4C 36 E2 92 1E CE  4B CF 42 40 73 25 E7 59  !.L6....K.B@s%.Y
00000020: 98 BE 97 59 31 D8 47 CF  22 37 C6 DA 76 53 63 CC  ...Y1.G."7..vSc.
got message 0x63 or SSH_MSG_CHANNEL_SUCCESS

my_recv_MAC() got:
00000000: 00 00 00 24 0A 62 00 00  00 00 00 00 00 0B 65 78  ...$.b........ex
00000010: 69 74 2D 73 74 61 74 75  73 00 00 00 00 00 00 00  it-status.......
00000020: 00 00 00 00 00 00 00 00  62 4A 30 F3 55 30 97 F9  ........bJ0.U0..
00000030: 13 FA 8E FF E7 B7 A0 3F  18 F5 6A F0 5F 74 B6 D1  .......?..j._t..
00000040: 2D 70 10 83 B1 1A 4E 08                           -p....N.
got message 0x62 or SSH_MSG_CHANNEL_REQUEST
exit-status 0

my_recv_MAC() got:
00000000: 00 00 00 54 07 5E 00 00  00 00 00 00 00 43 20 31  ...T.^.......C 1
00000010: 3A 35 36 50 4D 20 20 75  70 20 31 20 64 61 79 2C  :56PM  up 1 day,
00000020: 20 31 38 3A 31 32 2C 20  32 20 75 73 65 72 73 2C   18:12, 2 users,
00000030: 20 6C 6F 61 64 20 61 76  65 72 61 67 65 73 3A 20   load averages: 
00000040: 30 2E 30 34 2C 20 30 2E  30 39 2C 20 30 2E 30 34  0.04, 0.09, 0.04
00000050: 0A 00 00 00 00 00 00 00  F2 32 45 FF 9F 63 A4 1E  .........2E..c..
00000060: 2C 69 66 62 34 5C DC 53  48 14 F9 C9 D3 9F 89 8B  ,ifb4\.SH.......
00000070: 18 40 1B 1A 68 20 E4 54                           .@..h .T
got message 0x5e or SSH_MSG_CHANNEL_DATA
response:
==
 1:56PM  up 1 day, 18:12, 2 users, load averages: 0.04, 0.09, 0.04

==

my_recv_MAC() got:
00000000: 00 00 00 0C 06 60 00 00  00 00 00 00 00 00 00 00  .....`..........
00000010: FB D1 6B 8D E2 AB 73 CB  C4 F5 5B A5 03 72 FA E8  ..k...s...[..r..
00000020: 42 8C D5 FD D0 4B AB EB  E2 39 CF E2 52 53 58 84  B....K...9..RSX.
got message 0x60 or SSH_MSG_CHANNEL_EOF

my_recv_MAC() got:
00000000: 00 00 00 0C 06 61 00 00  00 00 00 00 00 00 00 00  .....a..........
00000010: B9 44 0D 22 42 1D 5E 97  13 0B 41 D9 8F 6A CD 0C  .D."B.^...A..j..
00000020: 99 CB 6D 32 F3 19 D7 41  E1 8A B7 46 07 63 7D 58  ..m2...A...F.c}X
got message 0x61 or SSH_MSG_CHANNEL_CLOSE

This is it. We got uptime's output.

Polite client would send EOF before disconnecting:

send_eof()
my_send() sending:
00000000: 00 00 00 0C 06 60 00 00  00 00 AA AA AA AA AA AA  .....`..........
00000010: CA FB 7A 09 41 0B FC 3C  21 99 11 15 46 5D 99 DD  ..z.A...!...F]..
00000020: F5 76 57 63 2C 59 BB 8B  5F 5C 33 0C 3C 86 E8 03  .vWc,Y.._\3.....

Now encryption

It was easy step to add encryption to all this.

When we send a packet, we just encrypt it with AES-CTR using IV (as counter) and key we already know.

But couple important notes.

1) Packet is encrypted with packet length. MAC is added to encrypted packet, but unencrypted. This is OK.

2) Here we use SDCTR mode (stateful-decryption CTR mode): 1, 2. This means that the AES-CTR counter for each direction is 'global' and initialized during key derivation step. This is considered more secure, than resetting counter at the start of each packet, as CTR mode would normally be implemented.

3) How do you decrypt a packet if you don't know its length? Decrypt first 16 bytes (AES block size) and read packet length. Then read the rest of packet.

This would work with your SSH server, unless it's ancient:

./toyssh_v1.py -vv -h 192.168.1.104 -u dennis -pass megapass -c uptime

Console/terminal is not implemented

So my toy SSH client only able to execute a command. This corresponds to ssh command like:

ssh user@host command

To extend it to support console/terminal, a 'pty-req' message is to be sent. Then each keypress is sent via each packet.

But I wanted to learn cryptography around SSH, not to recreate SSH client, so my client only can execute a command.

Good news

Even when using my toy SSH client, thanks to DH, an attacker literally can't decrypt all the traffic, even if it knows the username/password. This is important for backward/forward security. Attacker wouldn't have such opportunity even if he/she recorded all the traffic and stash it somewhere, for the moment when another SSH vulnerability will surface or when password will be leaked. Even if he will have physical access to all the files on client/server after the communication is finished.

All he/she can do is to try getting shared secret from RAM of client or server. Or predict PRNGs for both parties (this is why PRNG must be as good as possible).

This is the coolness of DH algo.

Bad news

But DH is vulnerable to MITM. As well, as my toy SSH client. An attacker can just pretend he is a real server you connect to.

But 'real' SSH is protected from this as well. This is to be covered in my next blog post(s). Stay tuned.

Backstage story

These commands I used to run patched OpenSSH client:

ssh -v -oKexAlgorithms=diffie-hellman-group-exchange-sha256 -oCiphers=none -oHostkeyAlgorithms=rsa-sha2-256 -oMACs=hmac-sha2-256 -p 669 USER@HOST uptime

ssh -v -oKexAlgorithms=diffie-hellman-group-exchange-sha256 -oCiphers=aes256-ctr -oHostkeyAlgorithms=ssh-rsa -oMACs=hmac-sha2-256 USER@HOST uptime

Then I replicated all these algorithms in my ToySSH client.

When hacking, each algo to be fixed. Also, thanks to Wireshark -- it can dissect many SSH packets.

Now a joke in spirit of the "Gödel, Escher, Bach" book. I connected via SSH to my OpenBSD VM that running patched OpenSSH 9.0, that dumps all the data exchanged, unencrypted. After my login, OpenSSH began dumping what it already dumped, i.e., hex dumps:

mac_compute() data:
0000: 00 00 0c ec 0a 5e 00 00 00 00 00 00 0c d8 1b 5b  .....^.........[
0010: 3f 32 35 6c 1b 5b 48 30 31 37 30 3a 20 32 30 20  ?25l.[H0170: 20
0020: 32 30 20 32 30 20 32 30 20 32 30 20 32 30 20 32  20 20 20 20 20 2
0030: 65 20 32 65 20 32 65 20 32 65 20 32 65 20 35 65  e 2e 2e 2e 2e 5e
0040: 20 32 65 20 35 62 20 34 62 20 32 65 20 20 20 20   2e 5b 4b 2e
0050: 20 20 20 20 2e 2e 2e 2e 2e 5e 2e 5b 4b 2e 1b 5b      .....^.[K..[
0060: 4b 0d 0a 30 31 38 30 3a 20 31 62 20 35 62 20 34  K..0180: 1b 5b 4
0070: 62 20 30 64 20 30 61 20 33 30 20 33 33 20 36 32  b 0d 0a 30 33 62
0080: 20 33 30 20 33 61 20 32 30 20 33 30 20 36 31 20   30 3a 20 30 61
0090: 32 30 20 33 37 20 33 33 20 20 2e 5b 4b 2e 2e 30  20 37 33  .[K..0
00a0: 33 62 30 3a 20 30 61 20 37 33 1b 5b 4b 0d 0a 30  3b0: 0a 73.[K..0
00b0: 31 39 30 3a 20 32 30 20 33 37 20 33 33 20 32 30  190: 20 37 33 20
00c0: 20 33 36 20 33 38 20 32 30 20 33 36 20 33 32 20   36 38 20 36 32
00d0: 32 30 20 33 37 20 33 35 20 32 30 20 33 36 20 33  20 37 35 20 36 3
00e0: 36 20 32 30 20 20 20 37 33 20 36 38 20 36 32 20  6 20   73 68 62
00f0: 37 35 20 36 36 20 1b 5b 4b 0d 0a 30 31 61 30 3a  75 66 .[K..01a0:
0100: 20 33 35 20 36 36 20 32 30 20 33 37 20 33 30 20   35 66 20 37 30
...

The following effect may be achieved if you connect via telnet (if enabled) to a host and run:

tcpdump -X port 23

A word about MAC

Packet number is also used during MAC calculation.

So, MAC is calculated as MAC(key=derived key, body=packet/sequence number (u32) || payload) ToySSH client keeps tracking on packet numbers.

[See RFC 4253 6.4]

That Linux penguin

A word about AES-CTR. As I wrote before, SSH use it in SDCTR mode (stateful-decryption CTR mode). In other words, counter is global. Why? Otherwise two similar packets would produce the same ciphertexts. This may be undesirable. Attacker will know that clients sends the same commands or server responds with similar replies. Some cryptographers compare this with the well-known joke about ECB and Linux penguin.

Both CTR and GCM modes protects from replay attack: each packet encrypted different, even if its contents is the same. The attacker wouldn't be able to send pre-recorded packet. Also, the attacker sitting in the middle (MITM) couldn't skip packets, which is also may be important.

The same story about incorporating packet number into HMAC when calculating MAC for each packet. MAC must be different for each packet, even if packets are the same.

All the files

Here.

Next part.

(the post first published at 20220828.)


List of my other blog posts.

Subscribe to my news feed

Yes, I know about these lousy Disqus ads. Please use adblocker. I would consider to subscribe to 'pro' version of Disqus if the signal/noise ratio in comments would be good enough.