朋友说视频号下载了全是乱码,我看了前八个字节说:没坏

前两天有个朋友找我,说他从某个App里扒出来一个视频文件,后缀是mp4,但是用任何播放器都打不开。他用十六进制编辑器打开一看,全是乱码,一个能认出来的字符都没有。

他问我,这玩意儿是不是坏了。

我说你把前八个字节发我看看。

他发过来。00 00 00 18 66 74 79 70。

我说,没坏。这是个正常的MP4。

他一脸问号???

就这八个字节,够你用一辈子
就这八个字节,够你用一辈子

今天想聊的,就是这件事。

不是MP4本身,而是一个更通用的方法论。当你面对一个完全陌生的二进制文件格式,不知道从哪下手的时候,怎么在一分钟之内找到方向。我不知道这个方法对大家有没有用,但确实是我自己反复在用的一个小套路,可能有些想法还不成熟,权当分享。

先说MP4这个格式本身。

你每天看的视频,不管是抖音B站还是微信群里随手转的,底层几乎全是MP4。它正式的名字叫ISO Base Media File Format,简称ISO BMFF,是ISO/IEC 14496-12标准定义的。MP4只是这个基础格式的一个扩展,跟它同族的还有苹果的MOV、HEIF照片(就是iPhone那个Live Photo的底层格式),甚至JPEG 2000也用了类似的骨架。

这里面有个挺有意思的历史。MP4不是从零设计的,它直接脱胎于苹果1991年的QuickTime文件格式。Apple当时管那些数据单元叫atoms,后来ISO标准化的时候改名叫boxes。你在网上搜资料的时候会看到这两个词混着用,别慌,它们就是同一个东西。

但这些都是后话。你只需要记住一件事。

所有这些格式,不管是谁产生的,不管是相机、手机、还是CDN分发系统,文件开头永远是一样的八个字节的结构。四个字节的大端序size,紧跟着四个字节的ASCII标签ftyp。

这不是巧合。这是格式定义本身的保证。

size + ftyp,永远的开场白
size + ftyp,永远的开场白

你想想看,这有多难得。你面对一个黑盒,你连它里面装的是什么都不知道,但有一样东西你可以闭着眼睛押注,就是它的前八个字节长什么样。这不是你碰运气碰出来的,是格式规范白纸黑字写死的。

这就是MP4(以及它整个家族)的设计哲学,boxes。

每个box的形状一模一样,三段式。四个字节size,四个字节type,剩下全是payload。size告诉你这个box有多大,type告诉你这个box是什么,payload就是这个box装的东西。如果box的size是1,意思是真正的size在后面的八个字节里,用64位存,这是给超过4GB的大文件准备的。如果size是0,意思是这个box一直延伸到文件末尾。其他情况,size就是字面意思。

就这么多。这就是整个语法的全部。

TLV,逆向你最好的朋友
TLV,逆向你最好的朋友

这种设计模式在工程里有个名字,叫TLV,Type-Length-Value。不是MP4发明的。网络协议里到处都是,TLS、SSH、RADIUS、IS-IS,全用TLV。ASN.1的DER编码也是TLV,你看的每一个HTTPS证书,底层都是TLV嵌套出来的。

为什么TLV这么受欢迎?因为它自描述。你读到一个TLV,你不需要知道它后面的内容长什么样,你只需要读Type,读Length,然后跳过Length个字节,就是下一个TLV。你不需要理解整个协议才能解析它,你可以只挑你认识的部分看,不认识的部分跳过去就行。

这跟XML的思路其实很像,只不过XML是文本的,TLV是二进制的。XML里你不认识的tag可以跳过,TLV里你不认识的type也可以跳过。但TLV更小更快,因为不需要存尖括号和属性名那些开销。

回到MP4这块。

既然每个box都是TLV,那你只需要一个很小的函数就能遍历整个文件的顶层结构。我贴一段Python,你感受一下有多短。

import struct

def walk_boxes(data, offset=0, end=None):
    end = len(data) if end is None else end
    while offset < end:
        size, box_type = struct.unpack_from(">I4s", data, offset)
        if size == 1:
            size = struct.unpack_from(">Q", data, offset + 8)[0]
        elif size == 0:
            size = end - offset
        yield box_type.decode("ascii""replace"), offset, size
        offset += size

十四行。

没有依赖,没有库,不需要知道任何关于视频编解码的知识,不需要关心里面装的是H.264还是HEVC还是什么乱七八糟的私有格式。你就能把任何一个MP4家族文件的顶层box全部列出来。

十四行,没有依赖
十四行,没有依赖

