30

一封钓鱼邮件带来的乐趣

 4 years ago
source link: https://www.tuicool.com/articles/jQVRRrn
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

整个六月你我都很忙,你忙着钓鱼、我忙着封锁 IP,一份邮件把我从紧张的氛围中拉了出来,邮件大意,“蓝队的朋友想要加分么?你如果能协助国际刑警修复从犯罪嫌疑人电脑上取证的受损图片,那么国内 GA 可以在本次 HW 行动中为你加分,国际刑警求助信息 We received this PNG file, but we’re a bit concerned the transmission may have not quite been perfect,受损图片见附件”。

无疑,这是一封来自红队的钓鱼邮件,收件是我的办公邮箱 [email protected],发送人邮箱地址用 swaks 伪造为 @110.com 的邮件域名,我在沙盒中简单分析了下附件图片,文件类型幻数 89 50 4E 47 0A 1A 0A 看起来像似 PNG,IHDR、IDAT、IEND 等等关键数据块也有,所以,从框架上来说,它应该是个 PNG 格式的图片;另外,IEND 后,出现了大量 powershell 脚本,我基本上可以研判,它是用 Invoke-PSImage 制作的图片马。传统意义上的图片马,指的是,将一句话写入图片,图片上传至 web 目录,结合 web 的解析漏洞或者文件包含漏洞,实现 getshell 的目的;而钓鱼邮件中的图片马并非此类,它内嵌反弹命令的 shellcode,用 wireshark 抓取 CC 的 IP,结合威胁情报,可溯源到红队归属的安全企业。

溯源不是本文的重点,我搜索了国际刑警的留言信息,找到了原始图片,原本是 PlaidCTF 在 2015 年出的一道取证的题目 PNG_Uncorrupt,刚好 HW 结束了,我得给自己找找乐子,在几乎安全取证相关技术背景为零的情况下,决定研究下这道题目,或许能获得新知识。

题意可知,文件传输可能导致该 PNG 文件破损,得尝试修复。先确认文件类型: Zv2YJjI.jpg!web

file 命令通过文件类型幻数分析文件类型,既然它无法识别,可能幻数错误,确认下: YrQB7fB.jpg!web 这与 PNG 正确的幻数 89 50 4E 47 0D 0A 1A 0A 不一致,缺少 0D,用十六进制工具编辑该文件,在第一个 0A 前插入 0D 后另存为 new.png: biymQzM.jpg!web

再次识别文件类型: yMVnyiB.jpg!web

cool,BUT,仍然无法渲染: jIBRRzI.jpg!web

没事、没事,看下错误详情: emaUjiV.jpg!web

原来是位于 0×00071 的 IDAT 数据块 CRC 校验失败,该 IDAT 数据块的实际内容有 131072 个字节,任意字节的任意位错误,都可能导致 CRC 失败,若是暴力猜解,有 2 ** 8 ** 131072 种可能,显然无法落地。不可蛮力、只能巧劲!

PNG 图片内含多个数据块(chunk),每个数据块依次由四个域组成:长度(length)、类型代号(chunk type code)、实际内容(chunk content)、循环冗余校验码(CRC,cyclic redundancy check):

长度域,4 字节,指定实际内容域的大小,而非该数据块的大小;

类型代号域,4 字节,指定该数据块的类型;

实际内容域,长度域所指定的字节数,存放该数据块的实际内容数据;

校验码域,4 字节,由类型代号域和实际内容域组合计算而得。

我用十六进制编辑器分析该数据块。跳至 0×00071 处: J7NBJzF.jpg!web

蓝色部分为长度域(即,0×00020000);黄色高亮为该数据块的类型代号域(即,”IDAT”);紫色为该数据块的实际内容域(即,具体像素),从位于 112 + 0×05 的位置开始,应该在哪个位置结束?从PNG 规范可知,PNG 内含多个 IDAT 数据块,依次连续且无间隔,上个 IDAT 数据块的最后一个字节后面一定是下个 IDAT 数据块的第一个字节: q2mm6fA.jpg!web

换言之,我只要找到下个关键字 IDAT 的位置,往前退 4 个字节的长度域,再往前退 4 个字节的校验码域,所在位置就是当前 IDAT 数据块的实际内容的结束位置。具体而言,我找到第二个 IDAT 关键字,往前退 4 + 4 个字节,到达字节 DD 字节所在的位置 131184 + 0×03 就是结束位置: vMnyUfV.jpg!web

