Synalyze It!的实现

1、起因

从今年1月1日开始,打算选择一个自己一直想做的,又不那么容易完成的事情,于是想到了以前多次动手,但是从未完成的软件“任意文件格式的文件结构分析工具”。

2009年:

  • 计划解析swf文件内的图片,音频资源,手工分析了20%左右,没有时间继续分析
  • 分析了不少音视频格式与它的封装 (RTMP, mp3, mp4...)
  • FFmpeg中有个缺陷,aiff音频文件的总时长没有写入文件,任何播放器都无法拖动进度条,需要在生成的文件中打补丁写入总时长
  • 看到CSDN上有人做过类似的软件,能分析固定大小,顺序的的结构体,功能相对单一,还不能满足需求

2014年(约)

  • iOS系统解析m4a文件,有时不能及时获得总时长与seek点,需要手工分析。

2016年

  • 搜索到了一款软件 Synalyze It!,这就是我想要的完美软件,凭着爱心我购买了,这个软件的核心是利用固定的分析引擎,把语法文件(.grammar)被分析文件(.mp3, .mp4)*粘合在一起,生成一个新的文件结构树,每一个数的枝叶对应到被分析文件的具体某个字节,并标明含义,该软件特别之处包含:
  1. 支持Python和lua脚本,方便在复杂的条件下进行具体分析,比如zip文件需要先分析文件末尾,PE文件包含结构体循环嵌套。
  2. 部分属性,比如结构体长度,循环次数支持表达式,这就为大部分通用容器类文件提供了很好的支持,因为结构体的大小依赖于结构体内第一个或者第二个元素的值。
  3. 支持结构体派生,减少了重复劳动,比如PNG文件的结构,都是存在基类,根据基类中的第一个字段来决定具体是用哪一个类
  4. 支持分析某一个bit的值

2017年

  • 多次想动手写,一共写不超过100句话,总是放弃了,突破不了自己的思维局限,也没有时间
  • 因为手机需要支持wma,分析了一遍wma文件格式,分析过程也花了不少时间

2019年

  • 分析pdf文件

2021年

  • 计划做一个和Synalyze It!一模一样的软件,不能马马虎虎的做,做之前就打算再做好之后要回忆做的过程中的思考,也能预测到做完之后就不想回忆了,这是很矛盾的。
  • 不要自己创新,完全抄袭,我不可能做的比它更好

2、现状

经过一个来月的努力,每一次技术的选择都很精心准备,已经完美支持Synalyze It!软件中核心功能,不过还是控制台的,没有GUI,如下的截图。

解析jpg文件

3、过程

3.1 技术选型

主要语言:
常用的可以发布到Store的语言包含C/C++,C#,OC,Swift,因为C/C++太啰嗦,OC和Swift在Windows平台上没有优秀的IDE支持,所以选择C#语言,C#语言听说也可以编译成Apple Store的包,自己曾经试过的确可以开发Mac App应用。
脚本语言:
程序中有三个地方用到了可能是脚本的地方

  1. 结构体或者结构体内成员的属性,比如占用字节长度,重复次数,可能是表达式比如,根据Synalyze It!的文档,没有说明用的是什么语法,只是说明支持的表达式很多,但都是一句话表达式
// 各种表达式
used_bytes_count + 5
ceil(sqr(used_bytes_count)) + offset
  1. 结构体内可能包含一个script代码,该script只解析该结构体内的一个内容,不解析结构体外的内容,比如把刚解析出来的unix time转换成 UTC 字符串作为结果显示到结果树中, 这一段代码能用到的函数比较多,都在帮助文档中说明,能用到的全局变量并没有说明,需要阅读代码,比如:
# 复杂的script代码
endFound = False
theOffset = currentOffset
byteView = currentMapper.getCurrentByteView()
results = currentMapper.getCurrentResults()
grammar = currentMapper.getCurrentGrammar()

while not endFound:
    theByte = byteView.readByte(theOffset);
    if (theByte == 0xff):
        theSecondByte = byteView.readByte(theOffset + 1);
        if ((theSecondByte > 0) and (theSecondByte < 0xFF)):
            endFound = True
            theValue = Value();
            theValue.setString("EOF");
            struct = grammar.getStructureByName("ImageBytes")
            element = struct.getElementByName("ImageBytes")
            length = theOffset - currentOffset
            currentMapper.mapElementWithSize(element, length);

    theOffset = theOffset + 1;

该代码中currentOffset的含义需要多阅读积分代码确定

  1. 作为文件级别通用的script代码, 这类script有固定的函数申明,用于处理指定区间内的数据
# 解析指定区间内的数据
from datetime import datetime, timedelta

