b站直播弹幕系统逆向工程笔记

0x00 就算是睿站只要有爱就没问题了吧

一直打算扒一波b站直播的各种接口,上次扒了刷经验的接口,写的小程序沿用至今,这次打算扒一波弹幕的系统,主要目的是统计一波每天有多少人刷小电视/摩天大楼,不用于自动抽奖等。

先随便开一个直播页面:

开发者工具:

印象中收弹幕用的是一个websocket来传输,果不其然:

点开之后,我们遇到了本次逆向工程的第一个难点:

所有的帧都使用二进制传输,chrome开发者工具无法查看内容。

谷歌一波,一个chromium开发组的哥们说:

Google follows the YAGNI, so we try not to expose much to keep other people that come and use the code from using it then having it get changed and then have it break areas that decided to start using it.

一句话:我们认为你们用不着这东西,所以我们就不提供。

行吧。继续。

0x01 狼与fiddler

第一个想法是在http协议下直接用fiddler抓包,结果:

在线websocket调试工具http://jxy.me/websocket-debug-tool/

得了,人家根本不让用http连。

幸亏fiddler有截获https连接的功能,如图所示:

点击Decrypt HTTPS traffic的时候会弹框提示安装根证书,因为fiddler抓包的原理是配置系统代理然后捕捉代理中经过的流量,而浏览器进行https连接之前需要经过一次握手交换证书信息,如果fidder提供的假证书不能得到系统信任的话则会拒绝连接。

启用解密HTTPS包之后,我们就能在fiddler界面中看到chrome中看不到的websocket帧的内容了:

从中我们可以看到,实际上通过二进制帧发送的内容仍然是JSON文本,这无疑大大降低了逆向工程的难度。

0x02 WS子的笔记

首先注意到:在弹幕websocket成功连接之后,首先由客户端向服务端发了一个包,内容如上图所示。简单分析一波:

而在之前的调试器中,我们可以看到:

如果不在连接之后发送确认身份的包,则会在5秒后被关闭连接。这明显是为了防止消耗太多服务器资源而作的设定。

再看一下下游的包:

稍微做一下格式化:

可以看到,JSON本身是一个JSON对象,cmd值为DANMU_MSG,即弹幕消息,info为参数。对于DANMU_MSG命令来说,几个参数分别为:(按照顺序)

{  // 部分字段与上图不同,为人为补充
	"info": [
		[  // 弹幕基础数据,不会为空
			0,  // ?,一直为0
			1,  // ?,一直为1
			25, // ?,一直为25
			16777215,    // 对于自己的弹幕,是8322816,即0x7EFF00,对于其他人的弹幕是16777215,即0xFFFFFF,应该是某种flag之类的
			1528474117,  // 弹幕发送时间戳,精确到秒,即new Date().getTime()去掉末三位
			-1713334982, // ?
			0,  // ?,一直为0
			"d5837852",  // 某种hash
			0   // ?,一直为0
		], 
		"打卡是什么用",  // 弹幕内容
		[  // 用户基础数据,不会为空
			11669035,    // 用户uid
			"胭脂醉",    // 用户名
			0,  // ?,一直为0
			0,  // 大会员标记
			0,  // 年度大会员标记
			"10000",     // ?,一直为10000
			1,  // ?,一直为1
			""  // ?,一直为空
		],
		[  // 佩戴勋章相关信息,无勋章则为空
			15, // 勋章等级
			"局花",     // 勋章名
			"痒局长",   // 勋章对应主播名
			528,        // 勋章对应主播直播间号
			16746162,   // ?,称之为A,待研究
			""  // ?,一直为空
		],
		[  // 用户等级相关信息,不会为空
			2,  // 用户UL等级
			0,  // ?,一直为0
			9868950,    // 对于未佩戴勋章者,一直为9868950,对于有勋章者有时等于A,也有例外情况
			">50000"    // 用户等级排名,字符串,具体值或>50000
		],
		[  // 成就头衔相关信息,无头衔则为空
			"title-144-1",  // 头衔编号
			"title-144-1"   // 重复了两遍?还是有特殊意义?
		], 
		0, // ?,一直为0
		0, // ?, 一直为0
		{
			"uname_color": ""  // 用户名颜色,格式不明,应该在上次桃花祭活动中用于粉名用户,现在暂时没有样本
		}
	],
	"cmd": "DANMU_MSG"  // 指令名
}

注意在下面这个帧中,同时包含了两条弹幕消息,而在热门的直播间中想必更多。

再浏览下去,可以看到还有其他几种指令类型:

SYS_MSG:系统消息,主要是小电视和摩天大楼的抽奖,这也是我们最关心的一个类型。

定义如下:

