计划

《终极斗罗》和 “蜜罐”

之前看过有关 “蜜罐” 的概念,觉得这个玩意儿很有意思。

我觉得有意思的点在于,它这个概念有效降低了原本攻防的不对称性。攻击方就像恐怖分子一样,不管怎样失败,只要成功一次,那便是防守方无法承受的。
但如果存在一个东西,可以掣肘攻击方呢?比如了解攻击方的行为,包括使用的技术、所有的资源等等。

这两年来一直在看唐家三少的 《终极斗罗》(orz,我在艺术审美能力上的确还是个小学生),蓝轩宇(男主名)提出了 “将战场主动放在我们希望的地方” 这个概念,
将原本打算直攻天马星的深红之母引到了龙马星系的另一颗星球——天和星上。

在天和星上,有足够吸引深红之母(靠吞噬能量为生的位面)的生命能量、有龙马星系最强的军事科技、有龙马星系最富有的种族搭建的防御工事。

我看了之后寻思,这不就是个 “蜜罐” 吗?

  • 有能够吸引攻击方的资源;
  • 有强大的防御能力,是精心准备的陷阱;
  • 与真正的资源存有距离;
  • 攻击方需要一段时间才能识破陷阱;
  • 攻击方在识破过程中会留下特征…

唐家三少,你知道 “蜜罐” 的,对吧?(

未来的我 要和 “蜜罐”

这学期有一门课,叫 系统安全与应用实验,第五周开始,我们从 扫描、入侵检测、个人防火墙 三个种类中选择一个来具体实现。

毫无疑问,想要水一个东西出来是很简单的。但我打算真正做点东西。一开始我也考虑过很多:

要不,我就利用 swoole 写个端口扫描吧
写起来估计方便,GitHub 上还没有类似实现

要不,我写个分布式物理机状态监测?
但是感觉对我来说好麻烦啊,我应该没那个水平,对内核 API 的了解也不够
估计选了就是不停不过脑子地查东西罢了

要不,我实现个小的 ufw?
我觉得也不好…… 我连 iptables 那几条链都没整明白过
……

最后我打算写一个 SSH-HoneyPot,并通过这个过程学习下 SSH 协议,弥补我上个学期在 SSH 这部分翘课带来的遗憾。

这个帖子就用来放 SSH 相关的细节备忘吧。

相关概念

这波第几层

毫无疑问,它是应用层协议,传输层使用 TCP,通常使用 22 端口,所以无论是拨号还是监听,都要指定 22:

1
2
3
4
5
// some code on server
lsn, err := net.Listen("tcp", ":22")

// some code on client
conn, err := net.Dial("tcp", ":22")

Message Code / Message Number

(注:在 WireShark 里面称作 Message CodeRFC 里面好像统称 Message Number,下面我称之为消息数)

根据 RFC-4251 SSH Proto Archi,消息数可以分为五类:

  1. 传输协议(SSH Transport Layer Proto) 中使用,在 RFC-4253 SSH Transport Layer Proto 中也有记载:
    1. 1-19 是通用(generic);
    2. 20-29 与算法相关,这里的算法相关不是指的 “加密 / 摘要算法”,而是 初始化(20)、新钥提示(21) 等;
    3. 30-49 与密钥交换(kex) 有关,具体内容 RFC 中没有定义。比如我知道的,30 和 31 就与 DH 交换有关。
      • 注:上面的说法有误,但我并不想 “毁尸灭迹”,因为这是我不谨慎、没有做调查就妄下断论的结果,不应该被删掉。
      • 我在读 go-crypto/ssh 相关实现的时候,发现了一点奇怪的地方——消息数貌似并没有被特别重视(并不存在一种方法对应一种或几种特定消息数的情况)。我后面查了 IANA相关条目,发现其中 30-49 是 Reserved。此后我读了 RFC-4419 SSH-DH-Group-Ex 以及 RFC-5656 SSH-ECC-Alg-IntMessage Numbers 部分,发现在消息数上有重叠的地方,而且都指出:“They are in a name space private to this document and not assigned by IANA.” 以及 “The message numbers may be redefined by any key exchange method without requiring an IANA registration process.”
      • 综上,消息数和使用的交换方法是无特别大关联的。而 Wireshark 或许是通过前面的 Key Init(20) 过程中双方给出的可接受交换方法序列来确定交换方法的吧?
  2. 用户认证协议(User Auth Proto) 中使用 50-127:
    1. 50-59 是通用,最经典的是 SSH_MSG_USERAUTH_REQUEST(50),客户端只能使用这个消息数;
    2. 60-79 是 reserved(根据 RFC-4252 SSH Auth Proto),为特定的交换方法设置,并且仅有服务器使用;
  3. 连接协议(Conn Proto) 中使用 80-127:
    1. 80-89 是通用的,在 RFC-4254 SSH Conn Proto 中有明确的定义;
    2. 90-127 也有部分定义(90-100),与 channel 有关,但是我不知道这是什么……
  4. Reserved / Local Ext
    1. 128-191 Reserved
    2. 192-255 Local Extensions

Host Key in KEX

主机密钥交换

上面说到,KeyInit(20) 中交换了各种各样的可使用加密、摘要、密钥交换算法。在 Client 收到消息数为 KeyInit(20) 的消息之后,会开始交换密钥。

那么在我这里,是使用的 ECDH(30),给出了密钥长度(Key Length) 和密钥本体。对应的,Server 要给出对应的响应,也就是上图。

ECDH-Reply(31),我不打算注重具体细节,紧跟着消息数的就是 host key,里面记载着一系列的信息,需要注意的是,这些并不都记录到了 Client 的数据库中:

Host Key Length 并没有被包括在 Client 的 Host Key 中,如上面的记事本所示,是从 Type 开始的。

上图左边是对记事本中的 base64 解码后的十六进制表达,可与右侧对照。

在这个流程结束后,客户端并不会直接存储,而是通过询问是否愿意继续连接:

1
2
3
4
5
The authenticity of host 'aaa.bbb.ccc.ddd (aaa.bbb.ccc.ddd)' can't be established.

ECDSA key fingerprint is SHA256:nADT+y9rU+7rmcWD60pZ6XA/FobSz7t/WS6Nz/doF70.

Are you sure you want to continue connecting (yes/no)?

只有在这个时候输入 yes,才会有:

1
Warning: Permanently added '47.101.154.133' (ECDSA) to the list of known hosts.

也就是说 Client 中存储的、用来标识 HostKey 的格式:

1
aaa.bbb.ccc.ddd(ip) xxxxx-xxx(method) yyyyy...(host key, from Key Type to end)

是在这个时候才被加入到客户端 known_hosts (我 Windows 是这个文件,不知道其他操作系统是什么) 中的。

写入这个主机密钥 猜测同样需要后面的交互,但是由于我没有解密 SSH 的手段(气死了,Windows 没有现成可用的代码,我也还没看到 SSH 是如何加密流量的)

最后说明一下:

仅仅对于 ECDH 这个算法,发出 ECDH(30) 的时候,后面附有客户端公钥 ECDH Public Key Client,而 ECDH-Reply(31) 的时候,在 Host key 后会附有 ECDH Public Key Server

e.g. KEX in go-crypto-ssh

具体代码见:go-crypto/ssh/kex.go

这份代码最关键的就是 ClientServer 两个 dhGroup / ecdh / curve25519sha256 / dhGEXSHA 的方法。

我在这里就以我 Win10 首选的 ecdh 为例。

首先注意到两个方法的第一个参数的类型:packetConn 这个类型是实现了 readPacket/writePacket 两个方法的。并且,这个过程是同步的,我们可以根据这两种关于 packet 的读写方法来分割 Client / Server 不同的状态。

1. 从 Client 开始

Client 首先根据 ecdh 的两个公共参数 X, Y 生成 C 公钥:

1
2
3
kexInit := kexECDHInitMsg{
ClientPubKey: elliptic.Marshal(kex.curve, ephKey.PublicKey.X, ephKey.PublicKey.Y),
}

生成之后直接写给 Server

1
2
3
4
serialized := Marshal(&kexInit)
if err := c.writePacket(serialized); err != nil {
return nil, err
}
2. Server 接到之后
1
2
3
4
packet, err := c.readPacket()
if err != nil {
return nil, err
}

会进行一系列的处理,总之会将下面这个结构体写给 Client

1
2
3
4
5
6
7
8
9
10
reply := kexECDHReplyMsg{
EphemeralPubKey: serializedEphKey,
HostKey: hostKeyBytes,
Signature: sig,
}

serialized := Marshal(&reply)
if err := c.writePacket(serialized); err != nil {
return nil, err
}

而其中的 serializedEphKey / hostKeyBytes / sig 是通过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

serializedEphKey := elliptic.Marshal(kex.curve, ephKey.PublicKey.X, ephKey.PublicKey.Y)

// some code
// priv means private key
// priv implements Signer interface (usually type of priv might be dsaPrivateKey, because ecdsa serves as Host Key Type) when it comes to ecdh.
hostKeyBytes := priv.PublicKey().Marshal()

// some code

h := ecHash(kex.curve).New()
magics.write(h)
writeString(h, hostKeyBytes)
writeString(h, kexECDHInit.ClientPubKey)
writeString(h, serializedEphKey)

K := make([]byte, intLength(secret))
marshalInt(K, secret)
h.Write(K)

H := h.Sum(nil)
sig, err := signAndMarshal(priv, rand, H)

来计算得到的。
但是如果代码这么跑的话,和我实际抓到的包就不吻合了,所以我也很纠结,不明白到底怎么回事。

如果我要实现的话,我肯定是只实现最简单的 DH 交换。

3. Client 接到了 Server 的响应
1
2
3
4
packet, err := c.readPacket()
if err != nil {
return nil, err
}

会根据 Server 传过来的 packet 计算一系列参数。多的就不说了。

默认的一些 Alg

下述的这些算法在 go-crypto/ssh 中得到支持,并且会在 SetDefaults (该方法在 Server.goClient.go 中调用) 中设定默认值。Config 有关通信中使用的算法们2:

1
2
3
4
RekeyThreshold      // 类似生命
KeyExchanges // 密钥交换 “算法” 们
Ciphers // 加密 “算法” 们
MACs // 摘要 “算法” 们
1. 密钥交换算法 (common.go-L63)

我的服务器上就是这样的:

1
2
3
4
curve25519-sha256@libssh.org,
ecdh-sha2-nistp256,
ecdh-sha2-nistp384,
ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha1
2. 主机密钥类型 (common.go-L71)

common.go 里面没给推荐,下面是我服务器的:

1
2
3
4
5
ssh-rsa,
rsa-sha2-512,
rsa-sha2-256,
ecdsa-sha2-nistp256,
ssh-ed25519
3. 加密算法 (common.go-L38)
1
2
3
aes128-gcm@openssh.com,
chacha20-poly1305@openssh.com
aes128-ctr,aes192-ctr,aes256-ctr
4. 摘要算法 (common.go-L84)
1
2
3
hmac-sha2-256-etm@openssh.com,
hmac-sha2-256,
hmac-sha1

明显真正的 OpenSSH 支持更多算法……

5. 压缩算法 (common.go-L88)
1
none

不进行任何压缩。

SSH-Type: 20

确实层套得太多了。

client.go-L71 中暴露了 NewClientConn 这个函数,作为真正 crypto/ssh 的使用者,是会真正应用这个函数的。这个函数的作用正如它的注释所描述:

1
2
3
NewClientConn establishes an authenticated SSH connection using c as the underlying transport.

The Request and NewChannel channels must be serviced or the connection will hang.

这个函数中会调用 clientHandshake 这个方法,该方法是 connection 接口中的方法:

1
2
// clientHandshake performs the client side key exchange. See RFC 4253 Section 7.
func (c *connection) clientHandshake(dialAddress string, config *ClientConfig) error {

注:Section 7 就是讲的 Key Exchange(Algorithm Negotiation, Output from Key Exchange, Taking Keys Into Use)

其中会调用 newClientTransport 来创建 connection 中位于 SSH 层面的客户端传输 Transport 的抽象。(我觉得是 Transport Proto 中的传输,而非传输层的传输)

1
2
3
4
5
6
7
c.transport = newClientTransport(
newTransport(c.sshConn.conn, config.Read, true),
c.clientVersion,
c.serverVersion,
config, dialAddress,
c.sshConn.RemoteAddr()
)

接着在 handshake.go-L123 中,newClientTransport 调用了 t.kexLoop,然后在 handshake.go-L278 调用了 t.sendKexInit,这个方法里面,是真正产生 Cookie 的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
msg := &kexInitMsg{
KexAlgos: t.config.KeyExchanges,
CiphersClientServer: t.config.Ciphers,
CiphersServerClient: t.config.Ciphers,
MACsClientServer: t.config.MACs,
MACsServerClient: t.config.MACs,
CompressionClientServer: supportedCompressions,
CompressionServerClient: supportedCompressions,
}
io.ReadFull(rand.Reader, msg.Cookie[:])

if len(t.hostKeys) > 0 {
for _, k := range t.hostKeys {
msg.ServerHostKeyAlgos = append(
msg.ServerHostKeyAlgos, k.PublicKey().Type())
}
} else {
msg.ServerHostKeyAlgos = t.hostKeyAlgorithms
}

其中使用的 kexInitMsgmessage.go 中定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type kexInitMsg struct {
Cookie [16]byte `sshtype:"20"`
KexAlgos []string
ServerHostKeyAlgos []string
CiphersClientServer []string
CiphersServerClient []string
MACsClientServer []string
MACsServerClient []string
CompressionClientServer []string
CompressionServerClient []string
LanguagesClientServer []string
LanguagesServerClient []string
FirstKexFollows bool
Reserved uint32
}

注:真正向 conn 中写包的行为被包装在了 handshake.go 中的 pushPacket 里。

Auth

毫无疑问是很重要的。我觉得 client_auth.go 里面这个地方设计得挺巧妙的,当然,应该是我见识短了。拿 passwordCallback 举例子:

  • passwordCallback 是返回 (password string, err error)函数 类型;
    • 这个函数类型还有两个方法:
      1. auth 这个方法是用来验证身份的。入参类型是 (session []byte, user string, c packetConn, rand io.Reader)
      2. method 基本上是个空方法,返回 RFC-4252 中的 method name
    • 需要注意的是,上面两个方法定义在了 AuthMethod 接口中,也就是说实现了上面两个方法的就满足这个接口,然后 这个接口就是如 RFC-4252 般验证身份的途径
  • 在同文件中,PasswordPasswordCallback 这两个函数的返回类型与之相关,都是 AuthMethod
    • PasswordCallback 传入类型就是个实质上和 passwordCallback 没差别的类型,然后通过强转变成实现了 AuthMethod 接口的函数。
    • Password 挺精髓的,传入的是 口令(secret string),但是构造了一个返回自身的函数,封成满足 AuthMethodpasswordCallback
1
2
3
4
// Password returns an AuthMethod using the given password.
func Password(secret string) AuthMethod {
return passwordCallback(func() (string, error) { return secret, nil })
}

比如可以这么写:

1
2
3
4
5
6
// session, c and rand have been defined already
user := "skyleaworlder"
secret := "fuck ssh!"
if ok, _, _ := Password(secret).auth(session, user, c, rand); ok {
fmt.Println("wuhu! depart!")
}

auth 中,就可以通过:

1
2
// assume that receiver: cb passwordCallback
pw, err := cb()

获得 password/secret 了。

按道理来说,由于 auth 这个函数名太抽象了,每一种 AuthMethod 都要对应实现一次,都需要特殊照顾。而对于 PasswordAuthentication 来说,如何将 Password(secret) 传给已经定义好入参类型的 auth 就成了个问题

我之前是从来没有遇到这种解决方案的…… 可能和我之前接触的语言有关,我只对 C, Python 有一些了解,但这两门语言貌似没有直接给函数加方法的,要来也是函数的返回值为一个对象。但那可是对象,不是什么闭包函数,所以自然取不到那个函数里面的东西。

如果是 Python 的话,我想象不出是什么样子。

现在看来,这或许是这种问题的很常见。