def parseByteRange(element, byteView, bitPos, bitLength, results):

    timeStamp = byteView.readUnsignedInt(bitPos/8, 4, ENDIAN_LITTLE)

    value = Value()

    if (timeStamp != 0):
        dt = datetime.fromtimestamp(timeStamp)
        dtAdjusted = dt - timedelta(hours=1)
        dateString = dtAdjusted.strftime("%Y-%m-%d %H:%M:%S")
        value.setString(dateString)
    else:
        value.setString("<not set>")

    results.addElement(element, 4, 0, value)

    return 4

对于2,3的script代码,已知的语法有python和lua,分别有110多个和40多个,那么现在要选择什么作为脚本语言呢?有几种选择

  • C# 非常容易和主要语言C#集成,可惜Store版本中不能把C#作为脚本语言,因为不支持动态生成可执行代码
  • Javascript 已知的实现JINT库,这个库的代码写的非常漂亮,没有任何使用特殊权限的地方,所以不会和Store的限制相互冲突,也很方便自定义新的函数来满足2中提到的表达式,唯一的缺陷是需要把现有的150个脚本手工写成Javascript的,写代码不会花费很长时间,问题在于很多文件格式没有现成的,难以把握质量
  • Python C#语言库中成熟的Python库是IronPython,需要测试是否和Store限制冲突,因为我们知道Python中有很多Process,Event相关的内容,肯定是无法提交到Store的,于是找到了IronPython源代码,尝试去掉那些功能,这里花了不少时间,具体过程如下
  1. 现在源代码编译,编译不过,不像Javascript的JINT库,没有其他依赖,IronPython依赖Microsoft DLR库,这个DIR库目前只给IronPython和IronLua用,还不清楚DLR库离开了这两个实现要怎么用
  2. 测试是否可以自定义模块,自定义函数
  3. 编译通过后,找到启用新进程部分,发现屏蔽方式是依靠条件编译,找到了条件编译文件是在.sln目录下的一个固定文件名叫做Directory.Build.props,找这个文件花了一些时间,因为这个文件不在解决方案目录树中,无法在IDE中搜索到,说明了一切隐含的东西,都会让人迷糊,比如C++的那么多奇怪的隐藏行为
  4. 不断的调整条件编译和编译Store包,用测试程序自检
  • Lua 前期并没有打算实现Lua脚本部分,后来也是懒得写40多个脚本,Lua的C#实现比较好找,为了方便查找Exception,也是找了源码来参与编译。

所以脚本最后的选择是Python+Lua

3.2 主要难点

3.2.1 结构体的派生

问题:
已知两个结构体parent和child,child从parent派生,parent定义了确定的元素和顺序a,b,c, child定义可能是以下情况

  1. 只包含parent中的元素,元素的顺序保持不变
  2. 只包含parent中的元素,元素的顺序和parent中不同[难]
  3. 不包含parent中任何元素
  4. 即包含parent中的元素,又包含新元素,而且parent中元素在先定义,且顺序保持不变
  5. 即包含parent中的元素,又包含新元素,而且parent中元素在先定义,顺序发生变化[难]
  6. 即包含parent中的元素,又包含新元素,而且parent中元素定义位置不确定,顺序也不确定[难]

想起6那种复杂的场景,虽然人工可以简单的感觉到答案,但是代码实现优雅,不容易想到,于是先写好了测试用例

// 派生关系的测试用例
public static void TestJoinKeys()
{
    List<(string, string, string)> data = new List<(string, string, string)>()
    {
        ("","",""),
        ("1","","1"),
        ("1,2,3","","1,2,3"),
        ("1,2,3","1,2,3","1,2,3"),
        ("1,2,3","1,2,3,4","1,2,3,4"),
        ("1,2,3","2","1,2,3"),
        ("1,2,3","2,3","1,2,3"),
        ("1,2,3","2,3,4","1,2,3,4"),
        ("","1,2,3","1,2,3"),
        ("1,2,3","1,3","1,2,3"),
        ("1,2,3,4,5","2,4","1,2,3,4,5"),
        ("1,2,3,4,5","1","1,2,3,4,5"),
        ("1,2,3,4,5","1,2","1,2,3,4,5"),
        ("1,2,3,4,5","5","1,2,3,4,5"),
        ("1,2,3,4,5","7,8","1,2,3,4,5,7,8"),
        ("1,2,3,4,5","1,2,7,8","1,2,3,4,5,7,8"),
        ("1,2,3,4,5","1,3,2","1,3,2"),
        ("1,2,3,4,5","3,2","1,3,2"),
        ("1,2,3,4,5,6,7,8","3,5,7","1,2,3,4,5,6,7,8"),
        ("1,2,3,4,5,6,7,8","2,4,9,10,11,12","1,2,3,4,5,6,7,8,9,10,11,12"),
    };
    foreach(var dataItem in data)
    {
        var parent = dataItem.Item1.Split(',').Where(s => s.Length > 0).ToList();
        var current = dataItem.Item2.Split(',').Where(s => s.Length > 0).ToList();
        var result = dataItem.Item3.Split(',').Where(s => s.Length > 0).ToList();
        var joined = JoinKeys(parent, current);
        Debug.Assert(joined.Count == result.Count);
        foreach(var i in Enumerable.Range(0, joined.Count))
        {
            Debug.Assert(joined[i] == result[i]);
        }
    }
}

