博客:www.wireghost.cn
前端时间因工作业务需要,简单还原了下六间房的刷人气场景,这里主要对相关过程做下梳理,并尝试提出一些检出建议。。
用Fiddler对六间房的登录环节进行抓包,发现上传过程中对密码做了加密处理。
在谷歌浏览器开发者模式下,检索"password"关键字,并通过JS调试定位到相应的加密函数:
其中,servertime和nonce都是由服务器返回,这里我们只需要拷贝那段加密用的JS代码,通过Python调用JS获取加密后的数据,并模拟后续对应的post请求,即可成功登录。。
def encode_pwd(self, pswd, st, nc): f = open("./sha.js", 'r') line = f.readline() htmlstr = '' while line: htmlstr = htmlstr + line line = f.readline() ctx = execjs.compile(htmlstr) return ctx.call('getpassword', pswd, st, nc)
由于HTTP是一种无状态协议,在数据交换完毕后,服务器端和客户端的链接就会关闭,即服务器不知道用户上一次的请求内容及次数,这严重阻碍了交互式web应用程序的实现。Cookie则是网站为了辨别用户身份,从而储存在用户本地终端上的数据(通常经过加密)。所以,我们还要获取登录相关的Cookie信息。
上图是发送登录请求后,服务器的响应内容,可以看到它返回了3个URL地址用于接下来的网络访问。通过分析发现redirect_url才是最终用于设置Cookie的链接:
这些Cookie中,有两个字段值得特别注意下,一个是ticket_v,这个应该是服务器下发用于识别账户身份的登录口令,下面会做说明;另一个是_vinfo或者_coin6,它的前几位数字序列是用户的tokenid,这个可以在登录后跳转到用户的直播主页进行确认。之所以单独把它们列出来,是因为这些数据在之后的进房操作时还会用到。。
# 解析JS资源 soup = BeautifulSoup(html.decode("UTF-8"), 'html.parser') jss = soup.find_all('script') pattern = re.compile("(?:\"prod\":\")([^\"]+)") prod = pattern.findall(str(jss))[0] print(prod) # 设置Cookie if rhead is not None: for item in rhead: if item[0] == 'Set-Cookie': self._thisCookie += item[1].split(';')[0] + '; ' # 从cookie中解析用户信息 self._ticket = re.search('ticket_v=[^\;]+', self._thisCookie).group().split('=')[1] self._puid = re.search('_vinfo=[^\;]+', self._thisCookie).group().split('%7C%7C')[0].split('=')[1] print(self._puid) self.currentlyin = self._puid + "_" + self.getouterip() # 登录账号的用户ID加上它的外网IP地址,用于标识和验证一个登录账户 self._thisCookie += 'currentlyin=' + self.currentlyin + '; ' self._thisCookie = self._thisCookie[:-2] print(self._thisCookie)
登录之后的下一步操作就是进指定的直播间了,下面我们以230676880这个房间为例:
这里,我们同时使用Fiddler和WireShark对进房行为进行抓包。为什么呢?原因是这两个抓包工具各有特色,Fiddler的原理是把自身作为一个代理,所有的http请求在达到目标服务器之前都会经过Fiddler,同样的,所有的http响应也都会在返回客户端之前流经Fiddler,在目标设备安装并信任Fiddler的证书后,更是可以直接解密HTTPS,但也因此基本上只能够用来分析HTTP/HTTPS网络协议;WireShark的功能则要更强大些,它的原理是直接基于网卡的,通过将其设置成混杂模式从而完成抓包。说简单点就是什么都抓,但配置及使用上比Fiddler要麻烦些。由于目前还无法确定进房间时会使用到哪些协议,所以都用。。
首先,对Fiddler抓取的数据包中疑似与进房行为有关的网络请求,逐一模拟,然而最终都没有显示成功进房。所以,这里我们可以初步判定是否进房的判断并不依赖于一个或多个HTTP\HTTPS请求的计算,它应该有着某种独立的关键性的进房数据包,并且为了让服务器知道是哪个用户要进哪个直播间,它应该包含用户的登录凭据以及主播的房间号或者主播ID才对。
根据前面的假设,尝试在Wireshark捕获的网络包中检索"230676880"(房间号)、"81041785"(主播ID)字符串,结果当查询"81041785"时,发现了以下数据包:
从内容上来看,这个很可能和登录进房有关。其中,UID就是用户的tokenid,encpass后面跟的则是前面提到的登录口令即ticket_v这个Cookie的值,roomid则是我们检索用到的主播ID,它可以在前面的直播页面框架请求中通过正则匹配拿到。。
至于IP和端口号,在这个例子中是"61.137.182.37:12200",它是从服务器返回的地址列表中随机获取到的。
现在,我们尝试用socket通信的方式模拟该网络请求:
tcpCliSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: tcpCliSock.connect((self._ip, int(self._port))) except socket.error: print('fail to setup socket connection') else: print('sending..........') login_cmd = 'command=login\r\n' + 'uid=' + self._puid + '\r\n' + 'encpass=' + self._ticket + '\r\n' + 'roomid=' + self._rid + '\r\n' self._login_cmd = '00000' + str(len(login_cmd)) +'\r\n' + login_cmd print(self._login_cmd) tcpCliSock.send(self._login_cmd.encode()) print('reading...........') print(tcpCliSock.recv(4028))
结果,我们确实收到了来自服务器的响应,提示已登录成功,但在直播页面上仍然没有显示进入房间。。
看来进房的关键包可能不只这一个,回到WireShark中继续分析,发现在这之后又发送了一个网络包,其内容做了加密处理,并且服务器连续返回了多条数据。于是我们怀疑,这个网络包可能也是进房算法的一部分。
既然如此,那么就有必要对这个网络请求也做下模拟。但是在这之前,我们得知道它的消息格式与加密算法,这就只能在JS源码中去找了。遗憾的是,并没有找到(ㆁωㆁ)。。
这说明Web端很可能对这部分代码做了某种保护,那怎么办呢?这里有个思路,就是转移战场,到移动端Web或移动端APP上继续逆向。由于是同一个直播平台,即便是在不同的端上,其设计思路也应该雷同,并且移动端Web都是用的HTML5,分析起来可能会更容易些。此外,针对移动端Web的场景,倒也不必专门在手机浏览器上进行操作,我们可以直接利用谷歌浏览器的开发者工具对移动设备进行下模拟即可。
看来移动端Web如预想的一样,直接就找到了相应的代码块:
通过断点调试,确定发送的消息格式为:
'{"t":"priv_info", "content":{"encpass":"登录口令"}}'
加密算法如上,可以看到它先是用了某种算法(笔者简单阅读后,没能分辨出),然后做base64加密,最后再对指定的一些字符进行替换。问题就出在第一步,现阶段我们并不知道它用的是什么算法。
当然,理论上即便不知道其具体算法,仍然可以通过调JS的方式进行解决。只是这部分关联的代码较之前更加复杂,实现起来相对困难,并且我们还是有办法知道这块的原理的。
通过对六间房的Android客户端进行逆向,得知前面那段JS代码有可能是在做deflate压缩:
消息格式和加密算法基本都弄清楚了,然而现在其实还有一个问题,就是这个消息格式是我们从移动端web分析过来的,加密算法也是参照了移动端APP的代码,但是真正的PC端Web场景和这个会不会存在差异呢?这里我们有必要编写相应的解密算法,对前面捕获的网络包解密,以做下确认。
public static String decryptContent(String content, boolean bool) { String result = ""; try { String source = content.replaceAll("@", "=").replaceAll("\\)", "/").replaceAll("\\(", "+"); byte[] bit = new BASE64Decoder().decodeBuffer(source); if(bool) { result = new String(inflate(bit)); return result; } result = new String(bit); } catch(Exception e) { e.printStackTrace(); } return result; } public static byte[] inflate(byte[] ori) { Inflater inflate = new Inflater(true); inflate.setInput(ori); ByteArrayOutputStream output = new ByteArrayOutputStream(ori.length); int count; byte[] array = new byte[1024]; try { do { if (inflate.finished()) { break; } count = inflate.inflate(array); output.write(array, 0, count); } while (count != 0); output.close(); array = output.toByteArray(); inflate.end(); return array; } catch (DataFormatException e) { e.printStackTrace(); return null; } catch (IOException e) { e.printStackTrace(); return null; } } public static void main(String args[]){ String decrypt = decryptContent("DcpLE0JAAADgv9LsucNar3STUZmV12aki5GQ0q68CuO)5)rNN4EWbEFVF31U0IyB9QokjLYpXXgCKU2quGmW0W9083o82Y3OE4J1P8Of3xgw7n6LC6Xk9qIamsHT8Dwk4s6S3LHOeCyPdcrUsy9oXwtFovOp7IS4(fWOWAF3UVweFO81NO6j613jncPIgZaK3pKkDfKFdpjqKmK1T6ow4SCEApjnPw@@",true); System.out.println(decrypt); }
程序运行结果与我们之前模拟移动端Web所调试出来的完全一致,说明算法和消息格式都没有问题,接下来就只需模拟该网络行为即可。。
继续发送以下数据包:
# 发送的第二个数据包 authkey = '{"t":"priv_info", "content":{"encpass":"[token]"}}' authkey = authkey.replace('[token]',self._ticket) enc_content = self.encryptcontent(authkey.encode()) getauthkey_cmd = 'command=sendmessage\r\n' + 'content=' + enc_content + '\r\n' self._getauthkey_cmd = '00000' + str(len(getauthkey_cmd)) + '\r\n' + getauthkey_cmd print(self._getauthkey_cmd) tcpCliSock.send(self._getauthkey_cmd.encode()) print('reading...........') print(tcpCliSock.recv(4028))
这一次,我们终于成功进入房间了!!!
仅仅只是登录进房还不够,因为不过多久就会掉线了。为此,我们需要持续向服务器发送心跳,以证明自己仍在房间。继续分析WireShark网络包,发现每隔一段时间就会发送如下数据:
这个很可能就是心跳包了,尝试每隔10s发送一次并验证效果。
# 成功进房后,开始发心跳包 heartbeat = 'command=sendmessage\r\ncontent=y8vPLwAA\r\n' self._heartbeat = '000000' + str(len(heartbeat)) + '\r\n' + heartbeat while True: time.sleep(10) print(self._heartbeat) tcpCliSock.send(self._heartbeat.encode()) print('reading...........') print(tcpCliSock.recv(4028))
在等待1个小时左右的时间后,发现测试账号仍然在观众列表中,证明我们已能稳定挂机,实现至少一个人气。。
事实上,直播平台的真实人气算法绝不只是计算观众数量这一个维度。稍微想想就能明白的,它与用户的活跃度,如弹幕、礼物等等亦应有所关联。只是考虑到我们的分析场景,对于黑产及普通用户而言,所谓的"直播刷人气"其实就是狭义的指代"刷观众数"。
这类直播刷人气的原理本质上就是分析对方的Web请求然后重写,并对部分行为做了些精简,相当于是模拟实现了一个小型客户端。从这个角度出发,在代码层面几乎是无法检测的,只能够依赖业务逻辑。这里有一个思路,就是正常用户进直播间,通常是会先浏览主页的直播信息,然后找到自己想要看的房间,再点击进入。所以我们可以尝试去采集页面上的鼠标轨迹,这是机器行为所没有的特征。此外,这类代刷人气的目标都是很明确的,一般来说不会特意去模拟获取房间列表的网络请求。目前的思路就这两个,希望能够抛砖引玉。。