{
	"cmd": "SYS_MSG",  // 指令名
	"msg": "宝贤和目子的老公:?送给:?琪宝宝想养猫:?一个小电视飞船,点击前往TA的房间去抽奖吧",  // 消息内容,不知道和msg_text有什么区别
	"msg_text": "宝贤和目子的老公:?送给:?琪宝宝想养猫:?一个小电视飞船,点击前往TA的房间去抽奖吧",  // 显示在播放区域内的消息,:?的格式用于控制格式时进行分段
	"rep": 1, // 重复次数(猜想),用于连发礼物的高能消息
	"styleType": 2,  // 样式类型,2号为抽奖
	"url": "http:\/\/live.bilibili.com\/656",  // 点击弹幕后的跳转链接,这个居然没改成https
	"roomid": 656,  // 消息源的直播间号,不确定是否与url会有所差别
	"real_roomid": 971977,  // 实际房间号,因为短号一般为活动奖品,这里的则是使用短号之前的直播间号
	"rnd": 1528469326,  // 随机数,与时间戳无关(只是这条恰好长得像
	"tv_id": 0  // ?
}

SEND_GIFT:喂食,仅限当前直播间,一般喂食的帧都很长,比较好区分。

定义如下:

{  // 注意源JSON里面字符串都放的是unicode编码,只是这里为了方便理解写成了汉字
	"cmd": "SEND_GIFT",  // 指令名
	"data": {  // 礼物数据,一个大JSON Object
		"giftName": "辣条",  // 礼物名称
		"num": 10,  // 数量
		"uname": "来者可是坂本君",  // 投喂用户名称
		"face": "http://i0.hdslb.com/bfs/face/0f29eab6101f23e01abb084b99a184fc12dd765a.jpg",  // 用户头像链接
		"guard_level": 0,  // 舰队等级,0为非舰队,舰长,提督,总督分别为3,2,1
		"rcost": 434023,  // 某种序号,值为上一个rcost+礼物瓜子数/100*礼物数量
		"uid": 43543357,  // 投喂用户uid
		"top_list": [ // 七日榜前三名信息,随着投喂发送,4~10名只有在鼠标hover的时候才会展示
		{  // 第一名,注意不确定是否一定按照123名的顺序
			"uid": 9234880,  // 用户uid
			"uname": "悲落君",  // 用户名
			"face": "http://i2.hdslb.com/bfs/face/443d95071925856ed309947ecdd1186117f6a16b.jpg",  // 用户头像链接
			"rank": 1,  // 榜单中排名,不确定是否一定与顺序相符
			"score": 4000,  // 投喂亮(金瓜子+银瓜子)
			"guard_level": 0,  // 舰队等级,0为非舰队,舰长,提督,总督分别为3,2,1
			"isSelf": 0  // 是否是自己
		}, {  // 第二名,字段含义同上
			"uid": 15441403,
			"uname": "\u96f6\u7075\u5b50",
			"face": "http://i1.hdslb.com/bfs/face/6085ff1710a319d46d332dee7c6cf6172e8e452a.jpg",
			"rank": 2,
			"score": 2400,
			"guard_level": 0,
			"isSelf": 0
		}, {  // 第三名,字段含义同上
			"uid": 107163907,
			"uname": "Jerry__W",
			"face": "http://i0.hdslb.com/bfs/face/975105f091b785b64ecaf989f4d7c4935e63fe8f.jpg",
			"rank": 3,
			"score": 2000,
			"guard_level": 0,
			"isSelf": 0
		}],
		"timestamp": 1528476920,  // 投喂时间戳,精确到秒
		"giftId": 1,  // 礼物id,辣条为1,别的再说
		"giftType": 0,  // 礼物类型?(猜想为是否连击/高能
		"action": "喂食",  // ?,一直为喂食
		"super": 0,  // ?
		"super_gift_num": 0,  // ?
		"price": 100,  // 礼物价格,瓜子数
		"rnd": "1596293081",  // 随机值
		"newMedal": 0,  // ?
		"newTitle": 0,  // ?
		"medal": [],  // ?
		"title": "",  // ?
		"beatId": "",  // ?
		"biz_source": "live",  // ?
		"metadata": "",  // ?
		"remain": 0,  // ?
		"gold": 0,  // ?
		"silver": 0,  // ?
		"eventScore": 0,  // ?
		"eventNum": 0,  // ?
		"smalltv_msg": [],  // 猜想为小电视信息,非小电视则为空
		"specialGift": null,  // ?
		"notice_msg": [],  // ?
		"capsule": {  // 扭蛋机相关数据,但是似乎已被弃用,其中的数据都是空的,不做分析
			"colorful": {
				"coin": 0,
				"change": 0,
				"progress": {
					"now": 0,
					"max": 5000
				}
			},
			"normal": {
				"coin": 0,
				"change": 0,
				"progress": {
					"now": 0,
					"max": 10000
				}
			},
			"move": 1
		},
		"addFollow": 0,  // ?
		"effect_block": 1,  // 展示效果类型?
		"coin_type": "silver",  // 花费瓜子类别,silver=银瓜子,gold=金瓜子
		"total_coin": 1000  // 总花费瓜子数,=price*num
	}
}