最后总结出还算合理的规则,如下

// 派生关系的合并
while (parent.length > 0 && child.length > 0)
{
      if (parent或者child其中一个长度为0)
      {
          把另外一个全部添加到结果列表
          退出循环
      }
      if (parent.first == child.first)
      {
            则加入到结果,同时parent和child都删除第一个元素       
            继续循环     
      }  
      else
      {
          if (child.contains(parent.first))
          {
              则说明child已经开始自定义剩下的部分了,把child剩下部分都加入到结果列表
              退出循环
          }
          else
          {
              则说明child没有覆盖定义parent中该元素定义,把parent中的第一个元素加入结果列表
              继续循环
          }
      }
}

3.2.2 结构体占用空间的计算

结构体占用空间的定义有三种

  1. 写明了多少字节,比如12字节,或者0x22字节
  2. 没有定义,说明是根据元素占用来计算,比如子元素一共占用12字节,说明结构体一共占用12字节
  3. 是一个表达式,表达式可能是之前已经计算过的其他元素的值,也可能是该结构体子元素的值,也就意味着他的值需要等他的子元素解析后才知道结果。

前两者的实现比较容易,第3种中,首先需要分析出表达式中所有的变量,变量是否已经定义,行为有所不同,如果遇到子元素名称和以往解析过的名称相同,该使用哪一个值呢?因此,我解析出了Synalyze It!中所有的表达式1000多个,利用简单而实用的函数解析出所有的变量

static List<String> GetVarsFromExpression(string expression)
{
    HashSet<string> functionNames = new HashSet<string>() { "ceil", "pow", "mod", "select", "if", "abs", "prev", "this", "Math.", "this." };
    List<String> r = expression.Split(new char[] { '+', '-', '*', '/', '(', ')', ',', '^', '.' })
        .Select(item => item.Trim())
        .Distinct()
        .Where(item => item.Length > 0)
        .Where(item => functionNames.Contains(item) == false)
        .Where(item => Char.IsDigit(item[0]) == false).ToList();
    return r;
}

检查每一个变量如果同时出现在子元素和其他结构体的行为,具体分析后得出结论:如果变量存在于子元素,则一定意味着子元素,不意味着其他结构体中已解析变量。

那么该如何写代码呢?难道每解析一个子元素就尝试计算一次表达式,如果该有的变量还没有定义,解析程序会抛出异常,异常内容并非一个结构化数据,不方便知道是不是那个变量没有定义引起。

定义集合,集合包含了需要解析的子元素列表,当最后一个需要解析的子元素解析了之后,再计算结构体长度,这里遇到一个另外一个问题,解析出来的长度可能小于已经解析过的子元素需要的长度,或者长度大于该结构体所可能的最大空间,这时,整个结构体都是需要抛弃的。

// 检查需要解析的子元素是否都解析好了
HashSet<string> depends = current_result.dic_attribute_depends_map[elementKey];
depends.ExceptWith(lst_just_finished_sub_element_name);
if (depends.Count == 0)
{
    long? tmp = mapContext.scriptInstance.GetScript(ScriptEnv.python).EvalExpression(s);
    Debug.Assert(tmp != null, "全局变量缺失");
    SetDefinedLength(tmp.GetValueOrDefault(0));
    continue_get = false;
    return;
}
else
{
    // 继续等待
    return;
}

3.2.3 获取从任意bit开始,小于64bit长度的数值

因为元素的长度可能只有1bit,而不是1byte,所以整个程序的度量单位必须定义成bit.
如何获取数值呢?
首先找到这么多长度的bit,如果bit的长度刚好是8的倍数,还比较好处理,如果不是8的倍数,需要考虑填充bit,使得取得的长度是8的整数倍,这些0填在哪里呢?是填入到最前面,还是最后面,依赖于数值的Endian类型,还有一个特例,如果总位数小于8,则总是左侧补0, 代码来说大概是这样

// bit长度补齐为8的倍数
if (bit length不是8的倍数)
{
      if (是大端 or bit length < 8)
      {
          左侧补0  
      }
      else
      {
        右侧补0
      }
}
根据Endian类型转成数值

由于年事已高,很难一次性推理出是左侧补0还是右侧补0,答案都是实验出来的。

3.2.4 越界保护