逻辑上,长度域的值应该等于实际内容域的字节数量,前者为0×20000,即 131072,后者为 (131184 + 0×03) – (112 + 0×05) + 1,即 131071,咦~咦,怎么不等呢?捋一捋,位于 0×00071 的 IDAT 数据块,本应存放 131072 个字节的实际内容,但却因某些原因丢失了一个字节(131072 – 131071),这个字节可能是任意内容、可能出现在任意位置,若是暴力猜解,有 2 ** 8 * 131072 = 33554432 种可能,嗯,可以接受,接下来,我得实现个暴破脚本。

PNG 规范可知,校验码域是通过 CRC32 算法对类型代号域和实际内容域求值而来,脚本在实际内容域的第一个字节前依次尝试插入 [0, 255] 范围内的值,验证 CRC32 结果是否等于校验码域,若不等继续尝试在第二个字节前插入,直到找到或者完成所有可能的验证。python 内置的 zlib.crc32() 函数支持 CRC32 算法: UnAbiua.jpg!web

参数必须是 bytes 类型,所以我得以二进制模式读入文件。为了减少干扰,我把位于 0×00071 的 IDAT 数据块的类型代号域和实际内容域的数据转存至文件 idat1 中: QneqyyU.jpg!web

三下五除二,实现了暴破功能,如下: R7RNn2m.jpg!web

跑起来,20s 不到就收到好消息: RfA3Yrj.jpg!web

酷,哈`哈`哈!

看看位置 553 是啥内容,0x0A,等一等,感觉之前见过,思索思索,最开始修复文件幻数时,在 0x0A 前插入了 0x0D,修复这个 IDAT 数据块时,也是在 0x0A 前插入了 0x0D,结合题目所述”文件传输可能导致该 PNG 文件破损“,印象中,win 采用 0x0D0x0A 换行,而 linux 采用 0x0A 换行,我可以合理猜测,该 PNG 文件传输时,0x0D0x0A 被替换成 0x0A,所以,代码无需尝试在任意位置插入任意字节,只需验证在哪些 0x0A 前插入 0x0D,但不是每个 0x0A 前插入 0x0D,优化代码: RzEZNbr.jpg!web

秒秒钟出来: yeqq6zJ.jpg!web

另外,由于 idat1 位于 new.png 的 113 位置,所以 idat1 中的 553 位置就对应 new.png 的 113 + 553 即 666 处,插入字节 0x0D 后另存为 new_fix1.png: UFnu6na.jpg!web

打开试试: 6VbEVfn.jpg!web

WTF!!不应该,思路没问题啊,看看错误详情: YbAzMzv.jpg!web

哦哦,原来报错的是另一个 IDAT 数据块,先前位于 0×00071 的 IDAT 数据块已经成功修复,还是有点不放心,要是有工具能渲染已经修复的那部分图像数据就好了。linux 严格遵循 PNG 规范,CRC 校验不通过就无法渲染,好像 win 不那么严苛,到 win 下试着打开 new_fix1.png: QB7vumR.jpg!web

WOW,有点小兴奋呢 :-)。你看,我们常诟病 win 不遵循行业规范,在这里反而变成”超强容错“的优点,事物永远都有两面性,用辨证的眼光去….(滚!!),sorry!

用相同方式,我继续分析位于 131196 的 IDAT 数据块。先从 new.png 提取该数据块,将 131184 + 0x0C 作为开始地址: uq6BRv7.jpg!web

将 262256 + 0x0C 作为结束地址: eiUV3mN.jpg!web

右键选择 save as dump,另存为 idat2。分析过程与之前类似,预期实际内容为 0×020000 个字节,而真实只有 (262256 + 0x0C) – (131184 + 0x0C + 4) + 1 个,缺失 3 个字节,按先前的预判,均为 0x0A 前的 0x0D,基于这一思路,继续优化代码,让其实现猜测多个字节。

缺失 3 个字节比 1 个字节在代码实现上要所谓麻烦些。比如,aaaa,若为在 a 前丢失 1 个字节(x)后的字符串,反推原始字符串,只需依次在每个 a 前添加 x 就好,即, xaaaa、axaaa、aaxaa、aaaxa 四种可能;若为在 a 前丢失 2 个字节(x)后的字符串, 则原始字符串可能为 xaxaaa、xaaxaa、xaaaxa、axaxaa、axaaxa、aaxaxa六种可能,也就是,从 4 个中选出 1 个有几种可能,以及, 从 4 个中选出 2 个又有几种可能,python 的 itertools.combinations() 函数可以帮我实现: aayM32a.jpg!web

