-
Notifications
You must be signed in to change notification settings - Fork 8.9k
Description
先说结论:mKCP的加密方案(其实应该称作混淆)引入的特征,可导致mKCP流量,以极低的开销被精准识别
目前没有找到除了重新设计mkcp加密以外的缓解措施。我个人的建议是,用标准的加密系统替代当前的混淆方式。
PoC比较简单,下面是核心部分,完整代码放在最后
func IsMKCPPacket(packet []byte) bool {
auth := NewSimpleAuthenticator()
_, err := auth.Open(packet, nil, nil, nil)
if err != nil {
return false
}
return true
}
其中的NewSimpleAuthenticator是core/transport/kcp中的函数
下面是分析和PoC
mKCP被识别不是一天两天的事情了,下面这个只是一种可能的思路。
mKCP的加密实现了一个AEAD接口,但是nonce和tag都没有被使用,仅仅使用了一个不需要密钥的xor进行混淆(汇编实现),然后使用附在末尾的校验值来判定包的完整性。
v2ray-core/transport/internet/kcp/crypt.go
Lines 14 to 78 in 1eb0098
type SimpleAuthenticator struct{} | |
// NewSimpleAuthenticator creates a new SimpleAuthenticator | |
func NewSimpleAuthenticator() cipher.AEAD { | |
return &SimpleAuthenticator{} | |
} | |
// NonceSize implements cipher.AEAD.NonceSize(). | |
func (*SimpleAuthenticator) NonceSize() int { | |
return 0 | |
} | |
// Overhead implements cipher.AEAD.NonceSize(). | |
func (*SimpleAuthenticator) Overhead() int { | |
return 6 | |
} | |
// Seal implements cipher.AEAD.Seal(). | |
func (a *SimpleAuthenticator) Seal(dst, nonce, plain, extra []byte) []byte { | |
dst = append(dst, 0, 0, 0, 0, 0, 0) // 4 bytes for hash, and then 2 bytes for length | |
binary.BigEndian.PutUint16(dst[4:], uint16(len(plain))) | |
dst = append(dst, plain...) | |
fnvHash := fnv.New32a() | |
common.Must2(fnvHash.Write(dst[4:])) | |
fnvHash.Sum(dst[:0]) | |
dstLen := len(dst) | |
xtra := 4 - dstLen%4 | |
if xtra != 4 { | |
dst = append(dst, make([]byte, xtra)...) | |
} | |
xorfwd(dst) | |
if xtra != 4 { | |
dst = dst[:dstLen] | |
} | |
return dst | |
} | |
// Open implements cipher.AEAD.Open(). | |
func (a *SimpleAuthenticator) Open(dst, nonce, cipherText, extra []byte) ([]byte, error) { | |
dst = append(dst, cipherText...) | |
dstLen := len(dst) | |
xtra := 4 - dstLen%4 | |
if xtra != 4 { | |
dst = append(dst, make([]byte, xtra)...) | |
} | |
xorbkd(dst) | |
if xtra != 4 { | |
dst = dst[:dstLen] | |
} | |
fnvHash := fnv.New32a() | |
common.Must2(fnvHash.Write(dst[4:])) | |
if binary.BigEndian.Uint32(dst[:4]) != fnvHash.Sum32() { | |
return nil, newError("invalid auth") | |
} | |
length := binary.BigEndian.Uint16(dst[4:6]) | |
if len(dst)-6 != int(length) { | |
return nil, newError("invalid auth") | |
} | |
return dst[6:], nil | |
} |
虽然注释写的是legacy,但是在mkcp的设置中的确被使用了
v2ray-core/transport/internet/kcp/config.go
Lines 62 to 65 in 1eb0098
// GetSecurity returns the security settings. | |
func (*Config) GetSecurity() (cipher.AEAD, error) { | |
return NewSimpleAuthenticator(), nil | |
} |
混淆和校验本身没有问题,问题在于这种混淆太过特殊,而且存在一个校验和判断内容是否正确,并且加解密开销很低。
所以检测的思路就是:对一个未知的UDP包,简单地用这个算法解密,然后判断校验和是否正确,如果正确,为mKCP。否则不是mKCP。
我猜测设计者应该是想使用简单的xor替换常规加密方式,降低开销,提升计算速度。但是,加密的开销越低,检测的开销就越低。因此使得大规模部署和检测成为可能。考虑到mKCP暴力发包的特性,防火墙只需要抽取其中几个包进行抽样测试即可准确判定。在我本地,mtu为1350的情况下(默认的最大的mKCP包),可以每秒检测超过20k个包。
建议使用标准的密码系统,或者直接明文传输,带来的特征都会比使用这种加密算法少。
完整PoC代码
package main
import (
"flag"
"log"
"net"
"v2ray.com/core/transport/internet/kcp"
)
var listenAddr = flag.String("from", "127.0.0.1:4444", "listen address")
var verbose = flag.Bool("verbose", false, "enable detailed output")
func init() {
flag.Parse()
}
func isMKCPPacket(packet []byte) bool {
auth := kcp.NewSimpleAuthenticator()
_, err := auth.Open(packet, nil, nil, nil)
if err != nil {
return false
}
return true
}
func main() {
udpAddr, _ := net.ResolveUDPAddr("udp4", *listenAddr)
l, _ := net.ListenUDP("udp", udpAddr)
log.Printf("mKCP PoC opened at %s...", *listenAddr)
for {
buf := make([]byte, 2048)
nRecv, _, e := l.ReadFromUDP(buf)
if e == nil {
result := isMKCPPacket(buf[:nRecv])
if *verbose {
log.Printf("detected=%v, size=%d, data=%x", result, nRecv, buf[:nRecv])
} else {
log.Printf("detected=%v, size=%d", result, nRecv)
}
}
}
}
这个程序监听本地4444端口,开启一个v2ray客户端向4444端口发送mkcp数据包即可识别。发送其他udp包,如dns,quic等不会误报。