三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

CTF实战:文件逆序与LSB隐写技术解析及Python实现

CTF实战:文件逆序与LSB隐写技术解析及Python实现

1. 项目概述

在CTF(Capture The Flag)竞赛的杂项(Misc)赛道上,文件逆序与LSB(Least Significant Bit,最低有效位)隐写是两类非常经典且高频出现的题目类型。前者考验选手对文件结构、字节序和数据组织的理解,后者则是对图像隐写术基础原理的实战检验。而“emoji解密”这个关键词,往往指向一种将二进制数据或文本信息编码为emoji表情符号的趣味性加密方式,在CTF中常作为LSB隐写提取出的数据的后续处理环节。

这篇文章,我将从一个实战者的角度,手把手地带你用Python解决这两类问题。我不会只给你干巴巴的代码,而是会深入讲解每一步背后的“为什么”,分享我在实际解题中踩过的坑和总结的技巧。无论你是刚接触CTF的新手,还是想巩固基础的老兵,相信这篇近万字的实战笔记都能让你有所收获。我们的目标很明确:拿到一个包含隐写信息的文件(如图片),通过逆向分析和数据处理,最终提取出隐藏的Flag。

2. 核心原理与工具准备

2.1 文件逆序:不只是“[::-1]”那么简单

文件逆序题目的核心思想是打乱数据的正常存储顺序,要求我们将其恢复。这不仅仅是简单的字符串反转([::-1]),其形态多变:

  1. 字节级整体逆序:整个文件的字节顺序被完全颠倒。这是最基础的形式,用file_bytes[::-1]即可解决。
  2. 按块逆序:文件被分割成固定大小的块(如每4字节、每16字节一块),块内顺序正常,但块与块之间的顺序被颠倒。
  3. 行逆序:针对文本文件,每一行文本的内部字符顺序正常,但行与行之间的顺序被颠倒。
  4. 比特位逆序:在字节内部,每个字节的8个比特位(bit)顺序被颠倒。例如,字节0b11010010(0xD2) 会变成0b01001011(0x4B)。
  5. 混合逆序:以上几种方式的组合,例如先按块逆序,再对每个块进行字节逆序。

为什么出题人爱考这个?因为它能有效考察选手对数据在内存/文件中存储形式的理解,以及对Python字节(bytes)和字节数组(bytearray)操作的熟练度。解题的关键在于仔细观察文件头尾。一个正常的PNG文件头是89 50 4E 47,如果逆序后变成了47 4E 50 89,这就是一个强烈的逆序信号。

必备工具

  • 010 EditorHxD:十六进制编辑器,用于直观查看文件原始字节,判断逆序类型。
  • Python:我们的主力武器,open()函数以二进制模式(’rb’)读取文件至关重要。

2.2 LSB隐写:藏在像素里的秘密

LSB隐写是图像隐写中最基础、最常见的技术。其原理利用了人类视觉系统对颜色细微变化的不敏感性。

  • 原理:一张彩色图像(如PNG)的每个像素由红(R)、绿(G)、蓝(B)三个通道组成,每个通道通常用8位(即0-255)表示。修改每个通道值的最低1位(甚至2-3位),对图像整体的视觉效果影响微乎其微。
  • 如何隐藏信息:将秘密信息(如Flag文本)转换为二进制比特流,然后依次替换图像像素RGB通道最低位的比特。
  • 如何提取信息:读取图像每个像素RGB通道的值,提取其最低位的比特,然后将这些比特按顺序拼接起来,转换回字节数据,最终得到隐藏的信息。

一个关键细节:提取顺序。信息比特被嵌入到像素中的顺序至关重要。常见的顺序有:

  • RGB, RGB, RGB...:按像素顺序,先R通道最低位,再G,再B,然后下一个像素。
  • RRR…GGG…BBB…:先提取所有像素的R通道最低位,然后是所有G通道,最后是所有B通道。
  • 自定义顺序:出题人可能指定任何顺序,如BGR,或只使用R和G通道。