程序中的主要越界保护用于两个地方

  1. 已确定占用空间的某个元素,如果想获取它的值(数值,字符串),需要遍历,写for语句容易访问到界外,比如原本只占用2个字节的元素,想获取它的Int32值,容易多访问两个字节
  2. 当结构体大小不确定的时候,需要尽可能的给结构体分配空间,这时会假定结构体大小是Int64.Max, 如果把Int64.Max用于数值下标,会出现异常。
    还好强大的C#可以一句话解决,那就是array.skip(m).take(n), 经过测试无论m和n怎么越界都不会出现异常,如果越界会尽可能的获取,或者返回空的数组,这也是为什么我选择C/C++的一个重要原因,需要写太多的保护逻辑了
// 越界的简单处理,一定不会越界
byte[] bytes = byteView.Take(8).ToArray();
if (endianType == ENDIAN_TYPE.ENDIAN_LITTLE)
{
    Array.Reverse(bytes);
}
Int64 v = 0;
foreach (byte b in bytes.Take(8))
{
    Int64 t = b;
    v = v * 256 + t;
}
return v;

3.2.5 Script的集成

script的集成主要准备好script环境,把script可能要使用的变量准备好,这需要多看script代码才知道要在程序中定义哪些变量,答案是

  1. 所有已经解析过的number类型字段的值需要定义到script环境中
  2. 需要定义变量currentOffset,表明当前分析到哪个字节了
    这两种的定义方法如下
// 为脚本定义变量
IScript script = mapContext.scriptInstance.GetScript(this.scriptLanguage.ToLower() == "python" 
                                                        ? ScriptEnv.python 
                                                        : ScriptEnv.lua);
script.SetValue("currentOffset", currentOffset);
  1. 需要定义一些全局变量,比如ENDIAN_LITTLE,有两种定义办法,一种是定义ENDIAN_LITTLE为string类型,一种是定义为枚举类型,如果定义成枚举类型,script中需要增加枚举类型前缀变成ENDIAN_TYPE.ENDIAN_LITTLE,为了不修改script,定义方法如下
//  在IronPython.Modules工程内添加代码文件,如下
namespace IronPython.Modules 
{
    public partial class file_structure_plugin
    {   
        public static readonly string ENDIAN_LITTLE = "ENDIAN_LITTLE";
        public static void logMessage(String module, int messageID, string severity, String message)
        {
            // TODO
        }
        ...
    }
}

通过上面的代码可以看到,我们也为Python脚本添加了全局函数logMessage

lua脚本中添加ENDIAN_LITTLE的方式暂时没有实现,等需要的时候再实现。

3.2.6 调试

整个调试过程,都是先运行一遍,看结果和Synalyze It!从哪个地方开始不一样的,不一样的地方,增加条件断点,因为整个引擎的核心地方可能就是1-2个循环,检查的地方就是那1-2个函数对于结构体复杂的文件,那简直就是圆环套圆环,头很晕,,只能用结构体id或者文件偏移当做断点的条件,一旦有修改,则把之前已经测试过的文件重新运行一遍,对关键地方进行重新检查。

// 很多测试用例
static void Main(string[] args)
{
    ElementStructure.TestJoinKeys();        // 结构体派生

    test_bit_padding();                     // bit位数不是8倍数时候的填充            
    test_all_element_express_is_number();   // 所有表达式是否能被识别
    test_all_script_content();              // 所有的脚本的类型
    test_offset_elements();                 // Offset类型的元素
    testIronPython();                       // IronPython是否支持import语法

    test_parse_one_file("jpeg.grammar",         "1.jpg");    
    test_parse_one_file("pe.grammar",           "1.dll");   // 复杂的结构体
    test_parse_one_file("gzip.grammar",         "1.gz");    // custom script
    test_parse_one_file("png.grammar",          "1.png");   // "must_match == true"
    test_parse_one_file("wav.grammar",          "1.wav");   
    test_parse_one_file("mp3.grammar",          "1.mp3");   // python script
    test_parse_one_file("qt.grammar",           "1.mp4");   // 复杂的结构体
    test_parse_one_file("flac.grammar",         "1.flac");  // 解析bit
    test_parse_one_file("zip.grammar",          "1.zip");   // lua script
    test_parse_one_file("test_padding.grammar", "1.flac");  // alignment
    test_parse_one_file("bitmap.grammar",       "1.bmp");   // offset
    

    Debug.WriteLine("Hello World!");
}

4 总结

  • 要想做的心里踏实,把事情调查清楚,的确需要花费很多时间
  • 测试用例还是需要写,多多的写
  • 对于可能发生的,但是不应该发生的,多多写断言
  • 今年心愿已了,明年还有机会的话再写另外一个心愿

5 担忧

版权

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354

推荐阅读更多精彩内容