回到题目中,我先找出所有 0x0A 的位置(newline_idxs),缺失多少个(lost_bytes_cnt)字节,就从中选出多少个组合: IbaUfe7.jpg!web

另外,由于位置是相对的,插入元素将导致后面元素的位置发生变化,这得注意。比如,有一字符串 aaaa,四个 a 的位置依次为 0、1、2、3,若我在第一个 a 的位置 0 前插入 x 后,要想在第二个 a 前继续插入 x,则必须使用 1 + 1 的位置,因为当前字符串已经变为 xaaaa,这比较麻烦,所以,我换了个方式,从后往前插入,位置 3 的 a 插入后为 aaaxa、位置 2 的 a 插入后为 aaxaxa。回到题目中,我将需要插入 0x0D 的位置先作逆向排序: zeuyYzm.jpg!web

代码如下: AfuYZnj.jpg!web

大约运行 1M 后出结果: qaueymu.jpg!web

由于找到的位置是 idat2 中的位置,idat2 又从 new.png 的131196 位置开始,所以,对应至 new.png 中的位置,131196 + 125131 即 256327、131196 + 26274 即 157470、131196 + 502 即 131698,另外,idat1 丢失的字节位置为 666,所以,我依次在 256327、157470、131698、666 前插入字节 0x0D,并且一定得从后往前插入(●﹏● 18+),另存为 new_fix2.png。

经确认,第一、二个 IDAT 数据块修复成功,第三个报错: maqA7zr.jpg!web

看看渲染后的效果: 7fEfma6.jpg!web

不错!

思路清晰了、方向明确了,用相同的方法处理其他 IDAT 数据块,就能修复整个图片文件。前面思路中,提取 IDAT 数据块的类型代号域和实际内容域、分析缺失的字节数量、图片文件中插入新数据等等,这些人工完成的工作,都应由脚本实现。

我在十六进制编辑器中找出所有 IDAT 关键字: U7vyQnu.jpg!web

有十个 IDAT 数据块啊,我得继续优化前面的代码,让它自动提取 IDAT 数据块、分析缺失字节数量、找出应在哪些 0x0A 前插入 0x0D,简单封装,实现类 PngIncompleteIdatChunks: emquEnj.jpg!web

运行代码: EFRr6zJ.jpg!web

漫长的等待,十四分钟后找回了所有丢失的 0x0D 字节!中途一度怀疑某些临界环境未考虑周全,刚好何阳同志过来找我出去抽了根烟,回来就出结果了。现在,只需按脚本提示,将 0x0D 依次从后往前插入new.png 的对应位置即可修复整张图片,但是,作为食物链顶端的我,怎么能屈尊去做这些低端低级的体力活儿呢,还得有劳脚本。

在类 PngIncompleteIdatChunks 中增加了 suggest()、fix() 两个成员函数,前者用于显示图片修复建议,后者实际修复图片,如下: VJNJve3.jpg!web

整个 PoC 完整代码如下:

import re
import os
import zlib
import typing
import itertools


