使用C#对HTTP数据还原
[创建时间:2016-05-12 00:19:00]
在NetAnalyzer2016中加入了一个HTTP分析功能,很过用户对此都很感兴趣,那么今天写一下具体的实现方式,对我自己也算是一个总结吧,好了,废话就不多少了,直接开始吧。
本文是专注于HTTP数据的分析,所以前期数据包采集相关的内容并不会本文不会涉及,相关内容可以见 NetAnalyzer 笔记 四
在这边默认你已经获取到了http数据报文。
一,提取TCP会话数据
通过TCP/IP协议知道,我们可以通过网络层的IP地址可以唯一的确定一台主机,通过传输层的端口确定一个主机应用,而主机应用与另外一个主机应用的一次数据通信(报文交换)我们称之为一次会话,而建立的传输层协议为TCP上面的一次交互数据我们就称之为TCP会话数据。
一次常规的TCP会话
在这里我们可以看到本地主机应用标志 IP地址: 192.168.1.102 端口 55298 和远端主机应用标志 IP地址:124.238.254.191端口 80 (80端口所对应的应用协议就是HTTP),通过这组标志,而下面每行记录都代表一TCP数据包,
每个TCP包包含了TCP状态标志,载荷数据(TCP数据包封装的数据)量,序列号等信息,大家可以留意一下每两条记录之间的序列号和载荷数据量之间的关系。
我们这里要提取的数据正是这组TCP报文中的载荷数据
这段代码用于定义并生成一个TCP标志
对于这段代码的使用方法如下:
1 // 选择一个RawCapture 2 RawCapture rawPacket = PacketList[i]; 3 // 通过RawCapture 获取到一个标志 4 var tmpId = new ConnIDbyIPPort(rawPacket);
通过标志位挑选会话数据
1 /// <summary> 2 /// 通过ID和连接类型获取原始数据列表 3 /// </summary> 4 /// <param name="id"></param> 5 /// <param name="connType">之定义了三种,以后会扩展</param> 6 /// <returns></returns> 7 private RawCapture[] getConnectionList(IConnectionID id, ConnectionType connType) 8 { 9 if (id == null) 10 return null; 11 try 12 { 13 //var values = from v in PacketList 14 // where id.Equals(id.CreatID(v)) 15 // select v; 16 //if (values == null) 17 // return null; 18 19 List<RawCapture> values = new List<RawCapture>(); 20 foreach (var i in PacketList) 21 { 22 if (id.Equals(id.CreatID(i))) 23 { 24 values.Add(i); 25 } 26 } 27 28 return values.ToArray(); 29 } 30 catch (Exception ex) 31 { 32 MessageBox.Show("正在获取网络获取相关数据,请稍后再试!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); 33 return null; 34
通过这段代码,我们最终获取到了这组TCP数据,接下来就是载荷数据的提取
二,TCP载荷数据的提取与组合
我们现在获取到的数据包都是TCP,接下来我们要面对两个重要的问题:
1、对于HTTP协议中传输的文本,图片,文件等数据有可能需要多个TCP数据包才能发送或接受完整;
2、存在一些载荷数据为0的TCP数据包,这些数据包对于TCP的会话特别重要(在TCP中完成确认或重传机制,保证数据可靠性),但是对于我们分析数据并没有太多的意义。
根据以上两点,我们需要对得到的这组会话数据进行重新的组合,这里的基本思路是,建立一个单向链表结构,以第一个载荷数据不为空的数据包作进行拆包提取特征量和载荷数据,然后移到下一个载荷数据不为空的数据包,提取数据包特征量,与当前节点特征量比较,如果方向一致(端口号相同)则将载荷数据缓存到当前节点,如果不一致(端口号不相同)则生成新的节点,生成特征,提取载荷数据缓存,并把上个节点指向新节点,
新结构示意图
对于每个节点的定义如下:
1 /// <summary> 2 /// TCP单向载荷数据节点 3 /// </summary> 4 class DataNode 5 { 6 /// <summary> 7 /// 构造 8 /// </summary> 9 /// <param name="port"> 端口</param> 10 public DataNode(ushort port) 11 { 12 this.Port = port; 13 this.Buffer = new List<byte>(); 14 } 15 16 public DataNode NextNode { get; set; } 17 18 19 public ushort Port { get; private set; } 20 public List<byte> Buffer { get; private set; } 21 22 23 public void InsertData(byte[] data) 24 { 25 this.Buffer.AddRange(data); 26 } 27 }
构建单向列表
/// <summary> /// 头部节点 /// </summary> private DataNode HeadNode; //当前比对的节点 private DataNode flagNode; public void InsertData(byte[] data, ushort port) { // 载荷数据判空 if (data == null || data.Length == 0) return; // 生成头部节点 if (HeadNode == null) { flagNode = new DataNode(port); HeadNode = flagNode; } // 插入数据 和 生成后续节点 if (port == flagNode.Port) { flagNode.InsertData(data); } else { var tmpNode = new DataNode(port); flagNode.NextNode = tmpNode; tmpNode.InsertData(data); flagNode = tmpNode; } }
TCP会话的转换
1 hp = new HttpHelper.HttpHelper(); 2 3 foreach (var rawPacket in PacketList) 4 { 5 Packet packet = Packet.ParsePacket(rawPacket.LinkLayerType, rawPacket.Data); 6 TcpPacket tcp = TcpPacket.GetEncapsulated(packet); 7 if (tcp != null)//TCP 8 { 9 hp.InsertData(tcp.PayloadData, tcp.SourcePort); 10 } 11 }
自此,我们已经获取到了,期望的数据结构模型,那么下一节就要着手开始提取HTTP信息了。
三,http特征量提取
我们知道常规的http协议包含消息头和数据两部分:消息头是由ASCII编码的多条命令行数据组成,每行之间使用CRLF(回车和换行)进行分割,消息头结束后,需要多增加一个CRLF,
一个典型的http请求与回复会话(根据上一节内容,这段数据,红色部分为头部节点,蓝色部分在第二个节点中)
有图可知,客户端发起一个 get 的请求(红色部分),该请求并没有携带数据;服务端回复 http/1.1 200 OK 表示已经接受请求,并返回数据(蓝色部分),服务请返回的数据除了http消息头 还有自己所带的数据。
我们可以很轻松的看到http协议具体的内容,但是对于数据部分是一对乱码,那么接下来任务就是要根据http消息头获取这些乱码的信息
因为本篇以实践为主,并不想讨论具体的http命令,所以此处只对部分命令做一些详细分析,其他命令,网上内容很多请自行搜索。
通过http协议对回复数据(第二个节点)查找到以下两个字段:
1. Content-Type: application/zip 通过查询MIME 我们知道这是一个*.zip文件,那也就是消息头后面的乱码事实上是一个zip压缩文件,
2. Content-Length: 7261 由此我们判断出这个zip文件的大小为 7261个字节
对于一些其他的命令对我们后续还原数据不大,直接跳过。
至此我们就找到了http协议特征量,但是因为http传输数据格式的多样性,传输过程的复杂性,有些服务器的http协议特征更为复杂
HTTP/1.1 200 OK Date: Wed, 11 May 2016 14:47:47 GMT Content-Type: text/html; charset=utf-8 Transfer-Encoding: chunked Connection: keep-alive Vary: Accept-Encoding Cache-Control: public, max-age=137 Expires: Wed, 11 May 2016 14:50:05 GMT Last-Modified: Wed, 11 May 2016 14:45:05 GMT Vary: * X-UA-Compatible: IE=10 Content-Encoding: gzip
这段http回复中可以看到数据类型变为 Content-Type: text/html外,没有了content-Length 字段,但是增加几条新的字段,分别是:
1. charset=utf-8 这部分对于数据类型为字符串的还原非常重要,尤其是对于非英语文本的还原,
2. Transfer-Encoding: chunked 这个字段,用于标出动态加载的数据 详细信息请参见:http://www.cnblogs.com/zhaozhan/archive/2010/08/24/1807639.html
3. Content-Encoding: gzip 为了缩小数据传输量,使用该字段进行数据压缩,所以我们在还原数据的时候遇到该字段就需要解压缩
好了,扯了一大堆,那我们开始准备提取这些特征字段吧
先定义一个http数据结构
接下来对上一节提到的数据链表以此进行消息头提取
1 /// <summary> 2 /// 获取http实体列表 3 /// </summary> 4 /// <param name="encode">预指定的文本编码方式</param> 5 /// <returns></returns> 6 public List<HttpEntity> GetHttpEntityList(Encoding encode) 7 { 8 List<HttpEntity> DataList = new List<HttpEntity>(); 9 10 for (DataNode tmp = HeadNode; tmp != null; tmp = tmp.NextNode) 11 { 12 for (int i = 0; i <= tmp.Buffer.Count - 4; i++) 13 { 14 if (tmp.Buffer[i] == 0x0D && tmp.Buffer[i + 1] == 0x0A && tmp.Buffer[i + 2] == 0x0D && tmp.Buffer[i + 3] == 0x0A) 15 { 16 string headStr = Encoding.ASCII.GetString(tmp.Buffer.Take(i + 1).ToArray()); 17 DataList.Add(getInnerStr(tmp.Buffer.Skip(i + 4).ToArray(), headStr, encode)); 18 break; 19 } 20 } 21 } 22 23 return DataList; 24 }
这里使用for循环,从链表表头开始以此查找两组CRLF(0x0D 0x0A 0x0D 0x0A)直到直到之后,截取前面的数据并使用ASCII进行解码为headStr ,而后面的数据就可以根据前面获取到的消息头进行进一步还原了,在 getInnerStr() 方法中完成
接下来我们继续看getInnerStr()方法的前半部分
1 private HttpEntity getInnerStr(byte[] data, string headFlag, Encoding defaultEncode) 2 { 3 4 //最终数据呈现 5 HttpEntity result = new HttpEntity(); 6 result.HeadStr = headFlag; 7 8 9 if (data == null || data.Length == 0) 10 return result; 11 12 StringBuilder sbr = new StringBuilder(); 13 14 //是否使用chunked 15 bool isChunked = headFlag.ToLower().Contains("transfer-encoding: chunked"); 16 bool isGzip = headFlag.ToLower().Contains("content-encoding: gzip"); 17 string contentType = ""; 18 string charSet = ""; 19 int ContentLength = 0; 20 Encoding enCode = defaultEncode; 21 var mtype = Regex.Match(headFlag, @"Content-Type:s*(w+/[w.-+]+)", RegexOptions.IgnoreCase); 22 if (mtype.Success && mtype.Groups.Count >= 2) 23 { 24 contentType = mtype.Groups[1].Value.Trim().ToLower(); 25 } 26 var Mchar = Regex.Match(headFlag, @"charset=s*([-w]+)", RegexOptions.IgnoreCase); 27 if (Mchar.Success && Mchar.Groups.Count >= 2) 28 { 29 charSet = Mchar.Groups[1].Value.Trim(); 30 if (charSet.ToLower() == "utf8") 31 { 32 charSet = "utf-8"; 33 } 34 try 35 { 36 enCode = Encoding.GetEncoding(charSet); 37 } 38 catch (Exception) 39 { 40 enCode = defaultEncode; 41 } 42 } 43 44 var MLength = Regex.Match(headFlag, @"Content-Length:s*(d+)", RegexOptions.IgnoreCase); 45 if (MLength.Success && MLength.Groups.Count >= 2) 46 { 47 ContentLength = int.Parse(MLength.Groups[1].Value.Trim()); 48 } 49 …………
这里使用正则表达式和字符串查找功能,在消息头中分别查找和去取对应的字段(对于字符编码,有些服务器使用的utf8 在解码是需要改为utf-8 否则会引起异常)
代码相对比较简短,这里就不做过多介绍了。
四,http数据解析(包括chunked 和 gzip)
通过上一节,我们已经拿到了http消息头的特征信息和数据,这部分就开始还原数据,具体有三个步骤:
1. 数据提取,数据提取基本就是找开始位置和数据长度,根据页面加载方式的不同,数据头提取分为 通过Content-Length 提取和 通过 Chunked 提取两种方法,
2. 数据解压缩,有时候我们获得的数据可能经过gzip压缩的,所以这个时候要对数据进行解压缩处理。
3. 数据还原,对于不同的数据有着不同的还原方式,转为字符串、保存为图片,生成文件等等
那接下来就开始看代码吧,首先我们补上上一节方法的后半部分
1 /// 数据整合 2 byte[] rawData = null; 3 if (isChunked)// 通过Chunked获取数据 4 { 5 GetchunkedData(data); 6 rawData = ChunkBuffer.ToArray(); 7 } 8 else if (ContentLength > 0 && ContentLength <= data.Length)//通过ContentLength 获取数 9 { 10 rawData = data.Take(ContentLength).ToArray(); 11 } 12 else 13 { 14 rawData = data; 15 } 16 if (rawData == null && rawData.Length == 0) 17 return result; 18 19 /// 数据解压缩 20 if (isGzip) 21 { 22 rawData = Tools.GzipDecompress(rawData); 23 } 24 if (rawData == null && rawData.Length == 0) 25 return result; 26 27 28 29 //获取扩展名 30 string extName = ""; 31 if (EimeData.EimeDic.Keys.Contains(contentType)) 32 { 33 extName = EimeData.EimeDic[contentType]; 34 } 35 result.ExtName = extName; 36 //获取数据或类型 37 switch (Tools.GetMimeType(contentType)) 38 { 39 case MimeType.Text: 40 result.Data = Tools.ShowPopFormStr(enCode.GetString(rawData)); 41 result.DataType = typeof(string); 42 break; 43 case MimeType.Image: 44 result.Data = rawData; 45 result.DataType = typeof(System.Drawing.Image); 46 break; 47 default: 48 result.Data = rawData; 49 result.DataType = typeof(byte[]); 50 break; 51 } 52 return result;
首先是整合数据,我们通过chunked、content-length等特征字段对获取的数据进行重新整合,这里涉及到Chunked数据整合,那让我们一起来看看代码吧
(具体chunked数据形式自行搜索)
1 /// <summary> 2 /// Chunked数据缓存列表 3 /// </summary> 4 List<byte> ChunkBuffer = new List<byte>(); 5 private void GetchunkedData(byte[] data) 6 { 7 if (data.Length == 0) 8 return; 9 for (int i = 0; i < data.Length; i++) 10 { 11 //查找CRCL标志 12 if (data[i] == 0x0D && data[i + 1] == 0x0A) 13 { 14 int count = data.Length - 2; 15 try 16 { 17 //获取下一段数据的长度 bytes -> ASCII字符 ->加上0x前缀 (如:0x34) -> 转为数值(下一块的数量长度) 18 count = Convert.ToInt32("0x" + Encoding.ASCII.GetString(data.Take(i).ToArray()), 16); 19 } 20 catch (Exception ex) 21 { 22 //产生异常的直接返回 23 ChunkBuffer.AddRange(data.Skip(i - 2).ToArray()); 24 break; 25 } 26 //如果为0表示完成Chunked数据转化跳出循环返回 27 if (count == 0) 28 break; 29 30 if (i + 2 + count <= data.Length) 31 { 32 //加入已经计算好的数据 33 ChunkBuffer.AddRange(data.Skip(i + 2).Take(count)); 34 //递归 进行下一轮匹配 35 GetchunkedData(data.Skip(i + 4 + count).ToArray()); 36 } 37 else 38 { 39 //对于存在数据不完整的 直接返回 40 ChunkBuffer.AddRange(data.Skip(i - 2).ToArray()); 41 } 42 break; 43 } 44 } 45 }
在这里预先定义一个缓存列表 ChunkBuffer, 然后通过GetchunkedData()方法递归调用最后获取到需要的数据。
此时我们已经获取到了数据,那么就可以考虑是否解压缩的问题了,解压缩比较简单,这里依然直接贴代码了
1 /// <summary> 2 /// 通过Gzip方式解压缩数据 3 /// </summary> 4 /// <param name="data">字节数据</param> 5 /// <returns></returns> 6 public static byte[] GzipDecompress(byte[] data) 7 { 8 //解压 9 using (MemoryStream dms = new MemoryStream()) 10 { 11 using (MemoryStream cms = new MemoryStream(data)) 12 { 13 using (System.IO.Compression.GZipStream gzip = new System.IO.Compression.GZipStream(cms, System.IO.Compression.CompressionMode.Decompress)) 14 { 15 byte[] bytes = new byte[1024]; 16 int len = 0; 17 int totalLen = 0; 18 //读取压缩流,同时会被解压 19 try 20 { 21 while ((len = gzip.Read(bytes, 0, bytes.Length)) > 0) 22 { 23 dms.Write(bytes, 0, len); 24 totalLen += len; 25 } 26 } 27 catch (InvalidDataException ex) 28 { 29 //dms.Write(data.Skip(totalLen).ToArray(),0,data.Length-totalLen); 30 } 31 } 32 } 33 return dms.ToArray(); 34 } 35 }
这样我们就拿到了真正需要的数据了
最后我们就可以通过 Content-Type 来判断将数据还原为那种类型,就这样我们就获得了需要的数据
接下来我们看看解析完的数据
首先是zip的数据:
导出为文件后
最后是哪个带有Chunked 和gzip 标志的数据还原
五,更加可靠的数据还原
通过上面的数据还原方法,已经基本满足我们的数据还原需求,但是因为网络、计算机、以及我们程序本身的问题,我们拿到的数据包并不会严格的按照发包的先后顺序获取到数据包,而且因为TCP协议确认重传,有可能发生在某个数据包中,及发送端发送了一个1440的数据包,接收端有可能只取700个字节,剩余的需要重现传输,对于监控端,如果没有处理这种情形的机制,就会造成数据差生误差而不能进行正确还原,更别说还有丢包的情况。
而这种机制就是TCP的数据重组,通过建立合理的模型对tcp状态迁移、序列号、数量等一系列变量进行统一的规划提取还原,最终生成完成而且正确的数据。因为本篇只是简单讨论http数据的还原,所以此处不打算深入的展开, 如果有时间,再写一篇关于TCP重做的文章吧。
感谢你的阅读,欢迎使用NetAnalyzer,欢迎关注NetAnalyzer公众平台。
NetAnalzyer交流群:39753670 (PS 只提供交流平台,群主基本不说话^_^)