工具与库

  • Pillow (PIL):Python图像处理库,Image.open()img.getpixel()/img.load()是我们读取像素的主要方式。
  • Stegsolve:一款经典的Java图像隐写分析工具,可以方便地查看各个颜色通道的LSB平面,是手动分析的神器。
  • zsteg:命令行工具,能自动检测并提取多种LSB隐写,支持多种顺序和组合,在CTF中极为常用。

实操心得:遇到一张可能藏有LSB隐写的图片,我的第一反应是先用zsteg -a image.png扫一遍。它能快速尝试多种常见的LSB提取方式,很多时候能直接给出结果。如果zsteg无效,再上Stegsolve手动分析或自己写脚本。

2.3 Emoji编解码:当Flag变成表情包

Emoji解密通常不是一种独立的加密,而是将LSB提取出的二进制数据或文本,进行了一次“可视化”或“混淆”编码。常见玩法有:

  1. Base64/Hex -> Emoji映射:将Base64编码的每个字符(如A-Z, a-z, 0-9, +, /)或十六进制的每个半字节(0-F)映射到一个特定的emoji上。你需要找到这个映射表。
  2. 二进制 -> Emoji映射:将二进制比特流(如01101001)按固定长度(如8位一组)映射到代表该字节值的emoji。
  3. 直接替换:将Flag中的字母、数字、符号直接替换为形状相似的emoji(如o-> 🅾️,i-> ℹ️,flag-> 🚩🍎🎮)。

解题思路:观察emoji序列的规律。如果emoji种类固定且数量较少(如8个),很可能对应3位二进制或一个十六进制字符。如果emoji种类很多,可能直接对应ASCII码。可以尝试统计频率,或者寻找题目描述、文件名中的提示(如“emoji encoding table”)。

3. 实战演练:文件逆序处理

假设我们拿到一个文件reversed.bin,用010 Editor打开发现文件头尾是反的,判断为整体字节逆序。

3.1 基础字节逆序

def reverse_file_bytes(input_path, output_path): """将整个文件字节顺序反转""" with open(input_path, 'rb') as f: data = f.read() # 核心操作:使用切片[::-1]反转字节序列 reversed_data = data[::-1] with open(output_path, 'wb') as f: f.write(reversed_data) print(f"[+] 字节逆序完成,结果保存至 {output_path}") # 使用示例 reverse_file_bytes('reversed.bin', 'fixed.bin')

注意事项:处理大文件时,一次性读入内存(f.read())可能引发内存不足。对于超大文件,应分块读取和写入:

def reverse_file_bytes_chunked(input_path, output_path, chunk_size=1024*1024): """分块处理大文件的字节逆序""" import os file_size = os.path.getsize(input_path) with open(input_path, 'rb') as fin, open(output_path, 'wb') as fout: # 从文件末尾开始,按块读取并逆序写入 for start in range(file_size, 0, -chunk_size): end = max(start - chunk_size, 0) fin.seek(end) chunk = fin.read(start - end) fout.write(chunk[::-1]) # 对当前块进行逆序 print(f"[+] 大文件字节逆序完成")

3.2 按块逆序与比特位逆序

def reverse_file_blocks(input_path, output_path, block_size=4): """按固定大小的块进行逆序(块内顺序不变)""" with open(input_path, 'rb') as f: data = f.read() # 将数据分割成块 blocks = [data[i:i+block_size] for i in range(0, len(data), block_size)] # 反转块顺序 reversed_blocks = blocks[::-1] # 重新拼接 reversed_data = b''.join(reversed_blocks) with open(output_path, 'wb') as f: f.write(reversed_data) print(f"[+] 按块(大小{block_size})逆序完成") def reverse_bits_in_bytes(input_path, output_path): """反转每个字节内部的比特位顺序""" with open(input_path, 'rb') as f: data = f.read() # 预计算0-255每个字节反转后的值,提升效率 bit_reverse_table = bytes.maketrans(bytes(range(256)), bytes([int(f'{i:08b}'[::-1], 2) for i in range(256)])) reversed_data = data.translate(bit_reverse_table) with open(output_path, 'wb') as f: f.write(reversed_data) print(f"[+] 字节内比特位逆序完成")

3.3 综合逆序与自动化尝试

在实际CTF中,逆序方式可能未知。我们可以编写一个脚本,尝试多种逆序组合,并自动识别生成的文件是否有效(例如,通过检查文件头)。

import os import magic # 需要安装python-magic库,用于识别文件类型 def try_multiple_reversals(input_path): """尝试多种逆序方式,并自动识别可能成功的文件""" with open(input_path, 'rb') as f: original_data = f.read() results = [] # 1. 整体字节逆序 reversed_full = original_data[::-1] results.append(('full_reverse', reversed_full)) # 2. 尝试不同的块大小进行块逆序 for block_size in [2, 4, 8, 16, 32, 64, 128]: blocks = [original_data[i:i+block_size] for i in range(0, len(original_data), block_size)] reversed_blocks = blocks[::-1] results.append((f'block_{block_size}_reverse', b''.join(reversed_blocks))) # 3. 比特位逆序 bit_reverse_table = bytes.maketrans(bytes(range(256)), bytes([int(f'{i:08b}'[::-1], 2) for i in range(256)])) bit_reversed = original_data.translate(bit_reverse_table) results.append(('bit_reverse', bit_reversed)) # 4. 组合:先比特逆序,再整体逆序 combo = bit_reversed[::-1] results.append(('bit_then_full_reverse', combo)) # 检查并保存可能有效的文件 for name, data in results: # 简单检查:是否为常见的可识别文件头 if data[:4] in [b'\x89PNG', b'\xff\xd8\xff\xe0', b'PK\x03\x04', b'\x25PDF'] or data[:3] == b'GIF': output_name = f'fixed_{name}.bin' with open(output_name, 'wb') as f: f.write(data) # 使用magic库进一步识别 try: file_type = magic.from_buffer(data) print(f"[+] 尝试 '{name}' 生成了可能有效的文件: {output_name}, 识别为: {file_type}") except: print(f"[+] 尝试 '{name}' 生成了可能有效的文件: {output_name}") else: # 也可以保存所有尝试结果供手动检查 pass

避坑指南magic库在Windows上安装可能有些麻烦,可以使用其替代品filemagic或纯Python实现的puremagic。更简单的方法是直接检查特定魔数(文件头),例如PNG的\x89PNG\r\n\x1a\n,JPEG的\xff\xd8,ZIP的PK\x03\x04

4. 实战演练:LSB隐写提取

假设我们有一张图片secret.png,怀疑其中含有LSB隐写。

4.1 使用zsteg进行快速扫描

在终端中,首先尝试最快捷的方式:

# 安装zsteg (需要Ruby环境) # gem install zsteg # 扫描图片中的所有LSB隐写可能性 zsteg -a secret.png # 提取特定通道和顺序的数据,例如提取RGB通道的LSB,按行扫描 zsteg -e b1,rgb,lsb,xy secret.png > extracted_data.bin # 查看提取出的文本(如果隐藏的是文本) zsteg -e b1,r,lsb,xy secret.png --strings

zsteg的参数解释:

  • -a:尝试所有已知的隐写方法。
  • -e:提取数据。
  • b1:每个通道提取1个比特(最低位)。
  • rgb:按R, G, B通道顺序提取。
  • lsb:最低有效位。
  • xy:按行扫描(X方向优先)。
  • --strings:尝试以字符串形式输出提取的数据。

4.2 手动编写Python提取脚本

zsteg无法直接提取,或我们需要更精细的控制时,就需要自己写脚本。以下是几种常见情况的提取脚本。

情况一:标准RGB顺序,每个通道取1位

from PIL import Image import sys def extract_lsb_simple(image_path, output_path): """从图片中提取LSB隐写数据(假设为RGB顺序,每个通道最低位)""" img = Image.open(image_path) pixels = img.load() width, height = img.size binary_data = '' for y in range(height): for x in range(width): r, g, b = pixels[x, y][:3] # 忽略Alpha通道 # 提取每个颜色通道的最低有效位 binary_data += str(r & 1) binary_data += str(g & 1) binary_data += str(b & 1) # 将二进制字符串转换为字节 # 确保二进制字符串长度是8的倍数 if len(binary_data) % 8 != 0: binary_data = binary_data[:-(len(binary_data) % 8)] byte_data = bytearray() for i in range(0, len(binary_data), 8): byte = int(binary_data[i:i+8], 2) byte_data.append(byte) with open(output_path, 'wb') as f: f.write(byte_data) print(f"[+] LSB数据已提取到 {output_path}") # 尝试打印开头部分,看是否是文本或已知文件头 print(f" 文件头: {byte_data[:16].hex()}") # 使用 extract_lsb_simple('secret.png', 'extracted.bin')

情况二:自定义提取顺序(例如,只提取R和G通道)

def extract_lsb_custom(image_path, output_path, channel_order='rg'): """ 按自定义通道顺序提取LSB。 channel_order: 字符串,如 'rg' 表示只提取R和G通道,'bgr'表示按B,G,R顺序。 """ img = Image.open(image_path) pixels = img.load() width, height = img.size channel_map = {'r': 0, 'g': 1, 'b': 2} binary_data = '' for y in range(height): for x in range(width): pixel = pixels[x, y] for ch in channel_order.lower(): if ch in channel_map: binary_data += str(pixel[channel_map[ch]] & 1) # 转换为字节 byte_data = bytearray() # 处理可能不足8位的尾部 for i in range(0, len(binary_data) - 7, 8): byte = int(binary_data[i:i+8], 2) byte_data.append(byte) with open(output_path, 'wb') as f: f.write(byte_data) print(f"[+] 按顺序 '{channel_order}' 提取的LSB数据已保存") print(f" 文件头: {byte_data[:16].hex()}")

情况三:提取的数据可能包含文件(如ZIP、PNG)

def extract_lsb_and_auto_save(image_path): """提取LSB并尝试自动识别和保存为文件""" img = Image.open(image_path) pixels = img.load() width, height = img.size binary_data = '' for y in range(height): for x in range(width): r, g, b = pixels[x, y][:3] binary_data += str(r & 1) binary_data += str(g & 1) binary_data += str(b & 1) # 转换为字节 data = bytes(int(binary_data[i:i+8], 2) for i in range(0, len(binary_data)-7, 8)) # 尝试识别常见文件类型 if data.startswith(b'PK\x03\x04'): output_name = 'extracted.zip' print(f"[+] 提取到ZIP文件") elif data.startswith(b'\x89PNG'): output_name = 'extracted.png' print(f"[+] 提取到PNG图像") elif data.startswith(b'\xff\xd8'): output_name = 'extracted.jpg' print(f"[+] 提取到JPEG图像") elif b'flag{' in data or b'FLAG{' in data or b'ctf{' in data: # 可能是直接隐藏的文本 try: text = data.decode('utf-8') print(f"[+] 提取到文本: {text[:100]}...") output_name = 'extracted.txt' except: output_name = 'extracted.bin' print(f"[+] 提取到未知数据,已保存为bin文件") else: output_name = 'extracted.bin' print(f"[+] 未识别出已知格式,已保存为bin文件") with open(output_name, 'wb') as f: f.write(data) return output_name

4.3 处理提取出的数据:Emoji解密示例

假设我们通过LSB提取出了一段文本:“🚀🐱🍎🎮 {th1s_1s_4_3m0j1_fl4g}”,这显然是经过Emoji编码的。

步骤1:观察与映射观察发现,Flag格式flag{...}被编码为🚀🐱🍎🎮 {...}。我们可以假设一个简单的替换密码:

  • f->🚀
  • l->🐱
  • a->🍎
  • g->🎮
  • {}保持不变(或也可能被替换,这里假设没有)。

步骤2:编写解码脚本

def decode_emoji_simple(emoji_text): """简单的Emoji替换解码""" # 建立映射字典(这里需要根据题目实际情况调整) emoji_to_char = { '🚀': 'f', '🐱': 'l', '🍎': 'a', '🎮': 'g', # 可以继续添加其他映射,如数字、下划线等 '1': '1', # 假设数字1没有被编码 '_': '_', '{': '{', '}': '}', } decoded = '' for char in emoji_text: decoded += emoji_to_char.get(char, char) # 如果找不到映射,保留原字符 return decoded # 使用 hidden_text = "🚀🐱🍎🎮 {th1s_1s_4_3m0j1_fl4g}" flag = decode_emoji_simple(hidden_text) print(f"[+] 解码后的Flag: {flag}") # 输出: flag{th1s_1s_4_3m0j1_fl4g}

更复杂的情况:如果Emoji对应的是二进制或Base64。

import base64 def decode_emoji_base64(emoji_text, emoji_map): """ Emoji映射到Base64字符集。 emoji_map: 一个字典,如 {'😀': 'A', '😂': 'B', ...} 映射到标准的64个字符。 """ # 将Emoji序列转换为Base64字符串 base64_str = ''.join([emoji_map.get(e, '') for e in emoji_text]) # 尝试Base64解码 try: decoded_bytes = base64.b64decode(base64_str) return decoded_bytes.decode('utf-8') except: return f"解码失败或非Base64。Base64串: {base64_str}" # 假设我们有一个映射表(实际题目会给出或需要猜测) # 这里只是一个示例,实际映射需要根据题目确定 example_emoji_map = { '🍎': 'A', '🚀': 'B', '🐱': 'C', '🎮': 'D', # ... 假设映射到完整的A-Za-z0-9+/ } # decoded = decode_emoji_base64(emoji_sequence, example_emoji_map)

5. 综合实战案例与问题排查

让我们模拟一个完整的CTF题目流程。

题目描述:附件是一个图片文件challenge.png。提示:“秘密藏在最不起眼的地方,需要倒着看,并用快乐的表情打开。”

解题步骤

  1. 初步观察:用file命令和binwalk检查,binwalk显示图片末尾附加了数据。用dd或Python分离出附加数据extra.dat
  2. 文件逆序:用010 Editor打开extra.dat,发现文件头像是某个正常文件的反转。运行我们的try_multiple_reversals函数,发现“整体字节逆序”生成了一个ZIP文件fixed_full_reverse.zip
  3. 解压ZIP:解压该ZIP文件,发现需要密码。同时,在ZIP的注释或某个文本文件中发现一串Emoji:“🔑: 🍎🚀🎮🐱”
  4. LSB提取:回到原始图片challenge.png。使用zsteg -a challenge.png扫描,发现b1,rgb,lsb,xy通道提取出的数据开头有PK\x03\x04,说明隐藏了一个ZIP。用zsteg -e b1,rgb,lsb,xy challenge.png > hidden.zip提取。
  5. Emoji解码:解压hidden.zip,得到一个文本文件hint.txt,内容就是那串Emoji“🔑: 🍎🚀🎮🐱”。结合ZIP密码的提示,我们猜测Emoji对应字母。根据常见单词,尝试“flag”的映射,发现🍎=f, 🚀=l, 🎮=a, 🐱=g拼出来是“flag”,但顺序不对。尝试“galf”作为密码?不对。再尝试“alfg”,“lfag”... 或者,提示“倒着看”,可能Emoji序列要反转:🐱🎮🚀🍎->galf。用galf作为密码尝试解压fixed_full_reverse.zip,成功!得到flag.txt

常见问题与排查技巧

问题现象可能原因排查方法
zsteg提取出一堆乱码,没有明显文件头。LSB提取的通道、顺序或位平面不对。1. 用zsteg -a查看所有输出,寻找像PNG,JFIF,PK等关键字。
2. 用Stegsolve打开图片,在Analyse -> Data Extract界面,手动勾选不同的通道(Red 0, Green 0, Blue 0)和Bit Order(LSB First / MSB First),观察预览框。
自己写的脚本提取出的数据开头是PK\x03\x04,但无法用压缩软件打开。提取的二进制数据可能错位了(不是从正确的比特开始),或者提取的比特数不对(如用了2个LSB)。1. 检查脚本的提取顺序是否与隐写顺序一致。尝试RGB,BGR,RBG等不同顺序。
2. 尝试偏移1-7个比特开始提取。写一个循环,尝试从二进制字符串的不同起始位置开始解码。
3. 检查是否每个通道只取了1个LSB。有时是取2个LSB(低2位)。
文件逆序后,文件类型识别正确,但文件损坏。逆序的粒度不对。可能是按块逆序,且块大小不对;或者是比特逆序后再字节逆序等组合。1. 用010 Editor对比正常文件头和逆序后文件的“尾部”(原文件头)。分析规律。
2. 尝试我们try_multiple_reversals函数中的多种组合,并用magicfile命令检查生成的文件。
Emoji解码后不是可读文本。映射关系错误,或者Emoji编码的不是直接文本,而是Base64、Hex或二进制数据。1. 统计Emoji的种类数量。如果正好是16种,可能对应十六进制(0-F);如果是64种左右,可能对应Base64。
2. 寻找题目描述、文件名、图片属性中的提示。
3. 尝试将Emoji序列直接转换为Unicode码点,然后观察规律。
提取出的ZIP文件需要密码,且无提示。可能是伪加密、CRC32爆破或字典爆破。1. 用010 Editor检查ZIP文件目录区的全局加密位(第6个字节的bit0)。如果是00 00但软件仍提示加密,可能是伪加密,改为00 00
2. 如果压缩文件很小(<8字节),尝试CRC32爆破。
3. 使用rockyou.txt等字典进行爆破。

一个实用的LSB提取调试脚本

def debug_lsb_extraction(image_path, start_bit=0, channels='rgb', bits_per_channel=1): """用于调试LSB提取参数""" img = Image.open(image_path).convert('RGB') pix = img.load() w, h = img.size binary_str = '' for y in range(h): for x in range(w): r, g, b = pix[x, y] if 'r' in channels: binary_str += format(r, '08b')[8-bits_per_channel:] # 取最低bits_per_channel位 if 'g' in channels: binary_str += format(g, '08b')[8-bits_per_channel:] if 'b' in channels: binary_str += format(b, '08b')[8-bits_per_channel:] # 从 start_bit 开始取 binary_str = binary_str[start_bit:] # 尝试以字节形式输出,并查找可打印字符或文件头 for i in range(0, min(len(binary_str), 1024*8), 8): if i + 8 > len(binary_str): break byte_val = int(binary_str[i:i+8], 2) # 打印前128个字节的hex和可打印字符 if i < 128*8: char = chr(byte_val) if 32 <= byte_val < 127 else '.' print(f'{byte_val:02x} ({char})', end=' ') if (i//8 + 1) % 16 == 0: print() # 也可以尝试直接写文件,用010 Editor查看 byte_data = bytes(int(binary_str[j:j+8], 2) for j in range(0, len(binary_str)-7, 8)) debug_name = f'debug_lsb_{channels}_{bits_per_channel}bit_start{start_bit}.bin' with open(debug_name, 'wb') as f: f.write(byte_data) print(f"\n[+] 调试数据已写入: {debug_name}") print(f" 文件头: {byte_data[:8].hex()}")

这个脚本允许你灵活调整起始位、颜色通道和每个通道提取的比特数,并将结果保存为文件,方便用十六进制编辑器查看,是解决“奇怪”LSB隐写的利器。

最后,CTF中的文件逆序和LSB隐写题目千变万化,但核心思路不变:理解数据存储格式,细心观察,大胆假设,小心验证。多动手写脚本,多分析真实赛题,你会逐渐形成自己的解题直觉和工具箱。记住,工具(zsteg,steghide,binwalk)能帮你快速解决80%的常规题,而剩下的20%难题,则需要你深入理解原理并灵活编写代码。

← 返回列表