# 仅处理 IDAT 数据块字节丢失的问题
class PngIncompleteIdatChunks:

    def __init__(self, png_file_path: str):
        with open(png_file_path, 'rb') as f:
            self._png_data = f.read()
        self._getIncompleteIdatChunks()
        self._check()

    def _getIncompleteIdatChunks(self):
        self._idat_chunks = list()
        cur_idx = 0
        while True:
            cur_idat_typecode_idx = self._png_data.find(B'IDAT', cur_idx)
            if cur_idat_typecode_idx == -1:
                break
         
            # 提取长度域
            length = self._png_data[cur_idat_typecode_idx - 4: cur_idat_typecode_idx]
            # 提取实际内容域的起止位置
            next_typecode_idx = self._png_data.find(B'IDAT', cur_idat_typecode_idx + 4)
            if next_typecode_idx == -1:
                next_typecode_idx = self._png_data.find(B'IEND', cur_idat_typecode_idx + 4)
            idat_content_end_idx = next_typecode_idx - 4 - 4
            idat_content_beg_idx = cur_idat_typecode_idx + 4
            # 提取校验码域
            crc32 = self._png_data[idat_content_end_idx: idat_content_end_idx + 4]
         
            self._idat_chunks.append((length, (idat_content_beg_idx, idat_content_end_idx), crc32))
            cur_idx = next_typecode_idx

    def getIdatChunksCnt(self):
        return(len(self._idat_chunks))

    def getIdatChunks(self):
        for (length, (content_beg_idx, content_end_idx), crc32) in self._idat_chunks:
            yield(int(length.hex(), 16), (content_beg_idx, content_end_idx), int(crc32.hex(), 16))

    @staticmethod
    def ___insertElems(data: bytes, insert_idxs: typing.Tuple[int, ...], elem: bytes) -> bytes:
        insert_idxs_rev_sorted = sorted(insert_idxs, reverse=True)
        for insert_idx in insert_idxs_rev_sorted:
            data = data[:insert_idx] + elem + data[insert_idx:]
        return(data)

    @staticmethod
    def ___checkChunkByInsertReturnBeforeNewline(typecode_n_content: bytes, typecode_beg_idx: int, lost_bytes_cnt: int, crc32) -> typing.List[int]:
        newline_idxs = (m.start() for m in re.finditer(bytes.fromhex('0A'), typecode_n_content))
        maybe_return_idxs_combin = itertools.combinations(newline_idxs, lost_bytes_cnt)
        
        for maybe_return_idxs in maybe_return_idxs_combin:
            print('\r', 'try to insert 0x0D at', *maybe_return_idxs, end='')
            data_fix = PngIncompleteIdatChunks.___insertElems(typecode_n_content, maybe_return_idxs, bytes.fromhex('0D'))
            if zlib.crc32(data_fix) == crc32:
                return_idxs = [idx + typecode_beg_idx for idx in maybe_return_idxs]  # 相对位置转绝对位置
                print('\r', 'bingo!!!! insert 0x0D at', *return_idxs)
                return(list(return_idxs))
        
        print('\r', 'sorry! I can not repair IDATA chunk. ', sep='')
        return(list())

    def _check(self):
        self._lost_return_idxs_reverse_sorted = list()
        for (length, (content_beg_idx, content_end_idx), crc32) in self.getIdatChunks():
            
            print(F'check [{content_beg_idx - 4}, {content_end_idx}) IDAT: ', end='')
            
            context_bytes_cnt = content_end_idx - content_beg_idx
            if length == context_bytes_cnt:
                print('ok. ')
                continue
            elif length < context_bytes_cnt:
                print('too many bytes. ')
                continue
            
            lost_bytes_cnt = length - context_bytes_cnt
            print(F'lost {lost_bytes_cnt} bytes, guess ...')
            
            lost_return_idxs = self.___checkChunkByInsertReturnBeforeNewline(self._png_data[content_beg_idx - 4: content_end_idx], content_beg_idx - 4, lost_bytes_cnt, crc32)
            self._lost_return_idxs_reverse_sorted.extend(lost_return_idxs)

    def suggest(self):
        print('-' * 16)
        print('now, I suggest U insert 0x0D _ONE_BY_ONE_ at', *self._lost_return_idxs_reverse_sorted)

    def fix(self, saveas_path: str):
        with open(saveas_path, 'wb') as f:
            f.write(self.___insertElems(self._png_data, tuple(self._lost_return_idxs_reverse_sorted), bytes.fromhex('0D')))
        print('-' * 16)
        print('now, I fixed it for U, by insert 0x0D _ONE_BY_ONE_ at', *self._lost_return_idxs_reverse_sorted, ', and save as', os.path.abspath(saveas_path))
    
    
png_incomplete_idat_chunks = PngIncompleteIdatChunks('./new.png')
png_incomplete_idat_chunks.fix('./new_fixed.png')

运行结果:

MrYVrqI.jpg!web

看看修复效果: FJNvyme.jpg!web

成功找到 flag,还没完,如果正式比赛,还得提交文字版本的 flag,看着图片手输?不行,不够洋气,光学字符识别,于是,我把 new_fixed.png 适当放大后,截取 flag 部分另存为 flag.png: 6bqINfr.jpg!web

接着 OCR: viEbeaF.jpg!web

文本 flag 出来了,拷贝提交即可。

最后,被我溯源的那台 CC 并没写入蓝队报告中,因为,虽然我在蓝队,但有颗红心!另外,六月未丢分的企业,并不能证明你们的安全做到位了,只能说明系统下线、封锁网段的临时防御手段有效,七月重新上线的、没有 7 × 24 监控的系统,没准一打一个中。成绩,是静态的,安全,是动态的,你知道我在说什么。

(BTW,借助 IM 工具 LX 的漏洞去拿蓝队终端的手法,大赞 :)

*本文作者:yangyangwithgnu,本文属 FreeBuf 原创奖励计划,未经许可禁止转载。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK