osx平台上lol英雄联盟launcher启动器的分析实现 我算个LOL玩家吧,虽然是郊区白银段位的,虽然能1打5个电脑,但瘾不小。每次想打LOL,都找不到PC电脑,看着OSX却不能玩国服,只能玩美服、韩服。而外服的网络延迟比较大,根本没法打,跳帧严重,野怪都打不过。每次瘾来了时,总是想在OSX上也能玩,次数多了,决定试试,看看能否在OSX上玩国服。 15年10月份对LOL英雄联盟录像做过解析器在mac osx上看lol国服ob录像的技术分析,文中最后提到过关于LOL客户端架构的组成,以及在OSX上打LOL国服还是有可能的。 现状分析,League of legends有mac版本,在riot拳头运营的地区中,有韩服、美服、欧服之类国外服务器。而腾讯运营国服版,却没有OSX版本。上次的录像分析中,是腾讯运营的windows版本产生的录像文件,可以在OSX版本的美服上解析、渲染、播放,也就以为着协议是相通的,也就意味着用riot运营的OSX客户端,连接腾讯运营的服务器,应该可以正常通讯,正常游戏的。 先来看下国服League of legends英雄联盟的目录结构 Air目录为LolClient.exe所在目录,即游戏大厅目录,air的,也就是flash as的,意味着反编译比较简单... Config目录确定 Cross目录为腾讯游戏必装的各种乱七八糟工具包目录 Game目录为game进程League of legends.exe所在目录 log目录,我也不知道这是记录啥日志的,一直是空的 logs目录,这是launcher启动器记录通讯数据的目录 TCLS目录是腾讯登录SSO的客户端程序目录 TQM目录,腾讯自己的,跟游戏无关 先来对比Windows跟osx上区别,riot运营的(以下以美服代表)游戏,是先选地区,再进入登录界面,再进入游戏。而腾讯运营的是先登录,再选择大区,再进入游戏。显然,腾讯运营的选大区,可以理解为riot运营的选地区,之后再进入游戏。区别是riot先选区,再登录;腾讯(以下简称国服)的先登录,再选区。先看国服在windows上流程,快捷方式打开的是TCLS\Client.exe,内嵌登录的DLL,登录之后,选大区,直接开启LolClient.exe进入对应大区游戏。而美服的登录窗口是在LolClient.exe中完成的,不难看出区别是美服手动登录,国服自动登录。显然,是LolClient.exe启动时,CLI参数包含了一些KEY,来实现了自动登录。 也就是说,问题在登录认证部分,只要我在OSX上能实现LolClient.exe的自动登录就可以...当然要在windows上获取登录的Game Signature Key,先来看下win上的进程启动信息: 果然是CLI中参数带有signature key,参数如下: 可以看出,除了Air运行时参数外,还有8393数字,认证的KEY,当前大区服务器域名,xmpp聊天服务器地址,登录服务器地址之类。另外,在看下LolClient.exe的进程树: 如上图,游戏大厅进程LolClient.exe跟game进程League of legends.exe都是lol.launcher_tencent.exe的子进程。 这里的8393数字其实是LolClient.exe要连接的TCP端口,也就是lol.launcher_tencent.exe进程监听的端口, lol.launcher_tencent.exe进程再fork起LolClient.exe进程,进入游戏大厅。 游戏开局匹配时,都是在LolClient.exe中进行的,匹配完成之后 ,lol.launcher_tencent.exe又createprocess出Game\League of Legends.exe进入游戏。 那么,只要我在osx上实现一个launcher的功能,就能在osx上玩League of legends英雄联盟的国服了,感觉很兴奋的样子,继续往下看...... LolClient.exe进程进入游戏后,lol.launcher_tencent.exe进程是如何知道的呢?它又是如何决定启动League of Legends.exe进程的?在上篇在mac osx上看lol国服ob录像的技术分析中,提到过,game进程启动时,命令行参数有个端口8394,先查看进程监听列表 从结果中,可以看到进程lol.launcher_tencent.exe监听了本地的8393、8394、8395端口,而且,LolClient.exe已经连接了8393端口,也就是其启动时命令行参数中的对应数值。那么另外两个端口8394、8395也应该是其他进程来连接的咯。抓本地数据包,看看他们之间有没有通讯,如看看他们通讯了什么内容:(在windows上,wireshark抓不到回环地址通讯包,可以用rawcap来抓包) 之后,wireshark打开这个文件,可以看到如下TCP数据包: 从协议包结果上来看,对比发送、接收的4个包作为例子,不难看出,他们的数据包格式拆分为前8bytes10 00 00 00 01 00 00 00为固定值(暂且认为是固定值);后面4bytes04 00 00 00、05 00 00 00为一段;再后面的4bytes00 00 00 00、34 00 00 00为另一段;最后一部分的Nbytes长度31 34 2e 31 37 2e 32 33 2e 39 34 20 35 31 35 36 20 51 62 77 41 34 73 6f 42 38 4a 71 37 43 4f 45 51 2b 31 78 63 34 67 3d 3d 20 34 30 30 36 33 31 39 32 33 32为最后一段;以第4个通讯包来看,第三段的对应数值是0x34,显然是LittleEndian小端字节序,显然后面的00000020 - 00000054部分数据长度也是0x34个,也就是说,协议通讯格式的第三段表示后面消息包长度的含义,第四段就是主体消息正文。第一段、第二段都是消息头的部分。第二段多数为command指令。暂且这么假设,那么通讯协议包格式确定了,接下来就要确认下每个command对应的业务含义了。先看下LolClient.exe进程发给launcher,跟League of legends.exe发给launcher进程的TCP数据包,初步确认下我对协议包格式的判断是否正确 如何查找command的类型一共有多少种?如何确认每种command的意义?记得最初LOL目录结构里提到大厅客户端LolClient.exe是flash air的,反编译比较方便,入口文件是LolClient.swf,跟下去是mod/win/ClientWindow.dat,然后加载了&^*($#@#,as代码太乱了,而且,都分布在各个小的flash中,目录还不统一,拓展名还都是dat的,还得手动改成swf,反编译工具才能选它,as代码也不好理解,检索麻烦,索性直接反编译lol.launcher_tencent.exe进程吧。 IDA的导入lol.launcher_tencent.exe进程后,在imports中看到 Maestro_Remove、...等来自RiotLauncher这个library 再静态分析RiotLauncher.dll,在Exports里看到Maestro_MessageTypeToString函数跟进,看到MAESTROMESSAGETYPE_GAMECLIENT_CREATE等字样 ,F5导出 至此,可以看到lol launcher的协议指令类型都在这里。再看看哪里调用了Maestro_MessageTypeToString函数: 再看看MaestroMessageAgent_SendMessage的调用处: 从MaestroGameController_SendGameConnectedToServerMessage函数名的字面含义上理解,此函数为『当游戏连接到服务器时,发送消息』,继续跟进...可是,我静态分析水平比较弱,越是继续跟进,我的思路越混乱,决定另辟蹊径。 在文中最初的LOL目录结构中,有个Logs目录,这个目录下Maestro Logs目录中的maestro-server.log、maestro-game_client.log中有一些日志,是开发人员用来排查调试程序执行结果的信息,大约如下: 这里确实可以对应着launcher协议中,某些command被接收后,会被记录到这里。如何确认每个日志记录指令对应是哪个TCP包发送后的结果呢?如何利用这些日志信息呢?如果windows上有类似linux的strace查看系统调用的工具就好了,你还别说,还真有一款功能类似的,就是ProcessMonitor,有了这工具,我就可以看到lol.launcher.tencent.exe进程在何时发送TCP数据包,发送后写了哪个文件数据,写了什么内容,先写日志还是先发TCP数据包。。。 如上图,可以看到先发TCP包,还是先写日志文件,哪个进程发,哪个进程写。但还有一点要确认的是,发的TCP包的内容如何跟写的日志内容配对问题,显然,ProcessMonitor工具里有些更有用的信息,你可能没注意到进程每次发包后,会记录发的包大小;进程每次写入文件数据时,会记录写时的offset以及写入文件长度,根据launcher进程写日志时,获取的offset位置,可以找到对应maestro-server.log文件的offset,再根据写入文件时的length数值,可以确定写入内容是什么。那么,机智的你不难看出,写入日志内容的字面含义,很容易确认了TCP数据包中对应的command代表的含义,也明白了command代表的业务逻辑。 好了,lol.launcher_tencent.exe的功能大约理清楚了,再来回顾下League of Legends的客户端架构,以及猜测下服务端架构,根据我的理解,我画了一副图 架构图中的虚线部分,为服务器之间节点通讯,是我的猜测,并不是想客户端部分那样,按照分析客户端通讯得来的架构图。可能不准确。 匹配请求处理服务器,用来处理客户端的登录认证、符文、天赋修改、英雄列表,进入战斗的匹配请求等。 匹配服务器,应该是整个大区用同一个,这里会预算所有与大区玩家相关的数据,战斗力匹配,寻找想匹配对手,划分到对应服务器中。这是核心点,也是瓶颈点。 房间管理服务器,应该要独立拆分出来,与匹配服务器解耦,与战场服务器解耦。当匹配服务器完成后,只要将匹配结果的10名玩家,玩家的符文、天赋配置等发送给房间管理服务器,然后由房间管理服务器去创建对战进程,再获取对战服务器的IP PORT 以及每个人的加密KEY(也应该是认证该角色的凭证),比如"14.17.23.94 5156 QbwA4soB8Jq7COEQ+1xc4g== 4006319232"发送给每一个客户端。客户端拿到消息,再发送给lol.launcher.tencent.exe,再有它启动游戏进程。 房间管理服务器都是可以横向扩展的,尤其是游戏服务器,更是可以横向扩展,单个游戏进程,只跟本次游戏中的10个玩家有关。 port范围5000-5500,也就是说单台服务器最多开500个端口,也就是500场战斗,以每场战斗10个人来算,单台服务器是5000人的承载。实际上可能要依赖服务器的性能承载。15年初网上消息LOL同时在线750W,时隔1年,国服现在同时在线有多少人?能承载多少玩家同时在线?匹配服务器虽然是很难拆分的节点,但业务比较单一,CPU密集型,应该大概也许可能不难解决。其他节点,拓展起来就更方便了,堆堆服务器应该可以解决。当然,我没坐过类似的客户端游戏架构,只是基于页游、手游的经验猜。 回到正题,LOL launcher的业务确定了,与另外两个进程通讯的协议格式也确定了,看看实现时,我要怎么做 监听系统信号的线程 监听TCP 8393端口,等待LolClient.exe连接 监听TCP 8394端口,等待League of legends.exe连接 监听TCP 8395端口?暂时不监听了,目前不知道这端口是干啥的 启动client的线程 保持与client的心跳线程 启动game的线程 保持跟game的心跳线程 接收来自game消息的心跳线程 处理client消息转发到game的线程 处理game消息转发到client的线程 解析协议数据包,获取command,数据包长度,数据包内容 分析command,心跳包直接回复。与game无关消息包,直接回复MAESTROMESSAGETYPE_REPLY类型已收到该消息。 部分消息需要转发给game进程,走chan转发,比如进入游戏后,好友还是可以在游戏大厅发消息到正在游戏中的玩家的,聊天消息转发MAESTROMESSAGETYPE_CHATMESSAGE_TO_GAME 就是从client发到launcher,再有launcher转发至game的。 处理client消息转发到game的线程、处理game消息转发到client的线程 当然,还得再启动两个线程来接收launcher转发来的消息, 等等等等,一系列代码写完后,本以为终于可以在osx上玩LOL国服了,没想到,我还是太年轻,想的太简单了。程序启动LolClient.exe之后就假死了,一番排查后,发现是TCP粘包问题 尼玛,是LolClient.exe发送给launcher.exe的TCP数据包分了两个TCP包发送的,尼玛,100个字节都不到,还分两个字节,尼玛。。累不累? 一系列代码写完后,本以为终于可以在osx上玩LOL国服了,没想到,我还是太年轻,想的太简单了。我写的lol launcher启动Lolclient.exe没问题,符文、天赋编辑也都没问题。但启动League of legends.exe时,却进程崩溃退出了,而我却百思不得其解,为什么?通信协议一样的,发送command时机一样的,为什么会崩溃呢?这突然的失败,让我思路中断,思绪全无,不知道如何进行下去。。。。。。冥冥中,我记得在静态分析lol.launcher.tencent.exe时,看到了一个特殊的函数名再回到ProcessMonitor中看看League of legends.exe启动时的环境变量是什么 显然是__COMPAT_LAYER=ElevateCreateProcess(不要问为什么显然...我也曾经懊恼过,上学时,老师总是说显然,可我就是不明白为什么显然) 在OSX上,也找下环境变量是什么获取LeagueOfLegends.app进程执行时的环境变量,去除系统默认的几个,剩下的,就是游戏自己添加的 一系列代码写完后,本以为终于可以在osx上玩LOL国服了,没想到,我还是太年轻,想的太简单了。除了程序执行时的环境变量外,还有一个程序执行时的当前目录: 记得要chdir一下,这么改进后,本以为终于可以在osx上玩LOL国服了,没想到,它确实在windows上登录国服,终于运行起来了 它也在OSX上登录美服,成功运行起来了 那么我只要把OSX上启动LolClient的命令行参数改为国服的参数,就可以登录国服,玩国服游戏咯,尝试一下果然可以,我情不自禁的开始佩服我的聪慧,机智,下一步就开房间,匹配打国服了?然而,我还是太年轻,想的太简单了。程序启动League of Legend.app时出错了,进不去,日志中记录 之后,游戏进程就报错退出了。我太年轻了,想的太简单了......可是,我好像已经不年轻了...... 这到底是为什么,玩个游戏怎么这么难,这么难?我只是想玩个游戏而已,为什么这么难?腾讯LOL项目组的朋友,能不能告诉我,你们还验证了什么?很明显不是版本号,是不是操作系统?是不是拒绝了OSX的登录? 曾经,我曾尝试过从协议上入手,看看LOL游戏协议能不能搞定,可难度比较大,花了好几个星期的空余时间,大约确认了是UDP Enet协议格式,后来,也在网上搜到相关信息,确认了。但网上公开的协议格式,是4.23以前的,现在都6.*的版本,早就改了....唉,以后再说吧... 最近两天,总结了之前研究LOL Launcher的实现过程,疏于工作,拖延了工作进度,我自己也是内心有愧。有愧的不仅仅是会议上的苍白答复,也还有这篇总结本计划与2015年底写完的,也还是拖延到现在。 总结完这边博文,接下来要写部门的年终总结了,离丙申年到来还有5天,犯有严重拖延症的我,能把我自己的年终总结,在乙未年结束之前写完吗? 参考资料: Evolving the Server-Side Architecture of League of Legends IntWars Scaling League of Legends Chat to 70 million Players 代码开源: 2016-02-03 +16 20 0
在mac osx上看lol国服ob录像的技术分析 一天,兴起而至,想在mac osx上看lol ob,要看国服的,没现成方案,实现不了,未遂,便决定研究一番。 在拳头游戏官方的api中SPECTATING GAMES riot games api提到观看ob模式的程序启动参数: "C:\Riot Games\League of Legends\RADS\solutions\lol_game_client_sln\releases\0.0.1.74\ deploy\League of Legends.exe" "8394" "LoLLauncher.exe" "" "spectator spectator.na.lol.riotgames.com:80 P+C3YqI3Mg9oHc6t9eTAKWE4T8prxwzR 1726229459 NA1" 程序启动的第三个参数中: 这里面包括ob server地址、端口,以及本场录像文件加密key,以及游戏id,游戏所属平台id。其中 encryptionKey参数每次都不一样,从数据包分析发现,这个key并不是为了在获取录像时的验证,我觉得是roitgames为了安全,为每场游戏的协议数据包做的加密key,客户端观看ob 录像时,用这个key解密,以提高协议安全度。 在riotgames官方api介绍上,以及github上几个开源的lol项目上,以及本人wireshark抓包分析总结出一场ob游戏观看,一共调用了如下http api接口 GET /observer-mode/rest/consumer/version //获取录像文件版本信息,返回字符串 GET /observer-mode/rest/consumer/getGameMetaData/:region/:id/*ignore //获取游戏的基本信息,包括游戏当前桢,最后一个chunkid,游戏开始时间等信息。返回json格式字符串 GET /observer-mode/rest/consumer/getLastChunkInfo/:region/:id/:end/*ignore //获取录像中最后一个chunk基本信息,包括了当前chunkid,下一个chunkid,当前桢的id之类信息,返回json格式字符串 GET /observer-mode/rest/consumer/getGameDataChunk/:region/:id/:chunkId/*ignore //获取对应chunkid的协议数据,返回字节流 GET /observer-mode/rest/consumer/getKeyFrame/:region/:id/:frame/*ignore //获取对应chunkid的协议数据,返回字节流 lol ob观战模式的实现,是将游戏中所有玩家操作,合并成一个chunk块,来发送到客户端的,以减少通讯量。 GET /record/:region/:gameId/:encryptionKey 这个api在github上有提到过,但我抓包过程中,没有看到被调用过。暂时忽略。 至此已经知道lol 英雄联盟的观战通讯过程,但全球大区中,我们腾讯服务器跟欧美服务器不一样,并没有公开的ob server地址。而是在tgp腾讯游戏助手中实现的。 这是个嵌入的html,真实地址是 http://api.tgp.qq.com/spectator/v1410/watch.shtml ,对应的ob 观战列表文件为 http://api.pallas.tgp.qq.com/core/get_ob_list ,以ob文件http://ob.pallas.tgp.qq.com/ob_data/1_1632443320.ob 来举例子。对于国外其它服务器的观战,是连接到官方的观战服务器进行的。而腾讯lol的观战,却是将录像文件下载到本地的,那是否可说明tgp助手在本地建立了一个http服务器,并解析ob文件,实现ob观战服务器的功能呢? 为此开始确认这个过程,在tgp腾讯游戏助手上观战了这场游戏,查看游戏进程的启动参数中,ob server地址是多少,开Process Monitor抓进程启动参数,开tgp助手观战: 如上图所示,启动参数复合riotgame的api介绍,我们来确认下127.0.0.1: 36722 是否正确,以及是哪个进程开启的。 replay.exe进程启动详情: 腾讯tgp助手,确实在本地开启http ob server来实现的。那么进一步抓包验证。对于回环地址,wireshark抓不到的,要用RawCap来抓。 同时,wireshark 抓取 ob录像文件下载的数据包,查看localhost.pcanpng ,果然是lol ob录像系统调用的几个http请求。 一一查看version\getGameMetaData\getLastChunkInfo\getGameDataChunk\getKeyFrame几个请求。也确实以相应字符串、json字符串、字节流的形式返回,但数据源头来自哪里呢?肯定是最初的ob文件了。 如上图,其中开头的每行分隔的字符串,、json字符串也比较好理解,里面的元素也是lol ob录像系统请求相应的json字符串 LOLOB/1.0 abstract:{"src": 1, "area_id": 1, "score": 5625000, "game_length": 1260, "battle_type": 4, "max_tier": [0, 0], "game_id": 1632443320, "start_time": "2015-10-21 08:13:37", "ob_ver": "1.82.89", "encryption_key": "x83U9UPUuB/+INJyaU2Wz8lUuLn5aXEt"} source:{"gameId": 1632443320, "gameStartTime": 1445386417603, "platformId": "HN1", "gameMode": "CLASSIC", "mapId": 11, "gameType": "MATCHED_GAME", "gameQueueConfigId": 4, "observers": {"encryptionKey": "x83U9UPUuB/+INJyaU2Wz8lUuLn5aXEt"}, "participants": [{"profileIconId": 7, "championId": 60, "summonerName": "\u5fc3\u6001\u597d\u80fd\u4e0a\u5206\u5417", "skinIndex": 2, "bot": false, "spell2Id": 11, "teamId": 100, "spell1Id": 4}, {"profileIconId": 15, "championId": 117, "summonerName": "\u4e5d\u670813\u65e5\u4e36", "skinIndex": 5, "bot": false, "spell2Id": 4, "teamId": 100, "spell1Id": 12}, {"profileIconId": 841, "championId": 58, "summonerName": "\u501a\u7af9\u8f7b\u8bed\u767d\u886b\u5982\u6545", "skinIndex": 2, "bot": false, "spell2Id": 4, "teamId": 100, "spell1Id": 12}, {"profileIconId": 17, "championId": 429, "summonerName": "\u840c\u515c\u515cTcT", "skinIndex": 2, "bot": false, "spell2Id": 7, "teamId": 100, "spell1Id": 4}, {"profileIconId": 914, "championId": 122, "summonerName": "\u91cd\u5e86\u897f\u5357\u5927\u5b66\u6821\u8349", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 100, "spell1Id": 14}, {"profileIconId": 922, "championId": 105, "summonerName": "L4ugh1ng", "skinIndex": 8, "bot": false, "spell2Id": 12, "teamId": 200, "spell1Id": 4}, {"profileIconId": 1, "championId": 28, "summonerName": "\u5170\u4ead\u5e8f", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 200, "spell1Id": 11}, {"profileIconId": 9, "championId": 203, "summonerName": "\u8292\u5e02\u9189\u9999\u56ed\u996d\u5e84", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 200, "spell1Id": 12}, {"profileIconId": 931, "championId": 110, "summonerName": "15\u5c81\u5357\u660c\u4eba\u670d\u4e0d\u670d", "skinIndex": 0, "bot": false, "spell2Id": 4, "teamId": 200, "spell1Id": 7}, {"profileIconId": 28, "championId": 432, "summonerName": "\u6218\u4e36\u65d7TV\u767d\u8272\u98ce\u8f66", "skinIndex": 1, "bot": false, "spell2Id": 14, "teamId": 200, "spell1Id": 4}], "gameTypeConfigId": 2, "gameLength": 5, "bannedChampions": [{"teamId": 100, "championId": 41, "pickTurn": 1}, {"teamId": 200, "championId": 157, "pickTurn": 2}, {"teamId": 100, "championId": 82, "pickTurn": 3}, {"teamId": 200, "championId": 114, "pickTurn": 4}, {"teamId": 100, "championId": 4, "pickTurn": 5}, {"teamId": 200, "championId": 76, "pickTurn": 6}]} obmeta:{"lastChunkId": 14, "gameKey": {"platformId": "HN1", "gameId": 1632443320}, "startTime": "Oct 21, 2015 8:13:37 AM", "endGameKeyFrameId": -1, "gameLength": 0, "keyFrameTimeInterval": 60000, "port": 0, "gameServerAddress": "", "interestScore": 2989, "clientBackFetchingEnabled": false, "clientAddedLag": 30000, "endStartupChunkId": 7, "chunkTimeInterval": 30000, "clientBackFetchingFreq": 1000, "pendingAvailableKeyFrameInfo": [], "decodedEncryptionKey": "", "createTime": "Oct 21, 2015 8:10:24 AM", "featuredGame": true, "gameEnded": false, "encryptionKey": "", "delayTime": 150000, "endGameChunkId": -1, "pendingAvailableChunkInfo": [], "startGameChunkId": 9, "lastKeyFrameId": 3} keyframe_tab:[[1, 9], [2, 11], [3, 13], [4, 15], [5, 17], [6, 19], [7, 21], [8, 23], [9, 25], [10, 27], [11, 29], [12, 31], [13, 33], [14, 35], [15, 37], [16, 39], [17, 41], [18, 43], [19, 45], [20, 47], [21, 49]] chunk_tab:[[1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0], [7, 0], [8, 0], [9, 30018], [10, 30008], [11, 29986], [12, 30007], [13, 30000], [14, 29985], [15, 30019], [16, 29981], [17, 30000], [18, 30008], [19, 30003], [20, 29982], [21, 30012], [22, 30011], [23, 29980], [24, 29993], [25, 30018], [26, 29982], [27, 30018], [28, 29984], [29, 30020], [30, 29984], [31, 30016], [32, 29980], [33, 29994], [34, 30009], [35, 30016], [36, 29997], [37, 29976], [38, 30006], [39, 30008], [40, 29985], [41, 30000], [42, 30027], [43, 29972], [44, 30006], [45, 30016], [46, 29977], [47, 30024], [48, 29990], [49, 29994], [50, 20015]] 不难看出,其中“LOLOB/1.0”为tgp助手本身识别的版本信息;abstract是ob录像版本,以及录像数据加密key存储的基本信息;obmeta对应着getGameMetaData的数据;keyframe_tab跟chunk_tab暂时不知道是什么意思,可稍后再回来看。 ob文件的剩下其它部分,显然就是录像中,每个chunk包的录像数据的了,那它是如何拼装的呢? 腾讯tgp助手下载的ob文件中,录像部分开头的字节流是01 00 01 00 00 35 88 85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98 0c 9d 01 7e d7 b6 6b,回头看看lol游戏进程获取ob录像chunk部分时数据包的内容开头部分 开头的字节流是85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98 0c 9d 01 7e d7 b6 6b 7d,相比之下,重复部分是85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98...,再多找几个录像获取数据的数据包,核对一下 可以确认,lol的ob录像回放协议中,协议头是85 a0 4a 30 1b 30 27 d5,也就是说,tgp下载的ob文件字节流部分,85 a0 4a 30 1b 30 27 d5前面的都不是lol协议的,这开头的01 00 01 00 00 35 88这7个字节为tgp读取识别的协议头部分。最初replay.exe程序读取理解的部分,本身不是lol客户端读取的协议部分。那么,这01 00 01 00 00 35 887个字节分别代表什么含义呢? 那我们开始找85 a0 4a 30 1b 30 27 d5出现的位置即可(关于在数M数据中,查找几个字节所在位置的地方,你最好有比较简便的方法,千万别告诉我你是一行一行看的。) 可以看出如下结果: 第一个在 00001124 部分,前面7个字节是01 00 01 00 00 35 88; 第二个在 000046B3 部分,前面7个字节是02 00 01 00 00 2b 10; 第三个在 000071CA 部分,前面7个字节是01 00 02 00 00 60 48; (wireshark会告诉你当前行所在的位置编号,记得加上所关注字节的偏移位置) 到了这里,根据编程灵(经)感(验),会这么猜想:第二个位置跟第一个位置相差了46B3-1124=358F个位置,也就是3588+7=358F(tgp协议头中,最后两个字节3588 + 协议头长度 7),用第三个跟第二个最比较以确认猜想对不对。71CA-46B3=2B17,即2B10+7符合猜测。 到此,可确认tgp协议头的7个字节中,最后2个字节表示这后续包的长度。但这后续包长度是否仅仅是后面2个字节所标示的,是不是后面3字节,还是4字节?还不能确定。能确定的是,开头的3个字节肯定不会表示后续包长度作用。如果你还是没发现这规律,那可不能怪我了,谁让你当时没有灵(jing)感(yan)呢? 如此一来,可以用更快的方法验证这个猜想,写一段程序,从ob录像文件中,分析tgp协议头,根据后2个字节对应长度的数据包,打印出前面tgp协议头的7字节,再打印后面8个字节(确认是否为85 a0 4a 30 1b 30 27 d5) [1 0 1 0 0 53 136] [133 160 74 48 27 48 39 213 59 235 178 18 77 140 13 98 82 11 21 159 209 122 212] [2 0 1 0 0 43 16] [133 160 74 48 27 48 39 213 54 53 74 75 93 254 127 91 102 147 88 122 160 207 84] [1 0 2 0 0 96 72] [133 160 74 48 27 48 39 213 240 224 50 221 162 250 17 161 77 240 23 76 184 68 252] [2 0 2 0 0 78 80] [133 160 74 48 27 48 39 213 137 33 26 130 55 190 142 64 241 79 180 111 211 4 8] [1 0 3 0 0 125 208] [133 160 74 48 27 48 39 213 156 71 54 190 104 32 173 195 78 15 247 95 154 20 4] [2 0 3 0 0 124 192] [133 160 74 48 27 48 39 213 71 93 96 97 131 246 103 242 117 117 242 122 102 57 252] [1 0 4 0 0 153 128] [133 160 74 48 27 48 39 213 199 1 236 10 13 19 41 186 138 120 76 94 198 235 231] [2 0 4 0 0 126 48] [133 160 74 48 27 48 39 213 161 190 218 133 34 189 202 6 4 210 38 115 68 138 93] [1 0 5 0 0 145 56] [133 160 74 48 27 48 39 213 181 160 40 167 228 7 106 250 239 177 147 143 223 218 189] [2 0 5 0 0 124 208] [133 160 74 48 27 48 39 213 133 89 166 5 196 161 242 20 46 198 139 26 18 52 142] [1 0 6 0 0 180 152] [133 160 74 48 27 48 39 213 199 1 236 10 13 19 41 186 60 43 67 53 87 214 163] [2 0 6 0 0 130 120] [133 160 74 48 27 48 39 213 158 207 13 38 213 94 203 78 214 51 158 92 104 197 112] [1 0 7 0 0 34 32] [133 160 74 48 27 48 39 213 178 213 133 20 42 35 24 186 160 140 25 92 252 210 242] [2 0 7 0 0 140 112] [133 160 74 48 27 48 39 213 46 67 112 101 89 72 41 205 82 117 228 27 204 109 200] [1 0 8 0 0 82 24] [133 160 74 48 27 48 39 213 142 246 116 70 48 41 45 65 120 25 184 155 91 123 174] [2 0 8 0 0 132 128] [133 160 74 48 27 48 39 213 253 10 27 170 202 136 197 234 239 187 196 5 166 215 182] [1 0 9 0 0 132 32] [133 160 74 48 27 48 39 213 63 45 71 158 103 71 129 113 196 148 131 177 29 128 162] [2 0 9 0 0 131 200] [133 160 74 48 27 48 39 213 52 170 14 71 17 205 63 28 76 253 41 134 241 227 200] [1 0 10 0 0 70 0] [133 160 74 48 27 48 39 213 9 8 197 104 10 153 18 70 24 239 30 230 123 22 102] [2 0 10 0 0 151 72] [133 160 74 48 27 48 39 213 208 112 28 134 21 105 39 113 51 90 157 36 215 94 173] [1 0 11 0 0 69 192] [133 160 74 48 27 48 39 213 207 49 23 198 37 84 251 4 153 255 28 239 177 47 13] [2 0 11 0 0 147 232] [133 160 74 48 27 48 39 213 64 115 149 233 120 244 31 210 191 42 252 38 126 32 27] [1 0 12 0 1 2 80] [133 160 74 48 27 48 39 213 186 139 142 5 35 179 166 152 135 234 102 89 109 33 6] [178 161 62 95 113 100 101] [113 58 47 114 56 216 181 49 171 213 150 80 50 222 228 94 34 110 55 170 212 163 177] [2 213 106 162 142 22 91] [8 155 150 15 27 47 191 79 102 112 16 90 168 160 96 244 195 13 72 185 16 118 29] 当tgp协议头的第五个字节不为0时,开始发生了错乱,截取后续包的内容是113 58 47 114 56 216 181 49,并非133 160 74 48 27 48 39 213(即85 a0 4a 30 1b 30 27 d5的十进制),说明tgp最后2字节表示后续包长度不对,应该继续增加,从目前的错误来看,起码是3个字节,即第5、6、7个字节。但如果是3个字节,而第4个字节也一直是0,显然也不合适。我决定将用后面4个字节表示剩余包的长度来检验一次。如下图: 结果都正确的打印了tgp协议头,以及后续8字节的85 a0 4a 30 1b 30 27 d5(对应十进制为:133 160 74 48 27 48 39 213) 至此,tgp协议头的后4个字节已经确认含义了,那么剩余前3个字节中,第一个字节有两种数值01、02,这两个数值的含义也比较好弄明白,这时,我的灵(jing)感(yan),他们分别对应getGameDataChunk、getKeyFrame的两种类型,可以从tgp录像请求的http中加以确认,如下图: 从本地抓取127.0.0.1的通讯包中,找http请求为getGameDataChunk、getKeyFrame的相应包,对照响应body中,字节流跟ob文件分析结果中,对应的是否一致。请求url为getGameDataChunk的,到tgp协议头第一个字节01的字节流中找。请求为getKeyFrame的,到第一个字节02中查找。这里也可以确认无误了。 至此,tgp协议头中,只剩下第2、3个字节不名含义。在汇总录像播放时,请求的所有url,你会发现getGameDataChunk、getKeyFrame请求中,对应的id参数是递增的,如下图: 再根据最初打印的所有tgp协议头7字节内容中,当第1字节一样时,发现第2字节不变,而第3个字节持续递增,这时有灵(jing)感(yan)了吗?第3个字节肯定是url请求中第n个包的含义。第二字节也可能跟第三字节构成uint16类型的总包数的含义。若表示一场游戏中,录像回放系统中,全部数据包分包数只用1字节表示的话,那一共可存储1<<8 -1 也就是255个,数量可能不够,所以,我假设第二、第三字节一起表示录像回放总包的含义。至此,tgp的分包方式中,包头含义已经全部表示明白了。那么,剩下的,就时写个结构体或者map来存储这些字节流即可。 到这里,ob文件分析完成了,对于lol ob server请求的几个url中,只剩下getLastChunkInfo请求没有处理了,你要知道,ob文件最初的几个json字符串中,还剩下chunk_tab、keyframe_tab 聪明的你,这时应该有灵(jing)感(yan)了吧。你来实现实时?什么?还是没灵(jing)感(yan)?再见...... 程序写好后,可以体验一下mac上lol客户端,看国服ob咯,不联网也可以看哦,只要从tgp助手那下载了ob录像文件。 国服客户端,提供观战功能并不能用,每次都是无法观战,或者观战数据加载错误之类的,不提供实时观战功能。ob录像请求的url返回404。 英雄联盟,游戏分为4个主进程 更新进程:LoLpatcher 启动进程:LoLLauncher 客户端(游戏大厅):LolClient 游戏进程:LeagueofLegends 这里已实现mac osx上观看lol ob录像,故可得知mac lol 游戏主进程 可以解析 国服lol 产生的lol协议文件,可大约证明游戏进程是兼容的,甚至一致的。。 更新进程上关系不大,只要保证版本一致即可。启动进程跟客户端两个进程实现了登陆认证功能,要做的,是实现这国服qq登陆认证绑定,也就是说mac osx上玩国服lol 理论上可行....要不,你来试试? 代码开源: 2015-10-27 +3 7 0
不要在linux上启用net.ipv4.tcp_tw_recycle参数 本文为翻译英文BLOG《Coping with the TCP TIME-WAIT state on busy Linux servers》,但并非完整的翻译,译者CFC4N对原文理解后,进行了调整,增加了相关论点论据,跟原文稍有不同。翻译的目的,是为了加深自己知识点的记忆,以及分享给其他朋友,或许对他们也有帮助。文章比较长,没耐心请点关闭。 不要启用 net.ipv4.tcp_tw_recycle linux 内核文档中,对net.ipv4.tcp_tw_recycle的描述并不是很明确。 tcp_tw_recycle (Boolean; default: disabled; since Linux 2.4)[译者注:来自linux man tcp的描述] Enable fast recycling of TIME-WAIT sockets. Enabling this option is not recommended since this causes problems when working with NAT (Network Address Translation). 启用TIME-WAIT状态sockets的快速回收,这个选项不推荐启用。在NAT(Network Address Translation)网络下,会导致大量的TCP连接建立错误。 与其功能相似的参数net.ipv4.tcp_tw_reuse,手册里稍微有点描述,如下: tcp_tw_reuse (Boolean; default: disabled; since Linux 2.4.19/2.6) Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol viewpoint. It should not be changed without advice/request of technical experts. //从协议设计上来看,对于TIME-WAIT状态的sockets重用到新的TCP连接上来说,是安全的。(用于客户端时的配置) 这里的注释说明非常的少,我们发现,网上很多linux参数调整指南都建议把这些参数net.ipv4.tcp_tw_recycle 设置1「启用」,用于快速减少在TIME-WAIT状态TCP连接数。 但是,在TCP(7)手册中,参数net.ipv4.tcp_tw_recycle 非常蛋疼,尤其是在普通用户家中,有多台设备,或者网吧、公司等多台设备,共用同一个NAT设备环境下,TW回收选项是很有问题的面向公共服务器作为它不会把手连接两台不同的计算机上,这问题很难发现,无从下手。 Enable fast recycling of TIME-WAIT sockets. Enabling this option is not recommended since this causes problems when working with NAT (Network Address Translation). 启用TIME-WAIT状态sockets的快速回收,这个选项不推荐启用。在NAT(Network Address Translation)网络下,会导致大量的TCP连接建立错误。如果没有技术大神的指点的话,千万不要去改动他。 下文将给予更详细的解释,希望可以纠正互联网上错误的观点,尤其是转载比较多的内容,搜索时,往往排在前面,使用者往往接触到的都是不严谨的或者是错误的知识点。 正如此文,在 net.ipv4.tcp_tw_recycle 控制参数中,尽管很多地方写的是ipv4,但对ipv6同样实用。此外,我们这里聊的是Linux TCP协议栈,在linux上可能会受到Netfilter影响,稍微有差异。 关于TCP连接的TIME-WAIT状态,它是为何而生,存在的意义是什么? 让我们回忆一下,什么是TCP TIME-WAIT状态?如下图 这图中的流程不是很好理解,再看一张流程更清晰的图 当TCP连接关闭之前,首先发起关闭的一方会进入TIME-WAIT状态,另一方可以快速回收连接。 可以用ss -tan来查看TCP 连接的当前状态 TIME-WAIT状态的作用 对于TIME-WAIT状态来说,有两个作用 一、人尽皆知的是,防止上一个TCP连接的延迟的数据包(发起关闭,但关闭没完成),被接收后,影响到新的TCP连接。(唯一连接确认方式为四元组:源IP地址、目的IP地址、源端口、目的端口),包的序列号也有一定作用,会减少问题发生的几率,但无法完全避免。尤其是较大接收windows size的快速(回收)连接。RFC1137解释了当TIME-WAIT状态不足时将会发生什么。如果TIME-WAIT状态连接没有被快速回收,会避免什么问题呢?请看下面的例子: 缩短TIME-WAIT的时间后,延迟的TCP 包会被新建立的TCP连接接收。 二、另外一个作用是,当最后一个ACK丢失时,远程连接进入LAST-ACK状态,它可以确保远程已经关闭当前TCP连接。如果没有TIME-WAIT状态,当远程仍认为这个连接是有效的,则会继续与其通讯,导致这个连接会被重新打开。当远程收到一个SYN 时,会回复一个RST包,因为这SEQ不对,那么新的连接将无法建立成功,报错终止。 如果远程因为最后一个ACK包丢失,导致停留在LAST-ACK状态,将影响新建立具有相同四元组的TCP连接。 RFC 793中强调TIME-WAIT状态必须是两倍的MSL时间(max segment lifetime),在linux上,这个限制时间无法调整,写死为1分钟了,定义在include/net/tcp.h 曾有人提议将TCP TIME-WAIT时间改为一个可以自定义配置的参数,但被拒绝了,其实,这对TCP规范,对TIME-WAIT来说,是利大于弊的。 那么问题来了 我们来看下,为什么这个状态能影响到一个处理大量连接的服务器,从下面三个方面来说: 新老连接(相同四元组)在TCP连接表中的slot复用避免 内核中,socket结构体的内存占用 额外的CPU开销 而ss -tan state time-wait|wc -l的结果,并不能说明这些问题。 Connection table slot连接表槽 处于TIME-WAIT状态的TCP连接,在链接表槽中存活1分钟,意味着另一个相同四元组(源地址,源端口,目标地址,目标端口)的连接不能出现,也就是说新的TCP(相同四元组)连接无法建立。 对于web服务器来说,目标地址、目标端口都是固定值。如果web服务器是在L7层的负载均衡后面,那么源地址更是固定值。在LINUX上,作为客户端时,客户端端口默认可分配的数量是3W个(可以在参数net.ipv4.up_local_port_range上调整)。 这意味着,在web服务器跟负载均衡服务器之间,每分钟只有3W个端口是处于established状态,也就大约500连接每秒。 如果TIME-WAIT状态的socket出现在客户端,那这个问题很容易被发现。调用connect()函数会返回EADDRNOTAVAIL,程序也会记录相关的错误到日志。 如果TIME-WATI状态的socket出现在服务端,问题会非常复杂,因为这里并没有日志记录,也没有计数器参考。不过,可以列出服务器上当前所有四元组连接的数量来确认 解决办法是,增加四元组的范围,这有很多方法去实现。(以下建议的顺序,实施难度从小到大排列) 修改net.ipv4.ip_local_port_range参数,增加客户端端口可用范围。 增加服务端端口,多监听一些端口,比如81、82、83这些,web服务器前有负载均衡,对用户友好。 增加客户端IP,尤其是作为负载均衡服务器时,使用更多IP去跟后端的web服务器通讯。 增加服务端IP。 当然了,最后的办法是调整net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle。但不到万不得已,千万别这么做,稍后再讲。 内存 保持大量的连接时,当多为每一连接多保留1分钟,就会多消耗一些服务器的内存。举个栗子,如果服务器每秒处理了1W个新的TCP连接,那么服务器将会存货1W/s*60s = 60W个TIME-WAIT状态的TCP连接,那这将会占用多大的内存么?别担心,少年,没那么多。 首先,从应用的角度来看,一个TIME-WAIT状态的socket不会消耗任何内存:socket已经关了。在内核中,TIME-WAIT状态的socket,对于三种不同的作用,有三个不同的结构。 一、“TCP established hash table”的连接存储哈希表(包括其他非established状态的连接),当有新的数据包发来时,是用来定位查找存活状态的连接的。 该哈希表的bucket都包括在TIME-WAIT连接列表以及正在活跃的连接列表中(netstat -antp命令的结果中,没PID的TIME_WAIT状态连接,跟有PID的活跃连接两种)。 该哈希表的大小,取决于操作系统内存大小。在系统引导时,会打印出来,dmesg日志中可以看到。 这个数值,有可能被kernel启动参数thash_entries(设置TCP连接哈希表的最大数目)的改动而将其覆盖。 在TIME-WAIT状态连接列表中,每一个元素都是一个tcp_timewait_sock结构体,其他状态的连接都是tcp_sock结构体。 二、“death row”连接列表,是用来终止TIME-WAIT状态的连接的,这会在他们过期之前,开始申请。它占用的内存空间,跟在连接哈希表中的一样。这个结构体hlist_node tw_death_node是inet_timewait_sock的一个成员,如上代码的倒数第二行。 三、端口绑定的哈希表,用来存储绑定端口跟其他参数,用来确保当前端口没有被使用的,比如在listen监听时的指定端口,或者连接其他socket时,系统动态分配的端口。该哈希表的大小跟连接哈希表大小一样。 每个元素都是inet_bind_socket结构体。每个绑定的端口都会有一个元素。对于web服务器来说,它绑定的是80端口,其TIME-WAIT连接都是共享同一个entry的。另外,连接到远程服务器的本地连接,他们的端口都是随机分配的,并不共享其entry。 所以,我们只关心结构体tcp_timewait_sock跟结构体inet_bind_socket所占用的空间大小。每一个连到远程,或远程连到本地的一个TIME-WAIT状态的连接,都有一个tcp_timewait_sock结构体。还有个结构体inet_bind_socket,只会在连到远程的连接会存在,远程连过来的连接没这个结构体。 tcp_timewait_sock结构体的大小只有168 bytes,inet_bind_socket结构体为48bytes: 所以,当服务器上有4W个连进来的连接进入TIME-WAIT状态时,才用了10MB不到的内存。如果服务器上有4W个连接到远程的连接进入TIME-WAIT状态时,才用了2.5MB的内存。再来看下slabtop的结果,这里测试数据是5W个TIME-WAIT状态的连接结果,其中4.5W是连接到远程的连接: 命令执行结果原样输出,一个字符都没动。TIME-WAIT状态的连接占用内存非常的小。如果你的服务器上要处理每秒成千上万的新建TCP连接,你可能需要多一点的内存才能 正确无误的跟客户端做数据通信。但TIME-WAIT状态连接的内存占用,简直可以无视。 CPU 在CPU这边,查找一个空闲端口的操作,还是蛮珍贵的。这由inet_csk_get_port() 函数,加锁,遍历整个空闲端口列表实现。这个哈希表里条目数量大通常不是问题,如果服务器上存在大量连接到远程TIME-WAIT状态的连接(比如FPM连redis、memcache之类),都会同享相同的profile,这个特性会非常快的按照顺序找到一个新的空闲端口。 其他解决办法 如果你读了上面的章节后,仍对TIME-WAIT状态的连接存有疑问,那么接着看吧: 禁用socket延迟关闭「译者注1:以ubuntu 12.04为例,linger结构体定义在:/usr/src/linux-headers-3.2.0-23/include/linux/socket.h」 禁用net.ipv4.tcp_tw_reuse 禁用net.ipv4.tcp_tw_recycle 当close被调用时,SOCKET需要延迟关闭(lingering),在内核buffers中的残留数据将会发送到远程地址,同时,socket会切换到TIME-WAIT状态。如果禁用此选项,则调用close之后,底层也会关闭,不会将Buffers中残留数据未发送的数据继续发送。 不过呢,应用程序可以选择禁用socket lingering延迟关闭行为。关于socket lingering 延迟关闭,下面两个行为简单描述一下: 第一种情况,close函数后,并不会直接终止该四元组连接序号,而是在buffers任何残留数据都会被丢弃。该TCP连接将会收到一个RST的关闭信号,之后,服务端将立刻销毁该(四元组)连接。 在这种做法中,不会再有TIME-WAIT状态的SOCKET出现。第二种情况,如果当调用close函数后,socket发送buffer中仍然有残留数据,此进程将会休眠,直到所有数据都发送完成并确认,或者所配置的linger计时器过期了。非阻塞socket可以设置不休眠。如上,这些过程都都在底层发生,这个机制确保残留数据在配置的超时时间内都发送出去。 如果数据正常发送出去,close包也正常发送,那么将会转换为TIME-WAIT状态。其他异常情况下,客户端将会收到RST的连接关闭信号,同时,服务端残留数据会被丢弃。 这里的两种情况,禁用socket linger延迟关闭不是万金油。但在HAproxy,Nginx(反代)场景中,在TCP协议上层的应用上(比如HTTP),比较合适。同样,也有很多无可厚非的理由不能禁用它。 net.ipv4.tcp_tw_reuse TIME-WAIT状态是为了防止不相关的延迟请求包被接受。但在某些特定条件下,很有可能出现,新建立的TCP连接请求包,被老连接(同样的四元组,暂时还是TIME-WAIT状态,回收中)的连接误处理。RFC 1323 实现了TCP拓展规范,以保证网络繁忙状态下的高可用。除此之外,另外,它定义了一个新的TCP选项--两个四字节的timestamp fields时间戳字段,第一个是TCP发送方的当前时钟时间戳,而第二个是从远程主机接收到的最新时间戳。 启用net.ipv4.tcp_tw_reuse后,如果新的时间戳,比以前存储的时间戳更大,那么linux将会从TIME-WAIT状态的存活连接中,选取一个,重新分配给新的连接出去的TCP连接。 连出的TIME-WAIT状态连接,仅仅1秒后就可以被重用了。 如何确通讯安全性? TIME-WAIT的第一个作用是避免新的连接(不相关的)接收到重复的数据包。由于使用了时间戳,重复的数据包会因为timestamp过期而丢弃。 第二个作用是确保远程端(远程的不一定是服务端,有可能,对于服务器来说,远程的是客户端,我这里就用远程端来代替)是不是在LAST-ACK状态。因为有可能丢ACK包丢。远程端会重发FIN包,直到 放弃(连接断开) 等到ACK包 收到RST包 如果 FIN包接及时收到,本地端依然是TIME-WAIT状态,同时,ACK包也会发送出去。 一旦新的连接替换了TIME-WAIT的entry,新连接的SYN包会被忽略掉(这得感谢timestramps),也不会应答RST包,但会重传FIN包。 FIN包将会收到一个RST包的应答(因为本地连接是SYN-SENT状态),这会让远程端跳过LAST-ACK状态。 最初的SYN包将会在1秒后重新发送,然后完成连接的建立。看起来没有啥错误发生,只是延迟了一下。 另外,当连接被重用时,TWrecycled计数器会增加的。「译者注:见/proc/net/netstat 中TWrecycled的值」 net.ipv4.tcp_tw_recycle 这种机制也依赖时间戳选项,这也会影响到所有连接进来和连接出去的连接。「译者注:linux上tcp_timestamps默认开启」 TIME-WAIT状态计划更早的过期:它将会在超时重发(RTO)间隔后移除(底层会根据当前连接的延迟状况根据RTT来计算RTO值,上篇《PHP-FPM中backlog参数变更的一些思考》也有提到过,比较复杂的算法)。可以执行ss指令,获取当前存活的TCP连接状态,查看这些数据。「译者注:linux指令ss的结果中rto,rtt值单位均为ms」 Linux将会放弃所有来自远程端的timestramp时间戳小于上次记录的时间戳(也是远程端发来的)的任何数据包。除非TIME-WAIT状态已经过期。 当远程端主机HOST处于NAT网络中时,时间戳在一分钟之内(MSL时间间隔)将禁止了NAT网络后面,除了这台主机以外的其他任何主机连接,因为他们都有各自CPU CLOCK,各自的时间戳。这会导致很多疑难杂症,很难去排查,建议你禁用这个选项。另外,对方上TCP的LAST-ACK状态是体现本机net.ipv4.tcp_tw_recycle的最好数据。 总结 最合适的解决方案是增加更多的四元组数目,比如,服务器可用端口,或服务器IP,让服务器能容纳足够多的TIME-WAIT状态连接。在我们常见的互联网架构中(NGINX反代跟NGINX,NGINX跟FPM,FPM跟redis、mysql、memcache等),减少TIME-WAIT状态的TCP连接,最有效的是使用长连接,不要用短连接,尤其是负载均衡跟web服务器之间。尤其是链家事件中的PHP连不上redis。 在服务端,不要启用net.ipv4.tcp_tw_recycle,除非你能确保你的服务器网络环境不是NAT。在服务端上启用net.ipv4.tw_reuse对于连接进来的TCP连接来说,并没有任何卵用。 在客户端(尤其是服务器上,某服务以客户端形式运行时,比如上面提到的nginx反代,连接着redis、mysql的FPM等等)上启用net.ipv4.tcp_tw_reuse,还算稍微安全的解决TIME-WAIT的方案。再开启net.ipv4.tcp_tw_recycle的话,对客户端(或以客户端形式)的回收,也没有什么卵用,反而会发生很多诡异的事情(尤其是FPM这种服务器上,相对nginx是服务端,相对redis是客户端)。 最后引用一下W. Richard Stevens在《UNIX网络编程》的一句话 The TIME_WAIT state is our friend and is there to help us (i.e., to let old duplicate segments expire in the network). Instead of trying to avoid the state, we should understand it. 译者:存在即合理,勇敢面对,而不是逃避。 译者注: 译者根据原文做了翻译,并增加了相关论点论据,跟原文稍有不同。 linux的netstat命令ss命令的结果中,前者是TIME_WAIT之类,分个字符是下划线,而后者是TIME-WAIT的中划线。 残酷的案例: Clients behind NAT/Stateful FW will get dropped, 99.99999999% of time should never be enabledTuning TCP and nginx on ec2中30页内容 参考资料: Tuning TCP and nginx on ec2 -p30 TIME_WAIT and its design implications for protocols and scalable client server systems tcp_tw_recycle和tcp_timestamps导致connect失败问题 记一次TIME_WAIT网络故障 打开tcp_tw_recycle引起的一个问题 Transmission Control Protocol 2017年10月更新 在linux 4.1内核中,net.ipv4.tcp_tw_recycle参数将被移除。net.ipv4.tcp_tw_recycle has been removed from Linux 4.12. The tcp_tw_recycle was already broken for connections behind NAT, since the per-destination timestamp is not monotonically increasing for multiple machines behind a single destination address. After the randomization of TCP timestamp offsets in commit 8a5bd45f6616 (tcp: randomize tcp timestamp offsets for each connection), the tcp_tw_recycle is broken for all types of connections for the same reason: the timestamps received from a single machine is not monotonically increasing, anymore. Remove tcp_tw_recycle, since it is not functional. Also, remove the PAWSPassive SNMP counter since it is only used for tcp_tw_recycle, and simplify tcp_v4_route_req and tcp_v6_route_req since the strict argument is only set when tcp_tw_recycle is enabled. 2015-07-27 +7 11 0
ububtu server 14.04上,PHP5-FPM服务的启动方式变更 在一位前辈微博上看到,他总是给自己找点事做,让自己忙碌起来,严于律己。我甚有感触,相比之下自惭形秽,一直做不到,这应该就是大神跟我等小菜之间的差距吧。如今,事情主动找到我,而且是一而再再而三的找上来,我再去逃避,再不认真去面对这些问题,实在说不过去。 朋友在群里问了问题,ubuntu server 14.04版本,apt-get install的PHP5-FPM,自己编译了phpredis,却发现始终没成功加载phpredis这个扩展。 ubuntu@shnj-b-batch-30-222:~$ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 14.04.2 LTS Release: 14.04 Codename: trusty 以及他的操作方式 而结果,却无任何回显。ps aux也看到所有FPM原来进程都还在,尝试sudo /etc/init.d/php5-fpm stop也无法停止掉进程,也无回显。无奈之下killall php5-fpmkill掉了所有FPM进程。当他再尝试sudo /etc/init.d/php5-fpm start,却发现PHP5-FPM启动不起来了。 那么问题来了,为什么sudo /etc/init.d/php5-fpm stop无法停止掉PHP5-FPM,是SIGNAL信号没传到FPM那边吗?为什么KILL之后不能重新启动了?是php加载了拓展时遇到意外,进程崩溃退出了吗?如何排查?群内有朋友建议上gdb来查看是不是php加载扩展出现意外了。但我觉得,php加载拓展前,还有很多事情要做,php加载扩展是/etc/init.d/php5-fpm 脚本执行/usr/sbin/php5-fpm去启动的,在执行之前,还有很多事情去做,比如获取系统shell变量,获取获取系统其他配置。执行后,还会去去读ini配置,读取拓展的so文件等等。我想先看下程序大约终止之前的文件操作走到了哪里。故我决定先用strace看下。 从strace结果可以看到,程序/etc/init.d/php5-fpm依次访问(open\stat)了 /usr/sbin/php5-fpm /etc/default/rcS /lib/init/vars.sh /lib/lsb/init-functions /sbin/initctl 之后,就以+++ exited with 1 +++结束了。并没有执行/usr/sbin/php5-fpm来操作对应的pidfile,执行stop指令。下面看下/etc/init.d/php5-fpm里如何写 大约可以看出,脚本执行到 init_is_upstart() 函数判断后,就退出了,exit 1 。从代码中文件访问(这里包括open、stat)顺序,以及退出是exit(1)的错误产生后退出。这也可以在执行echo $?来确认。既然知道这里退出的,那么修复方法就很简单了,注释掉 if init_is_upstart; then \ exit 1 \ fi这三行,就好了。 注释掉/etc/init.d/php5-fpm的27、28、29行之后,尝试/etc/init.d/php5-fpm start\stop\restart“果然”均正常... 注释掉/etc/init.d/php5-fpm的27、28、29行之后,就可以了???? 为什么代码里要加上那句判断,为什么判断后就退出了,不执行FPM的相关操作了?这么改之后,虽然FPM的启动、重启、停止都声场,但总觉得哪里不对,回到虚拟机上,我多测试了几遍,出现了更奇怪的事情。在上面的程序启动后,我随手敲了service php5-fpm stop返回了stop: Unknown instance:,嗯?service服务管理程序不能管理/etc/init.d/php5-fpm启动的程序?我记得是可以的,又立刻到服务器上做了测试,服务器是UBUNTU server 12.04 的,测试结果为他们可以互相管理对方启动的进程。 问题又来了,为什么ubuntu 12.04上都正常,而在ubuntu 14.04上不可以呢? 是不是我注释掉的那三行代码导致的?系统更新,变更了哪些内容?为此,我认真的看了service脚本的代码。 解释这段代码时,$SERVICE以php5-fpm为例,代码中写着,若系统中存在/etc/init/${SERVICE}.conf、同时initctl存在,且initctl version结果中包含upstart字符串,则执行start\stop\reload ${SERVICE} ${OPTIONS}。若不符合条件,则执行$SERVICEDIR/$SERVICE脚本来管理该程序。这里根据当前操作系统当前使用的init系统,来选择使用upstart还是sysvinit形式来管理服务。 同时,service在手册里的注释如下: DESCRIPTION service runs a System V init script or upstart job in as predictable environment as possible, removing most environment variables and with current working directory set to /. The SCRIPT parameter specifies a System V init script, located in /etc/init.d/SCRIPT, or the name of an upstart job in /etc/init. The existence of an upstart job of the same name as a script in /etc/init.d will cause the upstart job to take precedence over the init.d script. The supported values of COMMAND depend on the invoked script, service passes COMMAND and OPTIONS to the init script unmodified. For upstart jobs, start, stop, status, are passed through to their upstart equivilents. Restart will call the upstart 'stop' for the job, followed immediately by the 'start', and will exit with the return code of the start command. All scripts should support at least the start and stop commands. As a special case, if COMMAND is --full-restart, the script is run twice, first with the stop command, then with the start command. This option has no effect on upstart jobs. service --status-all runs all init scripts, in alphabetical order, with the status command. This option only calls status for sysvinit jobs, upstart jobs can be queried in a similar manner with initctl list'. FILES /etc/init.d The directory containing System V init scripts. /etc/init The directory containing upstart jobs. 根据man手册的解释,upstart是ubuntu特有的init管理系统,最初也是使用system v init系统来管理服务,现在debian好像也用了。system v init是最初大多数类unix系统上的init系统。后来system v init系统的性能问题,对热插拔的USB支持问题等诸多问题,大部分类unix系统改为了systemd。 但大多数linux都对system v init做了兼容。所以,service脚本为了支持两种init系统,做了封装,根据当前系统的init系统,来选择执行响应的操作。在ubuntu上, upstart系统的程序是initctl,而且initctl start\stop\restart\reload可简写为start\stop\restart\reload。 system v init系统的程序是start-stop-daemon(centos 上是daemon程序),service是对两种init系统执行的代码封装。/etc/init.d是System V init系统脚本的执行文件目录。/etc/init是upstart的配置文件目录。我特意对比虚拟机跟服务器上的service脚本的区别,只发现对upstart的判断更严格了,并没有太大区别...不过,我发现了一件奇葩的事情...就是service php5-fpm status指令跟service --status-all|php5-fpm的结果(在ubuntu 14.04上)并不一样,前者是upstart管理的服务状态数据,即upstart init系统的 status 指令来查看php5-fpm状态的;而后者是system v init的结果,即从$SERVICEDIR/$SERVICE下的脚本程序获取状态的,这...... 但是。。。也就是说知道service脚本的封装操作后,然而,这并没有解决我刚刚的疑问「为什么ubuntu 12.04上service脚本跟/etc/init.d/php5-fpm可以相互管理,而14.04上不可以?」,尽管我知道system v init跟upstart不能对同一个服务的状态互通,但为什么12.04上却可以?这到底是为什么呢?在我做了很多测试(走了很多弯路)之后,决定去服务器上再检查一遍环境。在我写出检查结果之前,我说下走弯路期间的发现吧。 之前提到过一个错stop: Unknown instance:,这也比较好理解,php5-fpm程序是用(注释那3行代码后)/etc/init.d/php5-fpm启动的,即system v init管理的。而使用service php5-fpm stop时,显然不能获取到PHP5-FPM服务的状态的,故返回stop: Unknown instance:。如果我反过来,使用system v init来关闭upstart启动的php5-fpm会发生什么呢? 细心的朋友会发现,原来的FPM主进程已经不存在,残留了2个子进程(父进程为1,是因为其父进程意外退出,被PID 为1 的init进程接管),而且, 从最后剩余的pid来看,也不是原来的子进程了。 同时,原来的pid file 跟 unix domain socket 监听文件都也残留....可以看出start-stop-daemon程序可以关闭原来的PHP5-FPM进程(直接执行kill指令,发送SIGTERM信号,当然可以关闭其他进程)。为什么会出现进程残留,原来子进程ID都不一样的问题。查看/var/log/upstart/php5-fpm.log中,有这么几条日志 system v init系统的start-stop-daemon调用kill指令,发送SIGTERM信号给FPM进程,让其退出。然而upstart发现进程退出,进行保护又启新的进程,然后又被start-stop-daemon 发送SIGKILL信号杀了。然后子进程还存活着...同时,原来的pid file 跟 unix domain socket 监听文件都也残留....也导致后来再启动时,发现sock文件依旧存在,无法再次启动。 回到刚刚的话题上,我去重新确认了配置,在ubuntu 12.04上,/lib/lsb/init-functions中,并没有init_is_upstart函数的定义。最主要的是,在/etc/init/目录下,根本没有/etc/init/php5-fpm.conf这文件的存在,也就是说,ubuntu 12.04上 是以system v init 来管理php5-fpm的。。。知道真相的我,眼泪都要留下来。。。 好了,最初的修改/etc/init.d/php5-fpm注释掉的3行代码,显然要还原。解决PHP5-FPM(包括但不限于)启动、停止、重启的方式,就用系统封装好的service吧。 总结本次获得的新技能 推荐使用service来管理服务,让service根据当前操作系统,自己确定选择用upstart、sysvinit、systemd来管理程序,而非我们主观上的认定。 ubuntu 1404 上 service XXXX status 跟 service --status-all|grep XXXX 的获取XXXX服务状态的方式不一样。前者是upstart,后者是sysvinit. UBUNTU 1404 使用sysvinit方式(/etc/init.d/php5-fpm stop)来关闭upstart启动(start php5-fpm或service php5-fpm start)的FPM进程时,会造成FPM进程无法全部杀死,直接发送SIGTERM信号给FPM主进程,然后upstart监控发现原来PID的进程不存在了,立刻创建。然后sysvinit(start-stop-daemon进程)二次检测,又继续kill。新启动的FPM子进程的所属主进程被kill了,他们的父ID没了,就被PID为1的init进程接管...发现类似的情况时,多数是主进程被意外终止掉了。 类似案例: upstart script ignores some php-fpm.conf parameters, cannot be bypassed in preference of SystemV init script. Ubuntu14.04重启网卡不生效 对于这些案例,相应的程序提供了一定的信息输出,比如“Stopping or restarting the networking job is not supported. ”起码告诉我们说不支持这种方式了,FPM应该echo一句话,告诉使用者不支持这种方式管理此服务了,这样,大家都方便了。 参考资料: 浅析 Linux 初始化 init 系统,第 1 部分: sysvinit 浅析 Linux 初始化 init 系统,第 2 部分: UpStart 浅析 Linux 初始化 init 系统,第 3 部分: systemd Operating system service management Linux init 2015-07-08 3 0
TCP SOCKET中backlog参数的用途是什么? 在前年时,业务中遇到好多次因为PHP-FPM的backlog参数引发的性能问题,一直想去详细研究一番,还特意在2013年总结里提到这事《为何PHP5.5.6中fpm backlog Changed default listen() backlog to 65535》,然而,我稍于懒惰,一拖再拖,直至今日,才动脑去想,动笔去写。 2013年12月14发布的PHP5.5.6中,changelog中有一条变更, FPM: Changed default listen() backlog to 65535. 这条改动,是在10月28日改的,见increase backlog to the highest value everywhere It makes no sense to use -1 for *BSD (which is the highest value there) and still use 128 for Linux. Lets raise it right to up the limit and let the people lower it if they think that 3.5Mb is too much for a process. IMO this is better than silently dropping connections. patch提交者认为,提高backlog数量,哪怕出现timeout之类错误,也比因为backlog满了之后,悄悄的忽略TCP SYN的请求要好。 当我最近开始想好好了解一下backlog的细节问题时,发现fpm的默认backlog已经不是65535了,现在是511了。(没写在changelog中,你没注意到,这不怪你。)我翻阅了github提交记录,找到这次改动,是在2014年7月22日,Set FPM_BACKLOG_DEFAULT to 511 It is too large for php-fpm to set the listen backlog to 65535. It is realy NOT a good idea to clog the accept queue especially when the client or nginx has a timeout for this connection. Assume that the php-fpm qps is 5000. It will take 13s to completely consume the 65535 backloged connections. The connection maybe already have been closed cause of timeout of nginx or clients. So when we accept the 65535th socket, we get a broken pipe. Even worse, if hundreds of php-fpm processes get a closed connection they are just wasting time and resouces to run a heavy task and finally get error when writing to the closed connection(error: Broken Pipe). The really max accept queue size will be backlog+1(ie, 512 here). We take 511 which is the same as nginx and redis. 其中理由是“backlog值为65535太大了。会导致前面的nginx(或者其他客户端)超时”,而且提交者举例计算了一下,假设FPM的QPS为5000,那么65535个请求全部处理完需要13s的样子。但前端的nginx(或其他客户端)已经等待超时,关闭了这个连接。当FPM处理完之后,再往这个SOCKET ID 写数据时,却发现连接已关闭,得到的是“error: Broken Pipe”,在nginx、redis、apache里,默认的backlog值兜是511。故这里也建议改为511。(后来发现,此patch提交者,是360团队「基础架构快报」中《TCP三次握手之backlog》的作者shafreeck) 对于backlog的参数的含义,在linux kernel手册中也给了注释说明listen - listen for connections on a socket #include /* See NOTES */ #include int listen(int sockfd, int backlog); Description listen() marks the socket referred to by sockfd as a passive socket, that is, as a socket that will be used to accept incoming connection requests using accept(2). The sockfd argument is a file descriptor that refers to a socket of type SOCK_STREAM or SOCK_SEQPACKET. The backlog argument defines the maximum length to which the queue of pending connections for sockfd may grow. If a connection request arrives when the queue is full, the client may receive an error with an indication of ECONNREFUSED or, if the underlying protocol supports retransmission, the request may be ignored so that a later reattempt at connection succeeds. Notes To accept connections, the following steps are performed: 1. A socket is created with socket(2). 2. The socket is bound to a local address using bind(2), so that other sockets may be connect(2)ed to it. 3. A willingness to accept incoming connections and a queue limit for incoming connections are specified with listen(). 4. Connections are accepted with accept(2). POSIX.1-2001 does not require the inclusion of , and this header file is not required on Linux. However, some historical (BSD) implementations required this header file, and portable applications are probably wise to include it. The behavior of the backlog argument on TCP sockets changed with Linux 2.2. Now it specifies the queue length for completely established sockets waiting to be accepted, instead of the number of incomplete connection requests. The maximum length of the queue for incomplete sockets can be set using /proc/sys/net/ipv4/tcp_max_syn_backlog. When syncookies are enabled there is no logical maximum length and this setting is ignored. See tcp(7) for more information. If the backlog argument is greater than the value in /proc/sys/net/core/somaxconn, then it is silently truncated to that value; the default value in this file is 128. In kernels before 2.4.25, this limit was a hard coded value, SOMAXCONN, with the value 128. backlog的定义是已连接但未进行accept处理的SOCKET队列大小,已是(并非syn的SOCKET队列)。如果这个队列满了,将会发送一个ECONNREFUSED错误信息给到客户端,即 linux 头文件 /usr/include/asm-generic/errno.h中定义的“Connection refused”,(如果协议不支持重传,该请求会被忽略)如下: 在linux 2.2以前,backlog大小包括了半连接状态和全连接状态两种队列大小。linux 2.2以后,分离为两个backlog来分别限制半连接SYN_RCVD状态的未完成连接队列大小跟全连接ESTABLISHED状态的已完成连接队列大小。互联网上常见的TCP SYN FLOOD恶意DOS攻击方式就是用/proc/sys/net/ipv4/tcp_max_syn_backlog来控制的,可参见《TCP洪水攻击(SYN Flood)的诊断和处理》。 在使用listen函数时,内核会根据传入参数的backlog跟系统配置参数/proc/sys/net/core/somaxconn中,二者取最小值,作为“ESTABLISHED状态之后,完成TCP连接,等待服务程序ACCEPT”的队列大小。在kernel 2.4.25之前,是写死在代码常量SOMAXCONN,默认值是128。在kernel 2.4.25之后,在配置文件/proc/sys/net/core/somaxconn (即 /etc/sysctl.conf 之类 )中可以修改。我稍微整理了流程图,如下: 如图,服务端收到客户端的syn请求后,将这个请求放入syns queue中,然后服务器端回复syn+ack给客户端,等收到客户端的ack后,将此连接放入accept queue。 大约了解其参数代表意义之后,我稍微测试了一番,并抓去了部分数据,首先确认系统默认参数 在FPM的配置中,listen.backlog值默认为511,而如上结果中看到的Send-Q却是128,可见确实是以/proc/sys/net/core/somaxconn跟listen参数的最小值作为backlog的值。 apache ab这边的结果中,非2xx的http响应有271个,在NGINX日志数据如下: 从nginx结果中看出,本次压测总请求数为10000。http 200响应数量9729个;http 502 响应数量186个;http 504响应数量未85个;即非2xx响应总数为502+504总数,为271个。同时,也跟error.log中数据吻合。同时,也跟TCP数据包中的RST包数量吻合。 在nginx error中,错误号为111,错误信息为“Connection refused”的有186条,对应着所有http 502响应错误的请求;错误号为110,错误信息为“Connection timed out”的有85条,对应着所有http 504响应错误的请求。在linux errno.h头文件定义中,错误号111对应着ECONNREFUSED;错误号110对应着ETIMEDOUT。linux man手册里,对listen参数的说明中,也提到,若client连不上server时,会报告ECONNREFUSED的错。 Nginx error日志中的详细错误如下: 在压测的时候,我用tcpdump抓了通讯包,配合通信包的数据,也可以看出,当backlog为某128时,accept queue队列塞满后,TCP建立的三次握手完成,连接进入ESTABLISHED状态,客户端(nginx)发送给PHP-FPM的数据,FPM处理不过来,没有调用accept将其从accept quque队列取出时,那么就没有ACK包返回给客户端nginx,nginx那边根据TCP 重传机制会再次发从尝试...报了“111: Connection refused”错。当SYNS QUEUE满了时,TCPDUMP的结果如下,不停重传SYN包。 对于已经调用accept函数,从accept queue取出,读取其数据的TCP连接,由于FPM本身处理较慢,以至于NGINX等待时间过久,直接终止了该fastcgi请求,返回“110: Connection timed out”。当FPM处理完成后,往FD里写数据时,发现前端的nginx已经断开连接了,就报了“Write broken pipe”。当ACCEPT QUEUE满了时,TCPDUMP的结果如下,不停重传PSH SCK包。(别问我TCP RTO重传的机制,太复杂了,太深奥了 、TCP的定时器系列 — 超时重传定时器) 对于这些结论,我尝试搜了很多资料,后来在360公司的「基础架构快报」中也看到了他们的研究资料《TCP三次握手之backlog》,也验证了我的结论。 关于ACCEPT QUEUE满了之后的表现问题,早上IM鑫爷给我指出几个错误,感谢批评及指导,在这里,我把这个问题再详细描述一下。如上图所示 NO.515 client发SYN到server,我的seq是0,消息包内容长度为0. (这里的seq并非真正的0,而是wireshark为了显示更好阅读,使用了Relative SeqNum相对序号) NO.516 server回SYN ACK给client,我的seq是0,消息包内容长度是0,已经收到你发的seq 1 之前的TCP包。(请发后面的) NO.641 client发ACK给server,我是seq 1 ,消息包内容长度是0,已经收到你发的seq 1 之前的TCP包。 NO.992 client发PSH给server,我是seq 1 ,消息包内容长度是496,已经收到你发的seq 1 之前的TCP包。 ...........等了一段时间之后(这里约0.2s左右) NO.4796 client没等到对方的ACK包,开始TCP retransmission这个包,我是seq 1,消息包长度496,已经收到你发的seq 1 之前的TCP包。 ..........又...等了一段时间 NO.9669 client还是没等到对方的ACK包,又开始TCP retransmission这个包,我是seq 1,消息包长度496,已经收到你发的seq 1 之前的TCP包。 NO.13434 server发了SYN ACK给client,这里是tcp spurious retransmission 伪重传,我的seq是0,消息包内容长度是0,已经收到你发的seq 1 之前的TCP包。距离其上次发包给client是NO.516 已1秒左右了,因为没有收到NO.641 包ACK。这时,client收到过server的SYN,ACK包,将此TCP 连接状态改为ESTABLISHED,而server那边没有收到client的ACK包,则其TCP连接状态是SYN_RCVD状态。(感谢IM鑫爷指正)也可能是因为accept queue满了,暂时不能将此TCP连接从syns queue拉到accept queue,导致这情况,这需要翻阅内核源码才能确认。 NO.13467 client发TCP DUP ACK包给server,其实是重发了N0.641 ,只是seq变化了,因为要包括它之前发送过的seq的序列号总和。即..我的seq 497 ,消息包内容长度是0,已经收到你发的seq 1 之前的TCP包。 NO.16573 client继续重新发消息数据给server,包的内容还是NO.992的内容,因为之前发的几次,都没收到确认。 NO.25813 client继续重新发消息数据给server,包的内容还还是NO.992的内容,仍没收到确认。(参见下图中绿色框内标识) NO.29733 server又重复了NO.13434包的流程,原因也一样,参见NO.13434包注释 NO.29765 client只好跟NO.13467一样,重发ACK包给server。 NO.44507 重复NO.16573的步骤 NO.79195 继续重复NO.16573的步骤 NO.79195 server立刻直接回了RST包,结束会话 详细的包内容备注在后面,需要关注的不光是包发送顺序,包的seq重传之类,还有一个重要的,TCP retransmission timeout,即TCP超时重传。对于这里已经抓到的数据包,wireshark可以看下每次超时重传的时间间隔,如下图: RTO的重传次数是系统可配置的,见/proc/sys/net/ipv4/tcp_retries1 ,而重传时间间隔,间隔增长频率等,是比较复杂的方式计算出来的,见《TCP/IP重传超时--RTO》。 backlog大小设置为多少合适? 从上面的结论中可以看出,这跟FPM的处理能力有关,backlog太大了,导致FPM处理不过来,nginx那边等待超时,断开连接,报504 gateway timeout错。同时FPM处理完准备write 数据给nginx时,发现TCP连接断开了,报“Broken pipe”。backlog太小的话,NGINX之类client,根本进入不了FPM的accept queue,报“502 Bad Gateway”错。所以,这还得去根据FPM的QPS来决定backlog的大小。计算方式最好为QPS=backlog。对了这里的QPS是正常业务下的QPS,千万别用echo hello world这种结果的QPS去欺骗自己。当然,backlog的数值,如果指定在FPM中的话,记得把操作系统的net.core.somaxconn设置的起码比它大。另外,ubuntu server 1404上/proc/sys/net/core/somaxconn 跟/proc/sys/net/ipv4/tcp_max_syn_backlog 默认值都是128,这个问题,我为了抓数据,测了好几遍才发现。 对于测试时,TCP数据包已经drop掉的未进入syns queue,以及未进入accept queue的数据包,可以用netstat -s来查看: 经过相关资料查阅,技术点研究,再做一番测试之后,又加深了我对TCP通讯知识点的记忆,以及对sync queue、accept queue所处环节知识点薄弱的补充,也是蛮有收获,这些知识,在以后的纯TCP通讯程序研发过程中,包括高性能的互联网通讯中,想必有很大帮助,希望自己能继续找些案例来实践检验一下对这些知识的掌握。 参考资料 TCP三次握手之backlog linux里的backlog详解 tcp-queue-的一些问题 The meaning of listen(2)'s backlog parameter TCP 的那些事儿(上) TCP/IP重传超时--RTO TCP的定时器系列—超时重传定时器 Coping with the TCP TIME-WAIT state on busy Linux servers 2018年10月22日新增How TCP backlog works in Linux 2015-06-02 +8 12 0
nginx上,http状态200响应,PHP空白返回的问题 最近的大半年中,编程语言从PHP换到了Golang后,就很少接触PHP,当然,更多的还是恋恋不舍。尽管如此,每当有人在群里聊起PHP的话题时,我总是想插几句,怀念怀念,同时也温故温故知识点,可不能把她给忘了。 昨天朋友tywei问我一个关于PHP奇怪问题,查到原因解决后,没有详细的解释。夜里睡觉时,老是回想这事,早上醒来,决定还是认真记录一下这些问题。也让自己回归正常状态,多写点博客,总结自己,记录自己。 问题描述 PHP+nginx的环境,任何PHP处理的结果,都是空白页面。OS是ubuntu 14.10 ,nginx 1.6.2 ,PHP5.5.12, 问任何PHP的页面,返回的HTTP状态200,但页面内容是空的,什么都没有,不管PHP页面里写的是什么,正常响应。初步怀疑这是拓展的问题,处理请求后,输出内容有冲突,输出为空之类。想查看拓展列表,看看加载了哪些拓展,但任何PHP代码都返回空,万不得已,在CLI模式下运行,确定ini是同一个。粗略的看了加载的模块,列表如下: 大约如上的模块加载,怀疑xdebug跟opcache冲突,尝试关闭后,仍未解决。 strace看系统调用信息 觉得好简短,好奇怪,访问的是SCRIPT_NAME index.php,怎么都没 lstat \open 这个PHP文件呢?直接返回了?起码要判断SCRIPT_FILENAME是否存在吧,要读取SCRIPT_FILENAME,解析里面的代码吧? 等下.....SCRIPT_FILENAME全部地址是啥?发来的CGI协议包中怎么没有SCRIPT_FILENAME? 仔细看下CGI包的内容 缺少: 那么问题来了 PHP-FPM接收CGI请求时,如果没有SCRIPT_FILENAME怎么处理的? 发来的CGI协议包中,为啥没有SCRIPT_FILENAME? (SCRIPT_FILENAME是什么,干啥用的,这个就不要问了吧。) 问题1:PHP-FPM接收CGI请求时,如果没有SCRIPT_FILENAME怎么处理的? fpm源码里(PHP5.5.x 为例) 同样是 fpm_main.c中init_request_info函数的代码如下: 从代码里看出,script_path_translated变量就是cgi协议包中SCRIPT_FILENAME的结果,其中1115行左右,判断如果script_path_translated为空,并且env_path_translated不为空,则用env_path_translated赋值到script_path_translated上。 之后,对request_info的几个属性给予了默认值,包括request_method为null,以及http_response_code默认200的http响应。 在后面的代码中if (script_path_translated) ,因为CGI包中没有SCRIPT_FILENAME,也没有PATH_TRANSLATED,即script_path_translated为空,故没有对request_method进行赋值,其默认值为NULL。 回到main函数中1839行附近if (!SG(request_info).request_method) ,则直接goto到了fastcgi_request_done,直接结束当前request请求,由于之前有设置过默认的http 响应状态为200 ,也就导致了每次返回http状态200成功响应的空白页面的问题。 同时也解释了strace系统调用中,没出现lstat、open 操作SCRIPT_FILENAME的记录了。 问题2,发来的CGI协议包中,为啥没有SCRIPT_FILENAME? PHP-FPM接收到的CGI协议包都是来自前面nginx的,cgi协议包中没有这个,肯定是nginx没发来,查看nginx配置看到fastcgi_params中没有这项。加上后就可以了。 解决办法: 在nginx配置的 fastcgt_params中加上SCRIPT_FILENAME的配置(在ubuntu的apt-get形式安装nginx配置中,默认是有这条的),比如 题外话: 在FPM的fpm_main.c文件的main函数中,1848行对SG(request_info).path_translated的判断,晚于1849行SG(request_info).request_method的判断。而在init_request_info函数中,对SG(request_info).path_translated的赋值却是比SG(request_info).request_method早的。 而且,明明找不到需要执行的脚本,却还返回200的http响应,很奇怪,也不方便排查。我觉得把对SG(request_info).request_method的判断放到SG(request_info).path_translated后面更合适一些。或者若找不到SCRIPT_FILENAME的话,http响应状态改为404,同时,写入LOG日志,便于排查。 后记: 从fpm代码里可以看到,其实作者是有考虑到没有SCRIPT_FILENAME的问题的,只是判断顺序搞错了。所以,我觉得这应该是个bug,就提了一下:BUG #69625:php-fpm return http 200 response on nginx without SCRIPT_FILENAME,不知道官方是否认为这是个BUG。 新的问题: 为什么如果SCRIPT_FILENAME不存在时,用PATH_TRANSLATED来代替它?PATH_TRANSLATED是每个CGI前端都要发送的吗? 这个问题,后来认真看了下,感觉还挺复杂,跟CGI客户端有关,PHPFPM针对IIS\APACHE\NGINX的处理都不一样。以后再写吧。 参考资料: RFC3875 - The Common Gateway Interface (CGI) Version 1.1 如果你在阅读PHP源码,或者阅读PHP SAPI、PHP拓展源码,可以关注一下PHP源码执行流程图 关于FPM的执行流程,可以看下下面这幅图: 2018年07月09日更新: Fix bugs #75120 and #69625 #3226里修复了我在2015年3月提的 bug report Fix bug #69625 php-fpm return http 200 response without SCRIPT_FILENAME #1270 2015-05-05 +3 7 0
2013年癸巳年总结 序 眼看2014年1月马上就过去,现在我才有时间去总结2013年历程,足以看出在2013年里,我是那么的繁忙,说是『案牍劳形』的话,也毫不为过。(如果按照农历纪年的话,还不算晚。[偷笑]) 习惯 每年的年终总结,总有一两件非比寻常的事情,比如2010年在老家买房;2011年初结婚、年底儿子出生;2012考了驾照;2013呢?好像没有哪件事能称得上非比寻常,有特殊意义的事情。去年的总结中,曾下定决心,努力做到『少些微博,多写博客』,今天来看,已经实现“一半”了----『少些微博』已实现。。。 读书 先说读书吧,在2012年的总结中,曾提到当年读书甚少,更别提技术类的书了,而今年,更是少之又少。电脑包里,常备一本《城南旧事》,爱不释手,坐车时,总会翻两页,每次读,每次都有新的感慨,我常想象儿子的童年回忆会是怎样?夏日时,树林中没有蝉可以抓,小河里没有鱼儿可以捕。冬日时,麦地里,没有溜出来觅食的野兔,桑树林里没有头钻雪堆中的野鸡。还有什么是可以值得怀念的呢? 忘记余华《活着》是如何被我发现的,读完之后,翻阅豆瓣的评论,跟着一位评论作者有着相似的感慨----你看见颓废,你看见衰败,你看见离奇却又平凡的人生。你只是抱着家珍说要好好过。你看见凄凉。你看见没落。你看见堕落而又坚强的过去。你只是赶着福贵要多耕点田。生活是怎么折磨一个人?唯有老爷子无声的长叹,在田地的那一头。 除了上面两本之外,就剩下《采桑子》了,虽然作者大家庭的背景,不是我这种山村孩子能理解的,但家庭的和睦总得用心的去经营的,尽人事,听天命。 知识转化 今年写过的博客,跟技术相关的无非 《webgame中常见安全问题、防御方式与挽救措施》、《为什么不能在字符组中使用反向引用》、《PHP API中,MYSQL与MYSQLI的持久连接区别》、《Nginx模块fastcgi_cache的几个注意点》几篇,少之又少,起草好标题,一直没时间写的《phpredis跟redis server开启长连接后,使用mutil指令的protocol error, got 't' as reply type byte》、《android九宫格密码的最复杂密码有哪些》、《为何PHP5.5.6中fpm backlog Changed default listen() backlog to 65535.》(2015年才写)、《ext4 文件系统,OS发生Out of memory之后,引起磁盘重新挂在为只读,Remounting filesystem read-only》几篇,今年没时间写了,一定在以后的时间里补上来。 工作 接着是工作,从年初开始做新webgame,做到现在,团队从最初的组建、磨合,到后来的人员筛选,似乎是一个团队的必经之路。曾经放弃自己的机会,去帮小弟们争取利益,也曾经一腔热血的想去做好一件事,但到最后总觉得不尽人意。有过无法阻拦的前途选择,有过杯弓蛇影的猜疑,好在想通了,理解了,好在讲透了,释疑了。说好的相互支持,相互信任还会很远吗?在后半年里,我的大量时间、精力都扑在项目上,团队中,重燃希望,希望不会辜负期望。 成长 然后是生活,一年中,总有段精神恍惚,胡思乱想,常反思今年过去的时间里,是否有过碌碌无为的虚度,是否有过愁肠百结的失落。那些蛰伏心底的理想,越来越渐行渐远了吗?每当我陷入这种沉思时,总是无比焦躁、迷茫,试图找一些心灵鸡汤来读,来平复骚乱的内心,但总是收效甚微。久而久之,也倒是可以应付自如,自己也能换个角度来安慰自己,鼓励自己,也许这就是成长吧。 生活 今年带母亲坐了一次飞机。看上去并不是惊天动地的事情,而对于我母亲来说,这次也只是巧合。 一来从上海到我家(徐州下属县市)的高铁比较多,不管是行程时间,便捷性,还是价格,乘坐飞机回家的途径,几乎没有优点。二来,我们也没什么特别急的事情等着回家。年底时,舅舅病逝,春运期间火车票比较紧张,机票倒是很充足,决定带母亲坐飞机回去。 母亲年事已高,也很少旅行,再加上这次是舅舅的葬礼,担心母亲会有晕机之类反应,路途中一直寻找话题,转移母亲注意力,让她离哀伤远点。而母亲的话题,总是离不开舅舅。姥姥是在58-62年左右的饥荒,跟着一起逃命的人,从焦作逃到徐州的,后来在姥爷的村子里,不知远在焦作的父母是否被饿死,也不知道是否还能回到焦作,为了不饿死,为了生存,为了在他乡能有个人照顾,便跟姥爷结婚,留下了。几年后,大舅出生,然后母亲出生,生活也愈加困难了,姥姥、姥爷拼命干活,挣公分粮票,没时间照顾孩子。可能是营养不了,或者是饥饿,大舅生病了。姥姥姥爷忙于挣口粮,无暇他的事,也总以为小孩生病,自己可挺过来。但天不遂人愿,大舅夭折了。姥姥、姥爷经历这事后,也有少许改变,稍微关心孩子了。 后来,母亲长大了,姥姥也带着孩子,姥爷,回去看望一下她的父母,打探她父母是否在饥荒中存活下来。也想去告诉她的村子,她活下来了。姥姥便带着母亲去焦作娘家,姥爷、舅舅、刚出生几个月的小姨也一同前往。小姨年龄太小,经受不了舟车劳顿,夭折在焦作,姥姥的父母怒与姥姥的婚事是其自己做主,并非父母之约媒妁之言,不能接受,怒其为了生存,自我做主,与姥爷成婚,致使祖辈颜面尽失。故姥姥的父母常逼迫姥姥改嫁,姥姥被逼的神经衰弱,以及小姨的事情,得了精神病。 听母亲说,姥姥常被铁链拴住,困在山洞里,山洞内湿度大,见不到阳光,久而久之,得了病,后来,姥姥病世了。姥爷可能是被强迫离婚的事,也可能因为懒惰,也或许是人生地不熟,羞于在他乡露面,舅舅担负了一家人的口粮问题。姥姥的母亲曾逼迫姥姥跟姥爷离婚,所以她没有对姥爷、舅舅、母亲有任何的怜悯,也没有救济。舅舅常年光脚走山路讨饭,脚底的老茧堪比行走多年老马的马蹄,坚如铁掌。听母亲的描述中,几次舅舅光脚在雪天的外出讨饭情景一直让母亲记忆深刻,无法忘怀。 每每说道这些场景,母亲总是眼泛泪光,表情哀伤。再后来,姥爷带着母亲、舅舅从焦作回徐州老家,姥爷还是那样懒惰,母亲嫁入我家以后,跟着父亲一起,常救济着舅舅,直到舅舅成家之后,舅舅的生活才好了些,后来舅舅孩子比较多,送人两个,为此,常跟孩子的养父母吵架,也跟舅舅嗜酒如命或者表哥忤逆不孝之有关,产生家庭矛盾,甚至拳脚相加,以至于妗子愤恨离世。舅舅又过上了贫苦的生活。慢慢的,舅舅一个人独自生活久了,吃饭也没规律,饥一顿,饱一顿,渐渐的变得面黄肌瘦。以前家庭矛盾也落下了病根,这几年吃穿都不能保证,得了食道癌。表哥不在身边,吃喝都愈加不方便,看病开销昂贵,生活又很拮据,没撑到过年就离世了。 在舅舅生病前,也常听母亲聊她的往事,但都没有这次听得这么全神贯注。仿佛看到山洞里,被铁链拴住的疯癫妇人;仿佛看到山脚下光着脚丫,踏着白雪,捧着馒头的欢快小孩。或许情节想象的过于真实,或许是母亲悲伤情绪的传递,我的眼睛里也渐渐湿润。在母亲的回忆里,我看到好多好多个《活着》中福贵的影子,生活是怎么折磨一个人?唯有老爷子无声的长叹,在田地的那一头。 回到上海后,我跟妻子聊到这些,随口问她,见过舅舅几次。妻子答俩次,一次是结婚时,另一次是舅舅到我家,问母亲焦作的亲戚地址。我听到后,突然发现,母亲跟舅舅都对幼年时,那段最艰苦的时光,是如此的怀念。多么想去看看那片光脚丈量过的山地,那座关着疯癫妇人的山洞。。。母亲的愿望是在有生之年,再去焦作看看那座大山,不知道这件事,我何时能实现。 总结 今年,我总觉得我有无数个失败,但内心里总有个声音,督促我卷起袖子往前冲,如是说『人情世故要看透,赤子之心永不丢』。 2014-01-22 +4 8 0
webgame中常见安全问题、防御方式与挽救措施 十月一的假期间,在知乎上看到一个问题《网页游戏都有哪些安全问题?》,我是一个网页游戏开发者,对这个问题非常感兴趣,印象比较深刻。当时是在游玩,也没时间细看这个问题。后来,在微博上,有一位朋友的转发,又让我看到这个问题,冥冥中,有种想回答的冲动。上周六时,研发部门内部周会时,听到其他项目组的一个整型溢出问题,导致刷钱的bug,又让我想起这个问题,更加坚定我要回答这个问题的决心,总结一下这项目中,所有经历过的webgame安全问题的经验,以加固当前项目安全壁垒,避免损失。亦可分享给其他做webgame研发的朋友,做交流探讨。 知乎中的原问题是『网页游戏都有哪些安全问题?』,我觉得不妥,我给改成了『网页游戏都有哪些安全问题?如何做得更安全?』,同时,问题也从『大家来研究探讨一下,网页游戏攻防技术。必定,这个话题很敏感。目前,网页游戏已经很多了,会不会被黑产盯上?网页游戏会不会被黑,数据库会不会被拖库』改成了『大家来研究探讨一下,网页游戏攻防技术。必定,这个话题很敏感。目前,网页游戏已经很多了,会不会被黑产盯上?网页游戏会不会被入侵?入侵方式有哪些?如何做好网页游戏的入侵防御?挽救措施有哪些?如何才能最小化减少厂商损失?入侵方式有哪些?如何做好网页游戏的入侵防御?挽救措施有哪些?如何才能最小化减少厂商损失?』,更改的理由是『本文原提问者开篇提到「大家来研究探讨一下,网页游戏攻防技术。」,那么应该不光提到如何入侵,更应该提到如何防御,应该细心描述漏洞形成原理,规避方式,以提高研发者技能水平;应该详细讲解安全事件发生后,如何最小化减少厂商损失,减少用户损失,保护游戏平衡。』,幸运的是,这个修改,被知乎通过了。对此,表示感谢。 在后来阅读这篇提问以及回答时,已经有几位网友回答了,多数是站在安全工作者角度上回答了这个问题。在这篇日志里,我将以webgame研发者角度,切合游戏业务模块逻辑,从业务需求,数据库设计,程序编写,操作方式上来讲解漏洞形成原理,规避方案,也欢迎大家讨论。 登录认证 近几年,网页游戏几乎都是以联运方式运营,意味着游戏服务器本身不保存用户密码,用户登录在平台,通过平台跟游戏服务器的接口对接登录。接口做加密认证。故webgame的帐号密码安全问题,这里不提了。但登录认证的hash字符串安全,也还是要注意的。比如登录hash字符串的生效时间,hash字符串的加密参数来源,比如包括用户名、登录IP,浏览器user-agent等数据,以防止改hash被泄漏了,也是很难通过服务器的验证。 游戏充值 webgame的游戏充值流程,跟普通网页充值流程一致,没有特殊的地方,其不同点就是跟其他众多平台做联合运营时,势必要每个公司做接口对接,且接口规范各式各样,且游戏厂商没有话语权,必须按照他们的接口规范来,这实在棘手。腾讯的充值接口的验证方式,安全性做的较为突出,大约代码: 在此基础上,还可以做的严谨点: 增加随机参数名、参数值。随机参数名、参数值由联运方随机生成,按照参数名的字符串所属ASCII码顺序排序,参数名、参数值均参与sign的计算,增加暴力破解密钥(app key)难度。 增加回调验证订单号,金额信息。游戏充值服务器接收到充值请求时,反向到该平台回调接口,确认此笔订单有效性,以防止加密密钥泄漏的问题。 远程文件引入 在网页游戏的研发中,多数都是使用框架来做,即使用REQUEST来的参数,作为请求文件名的一部分,来使用,那么很容易形成远程文件引入的漏洞。在我们之前的游戏中,曾出现过一例这样的漏洞问题。 从代码以及案例图中,可以看到对于REQUEST的参数没有过滤处理,直接作为文件名来include引入的,故导致这种问题,类似上页图中,若PHP version < 5.3.4 ,还会发生Null(%00) 截断的问题,带来更大的安全问题。在我们新的项目中,我们更改了实现方式,我们游戏所有接口都会走gateway,gateway里,对控制器名做类名规范的检测处理,再在指定几个目录下做autoload加载文件,且还会对REQUEST的类名、方法用ReflectionClass反射类的处理,检测到类、方法、参数是否合法。一来避免『远程文件引入』漏洞问题,二来便于前后端联调时,抛出更详细的异常,方便调试。下面为参考代码: [code lang="php"] require_once CONFIG_PATH . "/auto.php"; spl_autoload_register("__autoload"); ...... //默认消息格式 $view->clear(); $view->error(MLanguages::COM__INVALID_REQUST); $msg = new Afx_Amf_plugins_AcknowledgeMessage($val->data[0]->$messageIdField); $msg->setBody($view->get()); $message->data = $msg; ... $a = new Yaf_Request_Simple(); $a->setControllerName($method[0]); $a->setActionName($method[1]); $objC = new ReflectionClass($method[0]."Controller"); $arrParamenter = $objC->getMethod($method[1]."Action")->getParameters(); $arrRequest = isset($val->data[0]->body[0]) ? (array)$val->data[0]->body[0] : array(); $bCanCall = true; foreach ($arrParamenter as $objParam) { $parm = $objParam->getName(); $bIsOption = $objParam->isOptional(); //是否为可选参数 if (isset($arrRequest[$parm])) { $a->setParam($parm , $arrRequest[$parm]); } elseif ($objParam->isOptional()) { //可选参数 } else { $bCanCall = false; } } if ($bCanCall) { $rp = $app->getDispatcher()->dispatch($a); $msg = new Afx_Amf_plugins_AcknowledgeMessage($val->data[0]->$messageIdField); $msg->setBody($view->get()); $message->data = $msg; } [/code] SQL 注入 SQL注入原理、方式,跟普通web应用一样,没什么特别的,在使用REQUEST来的参数时,过滤处理即可。可能在消息格式,以及注入操作简便上,会蒙蔽研发人员的眼睛,被忽略掉了。比如我们项目的AMF消息格式,在前端界面没出来之前,我们后端程序员一般使用Pinta来模拟操作,调试程序。前端界面出来之后,会使用Charles proxy来捕捉http请求。在这些过程中,请求接口、参数的构造,没有普通web那么简单。研发人员也容易忽略对请求参数的过滤,故很容易形成这种问题。形成原理见:《WEB开发安全与运维安全浅见》,防御方式做过滤处理,或SQL预编译。 为了提高游戏服务器的吞吐能力,网页游戏的架构也是一直在演变的。在之前以Mysql作为数据存储的webgame架构中,其他节点都是可以水平扩展,或者说依赖简单粗暴的增加服务器来解决,单单作为唯一数据存储中心,不能这么做。为此,很多webgame的数据存储改用Nosql来代替,甚至java、C/C++的游戏数据,直接在内存中操作,游戏关服时,才写入到DB中。故SQL注入的问题,也会越来越少。 通讯协议与消息格式 网页游戏虽然名字叫网页游戏,但通讯协议并非全是http,也有很多使用socket,以及http+socket并用的做法。我们是http协议+amf消息格式,以及socket并用来实现。在http与https的取舍上,我们考虑到ssl的启用后,大量的ssl解密加密运算,势必会增加服务器大量的CPU计算压力。而传输的内容,多数是游戏业务的操作,响应,是能接受被监听嗅探的行为的(认证信息除外)。站在安全角度,这不能理解。但站在产品角度,考虑一下 投入产出,然后选择http通讯,也是可以理解的。socket在我们游戏中,除了在聊天应用上使用外,在一些组队、帮派战之类需要多个玩家之间同步数据信息时,我们也会使用socket来推送数据。在使用socket作为所有业务传输的协议时,协议格式一般都是开源协议,比如msgpack、protobuf之类,或者自定义的协议。使用自定义协议时,务必检测整个消息包的每一个参数,类型范围,避免个别超大数值、边界数值出现,导致主程序内存越界,以至于服务宕机,无法正常服务的情况发生。 金币复制-整型溢出 上周周六开周会时,听到其他项目组的一个关于整型溢出导致产生刷金币的问题。在这里,我抽象该案例,分享一下。商城出售开启背包格子的所需道具『梧桐木』。在游戏中,用户包裹格子数量一般都会作为一个收费点,一款游戏的格子大约为每行7格子,一共8行这样。比如前面3行是默认开放的,第4行是收费的,而且第一个格子所需品梧桐木的价格1个银子,第二个梧桐木是2个银子,第三个是4个银子。依次类推,意味着这些梧桐木的价格总和其实就是一个第一项为1,公比为2,项为35的等比数列。 当用户选择购买梧桐木数量大于31时,比如32-36中这些数字时,这些等比数列的和就是大于2147483647。(只是举例,实际上不会以这样的价格出售物品) 在java中,4字节的存放int型变量的范围是-2147483648至2147483647。在java、c的有符号int型中存储时,数的最高位描述符号位,4字节共32位,除去最高位的符号位,剩下31位,每个位上能表示2个数字,4字节的有符号的整数表示范围为:负整数2^31个,范围为『-1至-2147483648』;正整数2^31个,范围为『2147483647至1』。 比如下图(注意十进制数字跟二进制表示的变化顺序): 当开启格子数字为大于31时,比如32,那么所需费用就是2147483647个银两,再买点其他物品,凑成超过2147483647的数字,比如又买了3个银子的其他道具,总共花费2147483650个银子,在4字节的有符号int中表示出来的结果,变成符号位为1,即负整数。数值位为0000000 00000000 00000000 00000010,也就是10000000 00000000 00000000 00000010,对应十进制的-2147483646。程序逻辑上,再判断现有银两是否足够支付此笔花费时,是通过的。当使用当前余额减去这笔花费时,将变成减去一个负数,那么实际上就是加上一个正整数。变成了自己银两账户余额的增长。而余额字段类型是long,则正确的存储了这些余额,溢出漏洞被利用。在C中,使用无符号的数值类型,即可完成数值类型溢出刷钱的行为,但在java中,好像没有无符号的类型。这也可以先确定所有参与计算的数值必须为正整数作为必要条件(游戏业务特性,游戏内所有数字,肯定全为正整数,甚至都不包括零),先做大小判断,再做正正相加,不能得负;负负相加,不能得正。来判断是否发生了溢出问题。在PHP中,不用担心溢出问题。 更多信息参考:《溢出,符号与进位》 不光是程序中,整型变量类型得需要注意,使用unsigned int在存储数据的DB中也要做同样的处理,对于存储整型数字的字段,均设置为unsigned int/tinyint/...。在游戏中,数字几乎全是自然数,几乎不会出现负整数的情况。 道具复制-逻辑错误(2014/07/23 更新) 在我的新款webgame中,又发生了一起刷道具的问题,问题发生在背包内道具移动的业务上,业务需求如下: 当从A位置移动到B位置时,若两者是同一类型,且可叠加(两者都是绑定或不绑定),则叠加数量到其中一个上,并销毁另外一个。 若不是同一类型,或不可叠加,则两者呼唤位置。 若目标位置为空,则把物品从A移动到B。 如果这个功能由你看写,你如何写? 程序员的全部代码在 gist:b7d794d3cb663dd86aad 可能比较长,其实这次出问题的在下面的代码中 其出现问题部分就是如上这些,大家看代码时,会认为这前期的判断都是正确的,符合业务逻辑的,问题却发生了“来源格子”跟“目标格子”的判断,没有判断这两个参数是不是同一个位置,若是同一个位置,那么上面的所有业务逻辑判断都是合法的,下面的代码中,将根据这“两个”道具的属性进行堆叠,然后就发生了道具复制的问题..... 修复方法想必大家都会,就是判断来源位置目标位置是否相同 如果是你,你会犯这样的错误吗?我想.....呵呵... 金币复制-并发请求 Rpg类型的网页游戏中,多数都有道具出售的功能,直接卖到商店,以及道具材料从商店买入功能。当玩家同时针对买入、卖出两个操作,瞬间大量并发请求时,在服务器的处理逻辑一般有分别的两个进程处理,共享数据分别数据库中的对应账户余额表,如下图: 卖出请求的处理进程为1,买入请求的处理进程为2。在进程1还没将结果写入到DB时,进程2也从DB读取到余额为50。这是,两个进程拿到的余额信息都是50。进程1按照逻辑代码,计算出剩余余额是150;进程2计算出的剩余余额是0。最后,不管那个进程最后写入余额,都是错误的结果。(注:这里的代码逻辑操作,跟mysql事务无任何关系,事务只能保证单个进程的事务范围内多条语句都正确执行,或回滚。比如能保证扣钱成功,且物品删除掉的两个语句都正确执行。能保证其中之一的语句执行失败时,都正确回滚。) 其实,在事物开启时候,SELECT语句是否可以取到最新的数据,或者是否需要等待锁释放,取决于MYSQL的事务隔离级别。在MYSQL的事务隔离级别中,有一下几种隔离级别: READ-UNCOMMITTED(读取未提交内容)级别 READ-COMMITTED(读取提交内容 REPEATABLE-READ(可重读) SERIERLIZED(可串行化) 对于READ-UNCOMMITTED,可以读取其他事务中未提交的数据,而且据说性能还高不到哪里去,几乎没有在实际应用中使用;对于READ-COMMITTED,在同一事务中,会因为其他事务随时可能有新的commit,导致同一select可能返回不同结果。这个也不适合游戏业务;再说第四个SERIERLIZED,只要事务开启,所有其他查询,均排队等待该事务提交之后,对于上面提到的卖出买入情况,第二个事务的SELECT操作,不会立刻返回,会处于锁等待状态,一直到前一个事务结束。这个隔离级别,虽然能避免上面的问题,但性能较差,一般不会去使用。而REPEATABLE-READ隔离级别,也是mysql默认的隔离级别,从功能上,比较符合游戏业务需要,也应该是广大webgame架构中mysql的默认隔离级别。在使用REPEATABLE-READ隔离级别时,select的数据,都是事务未提交之前的数据,而每个事务都能正常成功执行,故错误的结果被执行出现。 对于这个问题,你可能很快就给出解决办法,把UPDATE语句改为UPDATE `role_gold` SET gold = gold + 100 WHERE role_id = 1或者UPDATE `role_gold` SET gold = 150 WHERE role_id = 1 AND gold = 100来解决,但这种多个事务同时操作修改多个表的多条记录时,还容易引发死锁问题,比如《webgame中Mysql Deadlock ERROR 1213 (40001)错误的排查历程》。而且,当条件为跨表内数据是否存在,或者另外条件不在MYSQL中,而在其他网络接口的响应中时,如何做呢? 金币复制--逻辑漏洞 引用DNF的漏洞新闻 《利用网游漏洞狂刷游戏币赚钱 玩家自曝3天赚17万》 玩家曝出刷币漏洞 一个游戏道具可刷400人民币 该漏洞到底是什么?原来游戏中“云幂袖珍罐”这个道具,可以开出2件一样的游戏装备,还有极少几率开出游戏币,开出的装备不值钱,但如果开出金币了,则分为5000万、8000万以及1亿游戏币。而1亿游戏币,按正常市场行情,可在交易网上卖400多元人民币。据玩家称,在游戏中,角色的装备是需要用包裹来存放的,不过目前角色的包裹最多只有48格,也就是只能存放最多48件装备。漏洞就是利用包裹的有限空间,存放47件装备(存放满了又无法开罐子),只留下一格空位,而在开“云幂袖珍罐”出装备时,就会因包裹空间不足,而导致开罐失败,而罐子还存在。玩家继续开罐,直到出现金币,但金币不会占据包裹的空间,因此开罐成功,然后罐子消失。发现这个漏洞后,部分玩家狂刷游戏币,然后马上在第三方交易平台出售游戏币,兑换成现金。 这种问题,都是研发人员逻辑不严谨导致,这种问题,也较难发现。规避方式可以依赖下面提到的『运营数据监控』。 道具复制--背包整理 跟上面的卖出、买入一样,同时穿装备、整理包裹。在设计时,可能会将身上装备设计在装备表中;将不在身上的装备,设计到背包表中。当同时进行穿装备跟整理包裹的请求并发时,也会发生跟上面卖出,买入的情况,线程1读取DB,发现包裹里有这装备,然后准备删除背包表的这条记录,当准备写入到装备表时,另外一个整理包裹请求的线程来了,读取了整个背包表,进行道具的合并、排序。这时,之前的线程将这个装备写入到装备表,并删除了背包表里的数据,并提交事务。这个穿装备的所有操作都是合理、正常,且正确执行的。但另外一个整理背包的线程读取了之前的背包表里的数据,包括那件被穿上的装备。在游戏中,整理背包需要对可堆叠道具做堆叠操作的,意味着需要合并多个道具,删除部分道具。这意味着这里的操作,当前cgi线程的内存中的数据,将都会以覆盖的形式,写入到DB中,那么意味着,之前被穿到身上的那件装备,也会重新被写入到背包中。那就变成两张表里出现了两个相同唯一ID的相同属性的道具。玩家就可以把背包中的这个道具出售给其他玩家。 在java或者C之类程序中,数据放内存中的游戏,也会存在这个问题,除非做读锁,但读锁会带来锁等待,锁等待会导致线程被占用,阻塞后面请求的处理,堆积大量请求。导致系统负载升高,服务器繁忙,以至于无法响应。好了,大约理解道具复制的形成原因了吗?这个问题,我们从根本原因想想,问题到底出现在哪里?如何规避呢?细心的同学不难发现,对于穿装备的操作结果,会对下一个请求产生影响的,当前操作未得到服务端响应之前,服务端是不能处理下一个响应的。对此,我们做了响应处理锁--『用户并发请求锁』。 用户并发请求锁的实现,php中session以文件形式存储时,php会对session文件加锁,不释放(如果不特意执行session_write_close),知道当前响应完成。另外一个线程才可以正常读取,这简介的形成了单个用户的并发请求锁,但是,后面的进程一直处于等待状态,也会占用一个php-fpm进程,阻塞其他用户的正常请求对php线程的使用。为此,我们使用NOSQL的K-V形式结构,以user_name为key的形式,实现用户并发请求锁,比如 redis的setnx接口,原子性判断操作有则返回false,,没有就添加一个,返回true。那么,对于下一个请求,setnx时,返回false,有这个key了,那么立刻抛出异常,结束响应,FLASH根据异常内容,提醒用户不要进行恶意操作。即不会发生并发请求,又不会阻塞请求处理。同时,在请求结束的析构函数里,对这个锁进行删除操作,不影响下一个正常请求。若因为程序异常,发生语法错误,导致析构函数没法执行,没有删除用户锁时,可以在生成锁的时候,设置过期时间,比如5秒,甚至2秒,利用nosql的过期机制,实现用户解锁,避免用户长时间无法正常游戏。 类CC攻击-多用户共享资源锁的timebomb 我们现在研发的项目,是以NOSQL Redis作为DB,来存储数据的,redis并没有成熟的事务处理机制,watch甚至算不上关系型数据库中的事务处理。对此,更需要对表进行加锁解锁。java之类语言的项目,很多都是直接操作内存的,更是需要资源锁,来解决并发问题,解决多个请求操作同一份数据的问题。公司有另外一个项目,出现过一次因为锁的颗粒度较大,带来的锁等待timebomb的问题,也导致了线程繁阻塞忙,请求堆积,系统负载上升,导致宕机的问题。这个项目的锁是针对所有用户的锁,每个用户的请求发来时,当前线程会对所有用户的数据加锁,直到响应完成,才释放掉。这么做,是为了解决因当前操作,会影响到其他用户数据,比如多人PK,多个玩家之间的交互。 当其他请求一并发来时,那么资源会立刻被锁住,直到上一个请求结束,才释放锁,那么其他线程都处于等待状态。用户基数小时,是看不出来锁带来的影响的,内存操作都比较快。当用户基数大时,或者说请求数增大时,后面的请求的等待时间会越来越长,超过webserver的等待时间,直接返回timeout,不能正常提供服务。 这种问题的发生,是因为锁的颗粒太大了,不应该将所有用户都锁住,最好细化到当前请求所影响到的单个用户,只锁住单个用户的数据。这样,才减少timebomb的发生。 其他 知乎里的朋友提到,很多webgame 的前端做了判断,而后端没做判断的问题,这种问题,实属不该存在。在我们的项目中,后端做的验证判断,远比前端多的多。有时候,为了界面上的动画表现,前端flash一般会在用户操作之后,立刻渲染,然后,再根据后端响应,决定是否继续做界面元素改动。比如脱装备,玩家操作时,会先渲染装备从角色面板,跳到背包里的动画,然后,再根据后端响应结果,决定是否回滚动画。这样,避免显得操作后,一定时间的反映迟钝假象,以提高用户体验。当然,后端是一定会做判断的,判断角色背包是否有空格之类。现在的webgame研发,一般都不会存在前端判断,而后端不判断的做法了。如果有,也应该是个别遗漏情况。 比如去年的time33算法的hash dos的问题,使用json消息格式的webgame一定要注意,php只是在接收请求时,做了最大数量的限制。但在json解码之后的数据中,是没有处理的。这里千万别忘记了。 运营数据异常监控 再完善的防御措施,都仍会有安全漏洞。适当的监控措施,也一定要有,监控等级、金币、游戏币、经验、珍贵物品的变化等等,一旦发现,立刻报警,在漏洞未扩散之前,第一时间去修复漏洞,以减少损失,维护游戏平衡。 日志系统 日志系统一定不能漏掉,所有操作,必须写入日志,当安全事件发生后,可以作为各种数据回滚,交易纠纷处理的可靠数据。也是作为数据监控的最准确的数据来源。 一个真实的故事。去年6月份,我们项目新上线一个系统,以及腾讯充值接口V2升级V3,涉及充值代码改动,研发测试、策划测试、QA测试完毕之后,上线到个别服务区,观察情况。每次新版本上线,整个项目组都会持续观察数据情况,尤其是充值总额, 10、50、100的涨,突然,总额下降了。充值总额下降了,这可是总额啊,只能增加,是不会下降的。肯定哪里有BUG,DBA直接看binlog,查充值记录相关的SQL语句,最后发现充值系统的sql语句为UPDATE table set gold_num = $num,is_pay =1 ,没有WHERE,没有WHERE啊,多么弱智的BUG,这尼玛能容忍么?肯定要拉出来,弹JJ弹到死。当我从SVN日志中看到涉及这个文件的修改者时,我立刻石化了,悄悄的修了bug,默默的上传...... 事情是发生了,原因很弱智。是我自己,忘记写where条件,由于框架封装了,问题并不容易被发现。而且自己测试充值都是正确的,包括后来的策划测试、QA测试,都没有发现问题。所属功劳,就是“数据监控系统“,所以,我们游戏开发商,第一时间,比用户还早的发现了问题所在。数据监控,一定必不可少。 至于修复方案,那要感谢日志系统。每笔充值,都有双份日志,一是各个游戏大区自己的DB中。二是充值中心中。充值中心负责跟其他各大平台对接。这次的事故,影响游戏大区的数据,并未影响到充值中心数据,故可以有据可查。这样,伟大的DBA可以更便捷、放心的修复数据了。对此,不管发生其他刷钱、刷装备、盗号之后的交易纠纷,都可以依赖日志来做处理。日志系统,也一定必不可少。 从新功能发布,到发现BUG,到修复BUG,总共历时不到1小时(svn时间可以看出来,16:30对外的),可想而知,数据监控的效果多么值得称赞。新功能上线时,各个测试环节要去做。按大区服务器,做灰度发布也可以更小的减少bug影响范围,减少损失。 如果你用了我画的小清新般的插图,请记得为图片写上署名来源,画图是最花费我时间的一件事。对了,看转载的同学,建议过来看原文,一来原文的排版格式会更适合阅读,二来原文会持续修正文中错误,增加内容。 2013-10-16 +3 7 0
基于语法分析的PHP webshell扫描工具–WoodPecker Scanner 前段时间,在一位大牛的BLOG上看到其resume上撰写的开源项目列表琳琅满目,数不胜数。再跟自己对比一下,从来没有一个开源项目,没有成功的参加过一个开源项目,只是零星的贡献过几个所谓工具,脚本。顿时无地自容,同时也是羡慕不已,再暗自勉励鼓励自己,向这位大牛学习。也是在前段时间,遇到了一个正则的问题《为什么不能在字符组中使用反向引用》,使得我又跟着自己的思路,翻阅之前的博客,面包屑导航一般,跟着链接,走到了另外一篇博客《如何精确查找PHP WEBSHELL木马?》。写那篇博客时,是2010年7月,我还在久游网工作,工作内容也是代码安全相关。我最初的实现,也是基于正则表达式实现的字符串匹配,来捕捉查找危险代码。同时,也计划在二期里,用LEX+YACC来实现对PHP代码的精确分析,扫描查找,为此,也经常装模作样故弄玄虚的研究学习yacc、lex,学习PHP的Zend引擎实现方式,研究zend_language_scanner.l、zend_language_parser.y、zend_language_scanner.c等几个文件的编写思路等等等等。从我2月下旬入职,到11月初离职,总共7个多月的时间里,也并不是一直做这个项目,甚至这个项目只是作为我个人研究的范畴,平常工作中,只能利用空闲时间来研究。因为种种原因,在没有完成那个项目之后,离开了久游。不管是因为自己基础薄弱,还是时间不足还是什么原因,每次想起这件事,都因没有完成那个项目而觉得遗憾。 人总喜欢找借口,好比我前段时间,心里烦躁不安、胡思乱想,开始玩起了英雄联盟,从5月到现在,断断续续玩了5个月了。支持者的角度上想,人总不能一直紧绷着大脑,偶尔玩玩游戏,放松一下自己,又何尝不可?反对者的角度上来想,放纵自己堕落,还找理由,找借口?我有时赞同前者,有时赞同后者,现在,我就觉得既然都是找借口去做事情,为何不给自己找个借口,做一些有意义的事情呢?“那就努力开发维护一个开源项目吧”我心理暗暗这么说道,决定完成曾经的遗憾。 这线引得有点太长,也算是我的自我检讨与牢骚吧。 所谓扫描工具,先要扫描,才能找到需要的东西。对于代码扫描方式,实现方式大约是特征关键字的捕捉,正则表达式的语法匹配,语法语义分析这些。这些实现方式,准确性、严谨性也是层级递增的。 特征关键字方式,是最简单、最不准确的检测手段,上篇博客里有个比较好的例子: 这里的正则的最后部分中文字符串那里|小组专用大马|提权|木马|PHP\s?反弹,这里的正则不是原本意义上的正则表达式了,只是简单的或逻辑,没有其他更严谨的匹配规则,所以只能当成简单的特征关键字来看待。比如代码或注释中出现螓首微微压下,右手倒提权杖、电影《特洛伊木马》均被当作webshell来抓获了。这种实现方式,最不准确。 正则表达式匹配方式,是在特征关键字之后,更加准确的捕捉方式,但仍存在大量的误报、漏报情况,比如我在三年前写的python版php webshell检测工具里的代码: 虽然可以更精确的匹配函数名符合的地方,字符串中出现的会被跳过,但仍无法排除注释中的代码,这种匹配结果,可以作为参考,仍需要管理人员认真校对每一个检测结果。当然,漏报情况,相比特征字符串的方式,要严谨的多。 语法语义分析形式,是根据php语言扫描编译的实现方式,进行剥离代码、注释,分析变量、函数、字符串、语言结构的分析方式,来实现关键危险函数的捕捉方式。这样可以完美解决漏报的情况。但误报上,仍存在问题。 误报问题所在,一是被检测文件是否为合法php语法文件,token_get_all函数的实现,是不验证是否问合法php语法文件的,只是对其进行扫描,分析。而语法验证是比较难于试下你的地方。其次是代码中因为业务需要而出现的危险函数,如何排除掉,恶意代码如何界定? 服务器云判断是一种根据恶意代码串的指纹,根据大量后门数据,做语法、语义分析,做业务逻辑分析,理解这段代码的用途,给出其是否为恶意代码的定位,而其他使用者,直接可以得到该代码片段是否为恶意代码的结果反馈。Pecker Scanner首先是基于语法分析,剥离token、注释、字符串、变量、语言结构,再进行php语法检测,提取恶意代码的扫描工具,来解决漏报问题。同时支持服务器云判断,尽量避免误报问题。比如exec ($cfe,$res)在Discuz!中(只是举例)是正常的功能代码,而在木马后门中,这就是恶意文件。同样的一段代码,在不同的项目中,扮演着不同的角色,这也不能光凭借代码功能上判断,还得依赖所属项目。这需要大量的木马后门库,以及开源项目库希望大家可以帮着提交一些。 Pecker Scanner这个开源项目,托管在Github上,大家都可以fork它,完善它。目前Pecker Scanner生成的报告,跟服务器校对检验方式是通过jsonp方式通讯,若以后流量较高,则采用验证API形式,直接在pecker scanner代码中,根据api 、 key运算,post多个token代码段到服务器获取状态,展示到报告文件中。一来减轻jsonp这种大量http请求数。二来减少恶意无用的查询。 Pecker Scanner目前是beta 0.3版本,您可以在这里下载Pecker Scannerbeta 0.4.4版,不建议直接到github上下载,因为github上可能存在我正在调试的代码,或者不完善的新功能。 Pecker Scanner的项目主页在这里,以后的版本更新,新功能发布,也都会在这里公告。扫描报告样例在pecker scanner report样例,您可以先预览一下。 2014-03-03 发布新版本 pecker scanner 0.4 2014-05-27 发布新版本 pecker scanner 0.4.4 修复复杂语法complex(curly)syntax的检测,比如${ $ {func}}($evil)语法。 pecker scanner beta 0.4 releases fix bugs for some of the syntax with "{" and "[". fix bugs with code : (require '1.dat').'.php'; thanks for poker付 ( http://weibo.com/1776130645 ) add "include" to default checklist . Pecker Scanner Server目前收集的开源软件包括Discuz_7.2_FULL_SC_UTF8、DedeCMS-V5.7-UTF8-SP1-Full;木马后门包括PhpSpy 2011、PhpSpy 2013、PhpSpy 2011加密版。其他开源项目以及木马后门,等待各位的反馈。 2013-09-24新增开源软件、webshell后门特征: DedeCMS-V5.7-UTF8-SP1-Full Discuz_7.2_FULL_SC_UTF8 Discuz_X3.0_SC_UTF8 Discuz_X3.1_SC_UTF8 espcms_utf8_5.7.13.09.10_b HDWiki-v5.1UTF8-20121102 Joomla_2.5.14-Stable-Full_Package Joomla_3.1.5-Stable-Full_Package KingCMS_6.1.1641(Sp2) none phpcms_v9.4.2_UTF8 phpMyAdmin-4.0.7-all-languages phpspy 2011 phpspy 2013 phpspy 2013 加密版 phpwind_UTF8_8.7 phpwind_v9.0_utf8 WeCenter_2-2-1 wordpress-3.6.1 您能做的: 风险函数列表总结 木马后门样本提供 服务器赞助(目前暂时将Pecker Scanner Server部分托管在朋友的服务器上,目前流量还能支撑,若...) 推广它(特别感谢黑哥的第一次推广,您的每一次使用,都是对我最大的支持) 使用反馈(感谢bgho4t的第一个测试报告) 我能做的: 默默的开发,悄悄的维护,轻轻的调试,偷偷的发布。 2013-09-22 +1 5 0