我第一次看到这段代码的时候,说实话有点被震到了。不是因为代码写得多巧妙,而是因为它揭示了一个事实,这么一个覆盖了全球绝大多数视频的格式,它的骨架简单到十四行Python就能走完。

好,那走完之后你看到了什么?

通常你会看到三个顶层box。一个一个说。

第一个,ftyp。这就是开头那八个字节所在的box,它告诉你这个文件是什么品牌,声称兼容什么。isom、mp42、M4V,这些brand字符串就是文件的身份证。你甚至不需要解析它,看一眼前几个字节的ASCII就行,零成本指纹识别。这是最便宜的情报,八个字节,零解析。

第二个,moov。这是整个文件的元数据树。音轨有几条、视频有多长、每一帧在文件的什么位置、每一帧多大、时间戳是多少,全在这里面。这是一个完整的索引,告诉播放器怎么去mdat里找数据。moov里面嵌套了好几层,trak对应每一条轨道,trak里面又有mdia,mdia里面有minf,minf里面有stbl,stbl就是sample table,是最终指向mdat里每一个数据块位置的那张表。

第三个,mdat。这是真正的媒体数据。视频的每一帧编码后的字节、音频的每一帧,全堆在这里面。对播放器来说,mdat就是一堆需要被解码的原始字节,没有moov的索引,它不知道哪段字节对应哪一帧。

这个分工是理解整个格式的钥匙。

moov是地图,mdat是宝藏。moov是明文的结构,mdat是可能被动手脚的payload。

为什么这么说?

因为绝大多数对MP4做加密或混淆的App,都只会动mdat,不会动moov。原因很简单,moov是播放器seek的命脉。你拖进度条的时候,播放器需要查moov里的sample table才能知道跳到文件的哪个字节去读。如果moov被加密了,播放器连进度条都拖不了,包括App自己的播放器。打破文件的可seek性,对App来说成本太高了,每一个播放器,包括它自己的,都得改。

而mdat呢,对通用解码器来说本来就是一堆需要配合codec才能理解的字节。App在mdat上做自己的scrambling,通用播放器本来也读不懂,不影响App自己的播放器跟自己的scrambling配合。

所以你看到的模式几乎总是,明文的结构包裹着一个被混淆的payload。

一旦你知道这个,你就知道该跳过哪个box,该攻击哪个box了。

moov是地图,mdat是宝藏
moov是地图,mdat是宝藏

说到这里,回到开头那个问题。

为什么我朋友发给我的文件”全是乱码”,但我说它是正常的MP4?

因为他看的是mdat区域。那个App确实对mdat做了自己的处理,所以那部分字节看起来像噪声。但文件开头的ftyp没动,moov大概率也没动。他只是看错了地方。

而ftyp这八个字节,不仅仅是一个文件格式的标识。对于做逆向的人来说,它是一个免费的已知明文。

这个词听起来很学术,但其实概念特别朴素。

你面对一段加密过的数据,如果你能确定其中一小段明文是什么,你就有了一个楔子,可以用来推断加密的方式或者密钥。密码学里管这叫known-plaintext attack,已知明文攻击。那个已知的明文片段,有个专门的名字,叫crib。

这个词的来历挺有意思。

二战的时候,布莱切利庄园,就是Alan Turing他们破译德军Enigma密码那个地方。他们管那些猜测出来的明文片段叫crib。这个词本来是学生作弊用的俚语,意思是偷看来的答案,后来被密码学家借过去了。

德国人每天固定时间发天气预报,格式是固定的。布莱切利的人知道天气报告的开头一定是某个固定的措辞,比如FORTY WEEPY WEEPY这种格式化的前缀(FORT是Fortsetzung的缩写,意思是续报,后面跟时间)。这就是他们的crib。拿着这个crib去对密文,就能反推出Enigma那天的转子设置。

crib,偷看来的答案
crib,偷看来的答案

你想想看,MP4的ftyp不就是这么个东西吗。

66 74 79 70,就是ftyp这四个字母的ASCII。你还没读文件的一个字节,你就已经知道了,这个文件如果真的是MP4,那它在靠近开头的位置一定有这四个字节。如果整个文件被加密了,你拿这四个字节当crib,就能开始你的工作。

而且不止这四个字节。ftyp前面还有size字段,常见的brand字符串就那么几种,isom、mp42、M4V,每种对应的size值是一个很小的可枚举集合。你甚至可以连size带ftyp一起当crib,一次性拿到八个甚至更多字节的已知明文。

免费的。

不需要打开调试器,不需要逆向任何代码,格式规范白纸黑字写在那儿,你只需要知道这件事。

我有时候觉得,逆向工程里最值钱的不是什么高超的技巧,而是这种”我知道一个你不知道的确定事实”的瞬间。你不需要比对方聪明,你只需要比对方多知道一个不变量。

