checker-ssh/checker/kexinit.go

378 lines
11 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"bufio"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"strings"
)
// The SSH transport protocol is fully specified in RFC 4253. For the
// security audit we only need the pre-authentication handshake:
//
// 1. exchange of protocol version strings (ASCII banners, CRLF-terminated)
// 2. exchange of SSH_MSG_KEXINIT packets, which carry the full algorithm
// preference lists in one go.
//
// We deliberately avoid going beyond KEXINIT: a shallow probe is cheap,
// works against every RFC-compliant SSH server without depending on any
// particular algorithm family being supported, and sidesteps the risk of
// running actual KEX math with untrusted peers.
const (
// sshClientBanner is the version string we advertise. ssh-audit and
// nmap publish a recognisable banner so operators can tell an audit
// probe apart from a real client in their logs.
sshClientBanner = "SSH-2.0-happyDomain-checker_1.0"
msgKexInit = 20
// maxPacketSize caps the largest packet we will read. RFC 4253 allows
// up to 32768 bytes of payload, so 65k is a safe ceiling that also
// protects us from a rogue server trying to exhaust memory.
maxPacketSize = 65535
// maxBannerSize limits how much we read before we give up on the
// peer's version string. RFC 4253 mandates at most 255 bytes.
maxBannerSize = 4096
)
// kexInitPayload is the parsed contents of a KEXINIT packet. The field
// names match RFC 4253 §7.1 verbatim to make audits easy.
type kexInitPayload struct {
Cookie [16]byte
KexAlgorithms []string
ServerHostKeyAlgorithms []string
EncryptionAlgorithmsClientToSvr []string
EncryptionAlgorithmsSvrToClient []string
MACAlgorithmsClientToSvr []string
MACAlgorithmsSvrToClient []string
CompressionAlgorithmsClientToSv []string
CompressionAlgorithmsSvrToClt []string
LanguagesClientToSvr []string
LanguagesSvrToClient []string
FirstKexPacketFollows bool
}
// readBanner reads the peer's SSH identification string. Servers may
// send several CRLF-terminated lines of free-text before the actual
// "SSH-2.0-..." line (RFC 4253 §4.2); we skip those and return the first
// line that looks like a version exchange.
func readBanner(r *bufio.Reader) (string, error) {
for i := 0; i < 16; i++ {
line, err := readLine(r, maxBannerSize)
if err != nil {
return "", err
}
if strings.HasPrefix(line, "SSH-") {
return line, nil
}
}
return "", fmt.Errorf("no SSH version string received")
}
// readLine reads a single CRLF-terminated line (or LF-terminated, as
// some servers omit the CR) and returns it without the terminator.
func readLine(r *bufio.Reader, max int) (string, error) {
var buf []byte
for {
b, err := r.ReadByte()
if err != nil {
return "", err
}
if b == '\n' {
if n := len(buf); n > 0 && buf[n-1] == '\r' {
buf = buf[:n-1]
}
return string(buf), nil
}
buf = append(buf, b)
if len(buf) > max {
return "", fmt.Errorf("line too long")
}
}
}
// writeBanner sends our client identification string.
func writeBanner(w io.Writer) error {
_, err := io.WriteString(w, sshClientBanner+"\r\n")
return err
}
// readPacket reads a single SSH binary packet (RFC 4253 §6) from r. The
// handshake is still in cleartext at this point, so we don't worry
// about MAC or cipher state: packet_length + padding_length + payload +
// random padding, no MAC.
func readPacket(r io.Reader) (payload []byte, err error) {
var lenBuf [4]byte
if _, err = io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
packetLen := binary.BigEndian.Uint32(lenBuf[:])
if packetLen < 5 || packetLen > maxPacketSize {
return nil, fmt.Errorf("invalid packet length %d", packetLen)
}
body := make([]byte, packetLen)
if _, err = io.ReadFull(r, body); err != nil {
return nil, err
}
padLen := int(body[0])
if padLen >= len(body) {
return nil, fmt.Errorf("invalid padding length %d vs packet %d", padLen, len(body))
}
return body[1 : len(body)-padLen], nil
}
// writePacket frames payload into an RFC 4253 binary packet and sends it.
func writePacket(w io.Writer, payload []byte) error {
// packet_length covers padding_length + payload + random_padding,
// but not itself. The total (4 + packet_length) must be a multiple
// of 8 (the block size used in unencrypted mode), and padding must
// be at least 4 bytes.
const blockSize = 8
padLen := blockSize - ((5 + len(payload)) % blockSize)
if padLen < 4 {
padLen += blockSize
}
packetLen := 1 + len(payload) + padLen
buf := make([]byte, 4+packetLen)
binary.BigEndian.PutUint32(buf[:4], uint32(packetLen))
buf[4] = byte(padLen)
copy(buf[5:], payload)
if _, err := rand.Read(buf[5+len(payload):]); err != nil {
return fmt.Errorf("padding rand: %w", err)
}
_, err := w.Write(buf)
return err
}
// buildKexInit crafts a client KEXINIT payload that advertises every
// algorithm family the Go SSH stack knows, plus the typical OpenSSH
// names we aren't implementing. We are never going to actually perform
// key exchange over this connection: the server only needs to accept
// our KEXINIT as well-formed and echo its own.
func buildKexInit() []byte {
var cookie [16]byte
_ = mustRand(cookie[:])
kex := strings.Join([]string{
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group16-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"sntrup761x25519-sha512@openssh.com",
"mlkem768x25519-sha256",
}, ",")
hostKey := strings.Join([]string{
"ssh-ed25519",
"ssh-ed25519-cert-v01@openssh.com",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-dss",
}, ",")
ciphers := strings.Join([]string{
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes128-cbc",
"3des-cbc",
}, ",")
macs := strings.Join([]string{
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"umac-128-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-sha1-96",
"hmac-md5",
}, ",")
comp := "none,zlib@openssh.com,zlib"
w := newPayloadWriter()
w.writeByte(msgKexInit)
w.writeBytes(cookie[:])
w.writeString(kex)
w.writeString(hostKey)
w.writeString(ciphers)
w.writeString(ciphers)
w.writeString(macs)
w.writeString(macs)
w.writeString(comp)
w.writeString(comp)
w.writeString("") // languages c2s
w.writeString("") // languages s2c
w.writeByte(0) // first_kex_packet_follows
w.writeUint32(0) // reserved
return w.bytes()
}
func mustRand(b []byte) error {
_, err := rand.Read(b)
return err
}
// parseKexInit parses a server KEXINIT payload. Validation is minimal:
// we do not reject over-long algorithm lists, we just trim them at the
// RFC ceiling so a hostile server can't make us allocate unbounded
// amounts of memory.
func parseKexInit(payload []byte) (*kexInitPayload, error) {
if len(payload) < 1 || payload[0] != msgKexInit {
return nil, fmt.Errorf("not a KEXINIT packet (first byte = %d)", func() byte {
if len(payload) == 0 {
return 0
}
return payload[0]
}())
}
r := newPayloadReader(payload[1:])
out := &kexInitPayload{}
if err := r.readBytes(out.Cookie[:]); err != nil {
return nil, err
}
var err error
if out.KexAlgorithms, err = r.readNameList(); err != nil {
return nil, err
}
if out.ServerHostKeyAlgorithms, err = r.readNameList(); err != nil {
return nil, err
}
if out.EncryptionAlgorithmsClientToSvr, err = r.readNameList(); err != nil {
return nil, err
}
if out.EncryptionAlgorithmsSvrToClient, err = r.readNameList(); err != nil {
return nil, err
}
if out.MACAlgorithmsClientToSvr, err = r.readNameList(); err != nil {
return nil, err
}
if out.MACAlgorithmsSvrToClient, err = r.readNameList(); err != nil {
return nil, err
}
if out.CompressionAlgorithmsClientToSv, err = r.readNameList(); err != nil {
return nil, err
}
if out.CompressionAlgorithmsSvrToClt, err = r.readNameList(); err != nil {
return nil, err
}
if out.LanguagesClientToSvr, err = r.readNameList(); err != nil {
return nil, err
}
if out.LanguagesSvrToClient, err = r.readNameList(); err != nil {
return nil, err
}
b, err := r.readByte()
if err != nil {
return nil, err
}
out.FirstKexPacketFollows = b != 0
return out, nil
}
// payloadWriter/Reader are tiny helpers for SSH wire encoding. We only
// ever use uint32-prefixed strings and comma-separated name-lists.
type payloadWriter struct{ buf []byte }
func newPayloadWriter() *payloadWriter { return &payloadWriter{} }
func (w *payloadWriter) bytes() []byte { return w.buf }
func (w *payloadWriter) writeByte(b byte) { w.buf = append(w.buf, b) }
func (w *payloadWriter) writeBytes(b []byte) { w.buf = append(w.buf, b...) }
func (w *payloadWriter) writeUint32(v uint32) {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
w.buf = append(w.buf, b[:]...)
}
func (w *payloadWriter) writeString(s string) {
w.writeUint32(uint32(len(s)))
w.buf = append(w.buf, s...)
}
type payloadReader struct {
buf []byte
pos int
}
func newPayloadReader(b []byte) *payloadReader { return &payloadReader{buf: b} }
func (r *payloadReader) readByte() (byte, error) {
if r.pos >= len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
b := r.buf[r.pos]
r.pos++
return b, nil
}
func (r *payloadReader) readBytes(dst []byte) error {
if r.pos+len(dst) > len(r.buf) {
return io.ErrUnexpectedEOF
}
copy(dst, r.buf[r.pos:r.pos+len(dst)])
r.pos += len(dst)
return nil
}
func (r *payloadReader) readUint32() (uint32, error) {
if r.pos+4 > len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
v := binary.BigEndian.Uint32(r.buf[r.pos : r.pos+4])
r.pos += 4
return v, nil
}
func (r *payloadReader) readNameList() ([]string, error) {
n, err := r.readUint32()
if err != nil {
return nil, err
}
if int(n) > len(r.buf)-r.pos {
return nil, io.ErrUnexpectedEOF
}
s := string(r.buf[r.pos : r.pos+int(n)])
r.pos += int(n)
if s == "" {
return nil, nil
}
return strings.Split(s, ","), nil
}