自动化测试 -- 通过代理控制协议包收发

一、使用场景

1、通过协议达到 UI 自动化的效果。

进入部分场景的本质其实是客户端发送协议后,接收并响应服务端的包,所以,对于这一部分前置的动作可以通过主动发送协议完成。

例如,对于对局的测试,创房间->匹配->确认对局->选择英雄是前置条件,则都可以通过发送协议来完成。

上图中,主动发送了进入房间的包和开启匹配的包,客户端虽然没有发送包,但会响应服务端返回的包。

2、拦截部分影响正常测试流程的包

因为客户端能响应服务端包的特性,也带来其他的一些问题,比如对于新手账号,进入房间时会触发新手引导,导致部分区域被遮盖,影响测试流程。

3、协议断言

在一些场景下,协议测试也可以作为测试的一部分。


二、原理

1、socks5 代理

利用代理进行 MITM 攻击是此中间件的原理。而游戏大多是 tcp/websocket 等长连接通信,所以需要使用 socks5 代理,普通的 http 代理只能转发 http 数据。

代理在接到客户端的连接服务端请求后,会和客户端保持通信的同时,和服务端也建立连接,再作为中间人的身份传话。

client <-> proxy <-> server

可以理解为该中间件可以控制代理传假话或者不传话。

2、消息队列

为了控制代理,需要引入消息队列来处理代理接收到的数据。对应不同功能,可以简单表述为听和说两种。

对应的是将收到的客户端数据和服务端数据放入消息队列中,让消费者处理。

client/server -> proxy -> MQ -> 消费者进程

此过程还有一个细节,对于收到的数据是否要再转给对端,这是拦截功能的要点。

说对应的就是向两端发送数据,生产者进程将数据放入消息队列,代理收到后发送给 client/server。

生产者进程 -> MQ -> proxy -> client/server


三、实现

1、mitmproxy 实现 socks5 代理

为了能承载更多设备,代理部分使用 go 语言开发的go-mitmproxy,但项目目前还不支持 socks5 代理,所以我在其基础上增加了 socks5 模式,但处理数据时没有区分 http 流量,还需要后续改进。

仿照了 mitmproxy 的做法,在 tcp 连接的三个过程 (tcp_start、tcp_message、tcp_end) 中加入了 hook,只需要定义 Addon 即可控制连接。

2、Hook + Redis 实现消息队列

在 tcp 开始连接时,需要建立两个插入数据的消息队列,我使用了 Redis 的 Pub/Sub 模式,所以需要订阅插入 client/server 的频道。

type AddHeader struct {
    addon.Base
    client *redis.Client
    context context.Context
        mode string
}

func (a *AddHeader) TCPStart(f *flow.Flow){
    connName := f.ClientConn.RemoteAddr().String() + "<->" + f.ServerConn.RemoteAddr().String()
    insertToServerChannel := "InsertServer:" + connName
    insertToClientChannel := "InsertClient:" + connName
    // a.client是redis连接client,将同一个client的连接放入一个set中。
    a.client.SAdd(a.context, strings.Split(f.ClientConn.RemoteAddr().String(), ":")[0], connName)

  // 两个消息队列在Goroutine中进行
    go a.InsertMessage(f.ClientConn, insertToClientChannel)
    go a.InsertMessage(f.ServerConn, insertToServerChannel)
}

// 订阅channel以监听插入事件
func (a *AddHeader) InsertMessage(conn net.Conn,channel string) {
    pubSub := a.client.Subscribe(a.context, channel)
    defer pubSub.Close()
    for {
        msg, err := pubSub.ReceiveMessage(a.context)
        if err != nil {
            panic(err)
        }
        msgHex := msg.Payload
    // 设置终止符以退出订阅
        if msgHex == TERMINATOR{
            fmt.Println(TERMINATOR)
            break
    // 更改模式
        }else if strings.HasPrefix(msgHex, CHANGE_MODE){
            switch msgHex {
            case MODE_FREE:
                fallthrough
            case MODE_HOLD_UP_CLIENT:
                fallthrough
            case MODE_HOLD_UP_SERVER:
                fallthrough
            case MODE_HOLD_UP_ALL:
                a.mode = msgHex
            default:
                // error mode
                continue
            }
        }
        b, e := hex.DecodeString(msgHex)
        if e != nil{
            break
        }
        _, e = conn.Write(b)
        if e != nil{
            break
        }
    }
}

和 tcp_start 和 tcp_end 不同的是,这个方法会触发多次,主要用于将接收到的数据放入消息队列,以及拦截数据。

func (a *AddHeader) TCPMessage(f *flow.Flow) {
   msg := *f.Message
   connName := f.ClientConn.RemoteAddr().String() + "<->" + f.ServerConn.RemoteAddr().String()
   clientSendMsgChannel := "Client:" + connName
   serverSendMsgChannel := "Server:" + connName

   if msg.FromClient == true{
      a.client.LPush(a.context, clientSendMsgChannel, msg.Content, 10*time.Second)
   }else {
      a.client.LPush(a.context, serverSendMsgChannel, msg.Content, 10*time.Second)
   }
     // 决定是否拦截(清空数据)
    switch a.mode {
    case MODE_HOLD_UP_SERVER:
        if f.Message.FromClient{
            f.Message.Content = nil
        }
    case MODE_HOLD_UP_CLIENT:
        if !f.Message.FromClient{
            f.Message.Content = nil
        }
    case MODE_HOLD_UP_ALL:
        f.Message.Content = nil
    default:
        // do nothing
    }
}
func (a *AddHeader) TCPEnd(f *flow.Flow){
    connName := f.ClientConn.RemoteAddr().String() + "<->" + f.ServerConn.RemoteAddr().String()
    insertToServerChannel := "InsertServer:" + connName
    insertToClientChannel := "InsertClient:" + connName

    // 通知退出订阅
    serverErr := a.client.Publish(a.context, insertToServerChannel, TERMINATOR).Err()
    clientErr := a.client.Publish(a.context, insertToClientChannel, TERMINATOR).Err()
    if serverErr != nil {
        fmt.Print(serverErr)
    }
    if clientErr != nil {
        fmt.Print(clientErr)
    }
  // 清除此次连接记录
    a.client.SRem(a.context, strings.Split(f.ClientConn.RemoteAddr().String(), ":")[0], connName)
}

四、使用方法

1、将该代理部署在服务器上。

2、待测手机使用 Postern 或 ProxyDroid 等支持 socks5 代理软件,代理到对应服务器。

3、在自动化脚本中通过和 redis 交互,插入或获取包。


↙↙↙阅读原文可查看相关链接,并与作者交流