好,那把这个思路拔出来,它就不止适用于MP4了。

当你面对一个陌生的二进制格式,不要一上来就钻进细节。先找骨架。找那种格式规范写死的、不可变的部分。那种不管文件内容怎么变、不管谁产生的、都一定存在的东西。

找到了骨架,你就有了方向。你知道哪部分是明文结构可以快速读懂,哪部分是被动手脚的payload需要重点攻击,以及最关键的,你已经手握一段免费的已知明文了。

这个方法不限于MP4。PNG的前八个字节永远是89 50 4E 47 0D 0A 1A 0A。ZIP文件开头永远是PK两个字母(50 4B)。Java的.class文件开头永远是CAFEBABE。GIF开头永远是GIF。PDF开头永远是%PDF。RIFF家族(WAV、AVI)开头永远是RIFF。

每个格式都有自己的ftyp。

你可能会有小伙伴纳闷,那如果文件的magic number也被改了呢?如果对方故意把前八个字节也抹掉了呢?

怎么说呢,确实有这种情况。但你要知道,改magic number的代价是文件连自己人都认不出来了。一个App如果把自己的MP4文件头都改了,那它自己的播放器也得先做一层还原才能播放,这增加了它自己的复杂度。大多数App不会这么做,因为收益不抵成本。而且就算它改了,你还有moov可以看,moov里面的box type(trak、mdia、stbl这些)也是固定ASCII,也是crib。一层一层找,总能找到它没法改的部分。

这就是固定结构的力量。它不是某一行字节可以被利用一次,而是整个骨架都可以被你反复利用。因为格式定义了它,而格式是公共的。

坦率的讲,这个方法一开始用起来可能会有点笨拙。你得自己去翻规范,自己去验证哪些字段是固定不变的,哪些是可变的。第一次对着一个新格式做这件事,花的时间可能比你直接用现成的解析库还长。你会觉得,我直接ffmpeg -i 或者用个Bento4不就完了嘛,干嘛要自己手写walker。

这话没错。如果你只是想提取视频或者转码,用现成工具当然更快。但如果你面对的是一个被App改过的文件,现成工具大概率直接报错退出,因为它认不出那些被动手脚的部分。这时候你就得自己下场了,而那个十四行walker就是你的起点。

而且这是一次性投入。你做过一次MP4,再做MOV就是秒懂,再做HEIF也差不多。因为它们共用同一套box语法,你的那个walker可以直接复用。你投资一次,回报覆盖整个格式家族。

说真的,这个方法的真正价值不在于帮你省了多少时间,而在于它给你一种确定感。面对一堆看起来像噪声的字节,你不再是两眼一抹黑。你知道该先看哪里,知道什么是可以信任的,什么是需要怀疑的。这种确定感,是让你敢往下走的第一步。

我自己也不是什么逆向工程专家,半路出家那种。但这个小套路确实帮过我不少忙,所以拿出来分享。可能有些地方说得不够严谨,欢迎懂行的兄弟指正。

回到我那个朋友。

我跟他说完这些之后,他用那十四行代码跑了一下,发现文件里ftyp在、moov在、mdat也在。moov里的sample table完好无损,清清楚楚地指向mdat里的各个位置。他顺着moov的索引去看了mdat里对应的字节,发现确实被App做了某种变换,但变换的规律并不复杂,因为sample的size和偏移量都是对的,只是每个sample内部的字节被重新排列了。

他花了大概一个下午,就把那个变换逆了出来。

后来他跟我说,最让他觉得值钱的不是最后那个结果,而是最开始那个瞬间。就是他意识到,这堆「乱码」其实有一个可以信赖的骨架,他不需要从零开始。

不需要从零开始
不需要从零开始

我觉得他说得挺好的。

这其实是一个挺普遍的道理。你面对任何复杂的东西,不管是二进制文件还是别的什么,最吓人的永远是那种「全是噪声、毫无头绪」的感觉。但几乎总是,噪声底下藏着结构。而结构,永远是你的入口。

Turing他们在布莱切利庄园面对Enigma的时候,也是这个处境。密文看起来是随机的,毫无规律,但你只要找到一个crib,一个你确定知道的明文片段,整件事就开始松动了。

ftyp就是MP4的crib。

每个格式都有自己的crib。

找到它。

/ 注意,本项目仅在 SyncMein 的500人小群分享。

/ 谢谢你看完了我的文章,我们下次再见吧。

/ 作者:明察 / 投稿或爆料,请联系邮箱:mingcha@gqmg.com

– END –

© 版权声明
THE END
喜欢就支持一下吧
点赞6赞赏 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容