ROOM_RANK:最近小时榜功能上线后加的,更新小时榜数据。

定义如下:

{
	"cmd": "ROOM_RANK",  // 指令名
	"data": {  // 数据
		"roomid": 5441,  // 房间编号,注意这里是real_roomid,不是奖励的短号
		"rank_desc": "小时榜 165",  // 显示在小时榜框中的字符串
		"color": "#FB7299",  // 小时榜框的颜色
		"h5_url": "https:\/\/live.bilibili.com\/p\/eden\/rank-h5-current?anchor_uid=322892",  // 网页h5版显示链接
		"web_url": "https:\/\/live.bilibili.com\/blackboard\/room-current-rank.html",  // 另一个链接?
		"timestamp": 1528480261  // 时间戳
	}
}

WELCOME:进场提醒,只针对老爷,不针对舰队。

定义:

{
	"cmd": "WELCOME",  // 指令名
	"data": {  // 数据
		"uid": 43071166,  // 用户uid
		"uname": "钟意钟意你",  // 用户名
		"is_admin": false,  // 是否是房管
		"vip": 1  // 当进场为月费老爷时,会有vip:1,进场为年费老爷时则没有vip:1,而有svip:1
	}
}

WELCOME_GUARD:舰队进场提醒。

定义:

{
	"cmd": "WELCOME_GUARD",  // 指令名
	"data": {  // 数据
		"uid": 11578620,  // 进场用户uid
		"username": "coralam",  // 进场用户名
		"guard_level": 3  // 进场用户舰队等级,1,2,3分别对应总督,提督,舰长
	}
}

其他的碰到再更新,我们接下去说。

0x03 我不用fiddler啦!乔乔!

用fiddler这样一个一个分析包还是很麻烦,如果使用程序来分析就要方便得多了。

由于websocket中传输的都是JSON,无疑Node.js技术栈是最简单的选择。不过在展开编码之前,还有几个细节需要注意到:

第一就是,尽管我们到现在为止看的一直都是JSON,但是实际上传输的是二进制帧。而在每一个二进制帧之前,都有16个字节的header,需要将它从帧里面去掉才能进行解析。

实际上这16个字节的前四个编码了整条消息的长度,后12位一定是0010 0000 0000 0005 0000 0000。

第二则是,客户端每30秒就会发送这样一帧:

这个并不是用于发送弹幕,实际上发送弹幕用的是api.live.bilibili.com/m的接口。而这个帧所发送的[object Object],则是javascript中将一个没有定义toString()方法的对象强行转化为字符串所得到的结果。

个人分析,这里理论上是应该传输一些数据到服务器的,但是因为前端编码的失误,没有正确定义toString()方法或者使用JSON.stringify()来将对象转化成字符串,导致了这样的后果。无疑,[object Object]没有传递任何数据,不过我认为它可能还是起到了一个心跳包的作用,用来告诉服务器自己没有断线,因此在实现模拟客户端的时候也需要加以考虑。

而在这一帧发出去后,立刻就会收到一个这样的帧:

这些帧并不包含JSON数据,应该在程序中过滤掉,过滤方法是其第17个字节不是左花括号{。

其他包含JSON数据的帧并不一定与这个echo帧同时发送,而是在有数据时立刻发送,这则利用了websocket的全双工特性。

第三点则是,针对起始帧而返回的echo帧也不太一样,内容如下图所示。

因而实现时要小心错误。

第四点则是,我们之前提到弹幕接口的第一帧中包含有当前登陆的用户uid,不过实际上这个uid可以是不需要的,只要留作0即可,而此时也无需传输任何cookie数据。(chrome隐身模式亲测)

当然,在这种情况下,下游帧中关于是否是自己的判断都会失效,不过这不影响我们的应用目的。


在了解了这些注意事项之后,我们就可以着手开始实现了。我这里的环境是Node.js v9.11.1,websocket客户端则用ws,其实有一个非常简单的实现,可惜这里空白太小……

因此,各位看官,方便的话,考虑移步github不?

在几天的开发中,又有一些新的发现,有空写成另一篇文章吧,所以不如点个关注,更新之后立刻推送,岂不美哉!

就酱。

编辑于 2018-06-11

文章被以下专栏收录