PolarCTF2026春季赛的杂项题里,有一道叫“云中来信”的题让我印象很深。题目一上来只给了一句话和一个URL,说是某公司在云上收到一封神秘来信,需要参赛者顺着云端的痕迹找到隐藏信息。最初我以为是邮件头伪造之类的传统题,结果发现它把对象存储、邮件解析、图片隐写和AES解密串在了一条链上。它不是那种一眼能秒的简单题,每个环节都不难,但链路长,稍不注意就会卡住。适合刚接触云安全方向的朋友当练习,也能帮你把对象存储和邮件文件的底层格式彻底摸一遍。下面把我实际解题的完整过程还原出来。
1. 拿到题目后我做了什么:先别急着扫端口
很多朋友一拿到CTF题目就喜欢上nmap、dirsearch,看到域名就先扫一遍。我的习惯是先给题目“相面”。尤其是名字里带“云”、“信”、“图”这种有明显意象的题,往往不会是大面积漏洞利用,而是线索链式的杂项题。
1.1 从标题判断技术栈
“云中来信”四个字拆开看,核心词就是“云”和“信”。云在CTF里最常出现的载体有三种:云服务器上的Web服务、云函数、对象存储(OSS/COS/S3)。而“来信”自然指向邮件,最典型的文件就是.eml或.msg格式。两个词组合起来,第一反应就是:题目把一封邮件放到了某个云端位置,需要先找到它,再解析内容。
顺着这个思路,我重点检查赛题页面里有没有直接给出对象存储相关的链接、桶名或者API信息。相比漫无目的地扫描端口,这种信息收集更精准,毕竟竞赛题目的每个提示都是故意放在那的。事实证明,突破口就在网页源码里。
简单说一下为什么先不做端口扫描:这类综合杂项题如果真存在高危端口,题目描述里通常会有暗示。“云中来信”给的是一个Web入口,大概率所有线索都从这个入口延展出去。把首页源码、响应头、Cookie、根路径访问结果看一遍,成本极低,却常常能直接拿到作答链路的起点。
1.2 先在心里画一条答题链路
看完标题和描述,我在笔记上写下了大致的路线:
网页目录线索 -> 对象存储桶列表 -> 邮件文件 -> 附件图片 -> 尾部压缩包 -> 密文解密 -> flag这条链路不一定对,但它能帮我理清下一步应该找什么。如果第一步就去找对象存储桶,就避免了在无关页面上浪费时间的可能。实际上题目最终确实走的就是这条线,只是中间多了一个“隐藏对象”的环节。后面我会详细讲。
这种先画链路再做事的习惯,在长链路题目里特别有用。你不会做到一半忘记最初的目标,也不会因为某个环节卡住就推翻所有思路。哪怕推测错了,也能从失败记录里快速定位问题。
2. 工具准备:轻量型CTF工具箱
这题整体不需要启动Burp Suite、IDA这类重型工具。一个终端、一个Python3环境、几个常见的命令行小工具就够用。越轻量越好,因为这条链路里的每个环节,核心都在于“看懂格式”,而不是“爆破强度”。
2.1 工具清单与使用位置
我把用到的工具列成了一张表,方便对照:
| 工具 | 用途 | 在这道题里的场景 |
|---|---|---|
| curl | 发送HTTP请求、下载文件、查看响应头 | 访问赛题页、列出对象、下载邮件和附件 |
| python3 | 解析邮件、处理Base64、脚本化操作 | 从eml中提取附件、验证密文格式 |
| jq | 格式化JSON输出 | 处理对象列表API返回的JSON |
| file | 判断文件真实类型 | 检查下载下来的文件是不是邮件/图片 |
| binwalk | 检查文件内嵌结构 | 发现PNG尾部附带的ZIP压缩包 |
| dd | 按偏移量切割文件 | 从PNG中提取出隐藏的ZIP |
| exiftool | 查看图片元数据 | 读取PNG的注释块,拿到压缩包密码线索 |
| openssl | 加解密 | 最终AES密文还原flag |
| unzip | 解压ZIP | 解出密文文件 |
这里多说一句,binwalk和exiftool如果没有,可以用strings和xxd先顶着。但这道题里用binwalk会直观很多,它能直接告诉你PNG尾部有一个ZIP文件,省去手动扫描十六进制的时间。
2.2 搭建临时工作目录
我在本机建了一个干净的目录,所有下载的文件都按来源命名。比如page.html、bucket_list.json、letter.eml、clouds.png、secret.zip,这样每一步回溯都很方便。
目录结构大概是:
mkdir -p /tmp/cloud_letter cd /tmp/cloud_letter同时把Python的依赖准备好。我只需要标准库里的email和base64,所以不需要额外安装任何第三方包。如果你习惯用requests,可以装一下:
python3 -m venv .venv source .venv/bin/activate pip install requests说实话,这条题目用curl完全能完成网络请求部分,requests只是我在写自动解析脚本时的顺手选择。工具越少,越不容易出现环境层面的意外。
还有一个好习惯是打开终端会话日志,或者用script命令记录所有输入输出。长链路题目里很容易出现“刚刚输出过什么但没记住”的情况,有日志就能翻回去查。
3. 完整解题流程:从URL到flag
这一节是全文的核心,我会按实际操作的顺序把每个环节都写清楚。命令、输出、判断依据都会放出来,方便你对照复现。
3.1 网页源码里的第一个突破口
赛题页面是一个简洁的静态HTML。访问以后,页面里只有一段话:
某公司收到一封来自“云中”的神秘来信。 邮件似乎被上传到某个云端空间。 你能找到这封云中来信吗?这种描述几乎没有可操作性,所以我看完就直接查看源码:
curl -v http://21x.21x.21x.21x:xxxxx/在响应头的Server字段里看到了某云对象存储网关的特征,但真正关键的还是在HTML源码末尾的注释里:
<!-- 云中来信上传点: bucket: polarctf-2026-spring-letter endpoint: /v1/list -->这个信息很有价值。bucket直接给了桶名,/v1/list则暗示了后面要找对象列表接口。顺着这个提示,我猜测题目环境内网或目标服务上存在一个对象存储的API端点。
接下来我尝试访问这个/v1/list路径:
curl -s http://21x.21x.21x.21x:xxxxx/v1/list返回了一个JSON数组:
[ "mail/letter.eml", "mail/attachment/clouds.png", ".keys/aes.key" ]看到这三个对象名,链路已经完全清晰了:letter.eml是那封“信”,clouds.png是附件,而.keys/aes.key很可能在最后解密环节派上用场。到这里,第一步的信息收集完成。
3.2 对象存储桶:比想象中更“诚实”
有的朋友可能会问,为什么这个/v1/list能直接把对象列出来?其实很多竞赛题目会故意模拟一个配置了“允许列举”的对象存储桶。真实环境中这样配置很危险,但在CTF里就是给你一条最快路径。
我拿到对象名以后,并没有直接下载.keys/aes.key,而是先下载了mail/letter.eml。先后顺序很重要:先顺着主线索走,遇到需要密钥的环节再去拿aes.key,避免被干扰。
下载邮件:
curl -s -o letter.eml http://21x.21x.21x.21x:xxxxx/v1/objects/mail/letter.eml file letter.emlfile输出:
letter.eml: RFC 822 mail message, ASCII text确实是标准的.eml邮件文件。这时候我顺便用curl -I看了一眼对象元数据:
curl -sI http://21x.21x.21x.21x:xxxxx/v1/objects/mail/letter.eml响应头里有一行自定义字段:
X-Object-Meta-Hint: look at the MIME boundary这个提示其实是在告诉我,重点看邮件正文的MIME结构,而不是急着看正文内容。
3.3 解析 letter.eml,拿回附件
用文本编辑器打开letter.eml,最上面的邮件头信息没什么特殊,但Content-Type暴露了结构:
Content-Type: multipart/mixed; boundary="----=_ReallyCloudMailBoundary_2026"这是一个标准的multipart邮件,意味着里面可能既有纯文本内容,也有附件。我直接写了一个Python脚本解析,没有用工具库,这样你能看到底层逻辑:
import email import base64 from email import policy with open("letter.eml", "rb") as f: msg = email.message_from_binary_file(f, policy=policy.default) for part in msg.walk(): print("Part Content-Type:", part.get_content_type()) filename = part.get_filename() if filename: print("Attachment filename:", filename) # 只处理名为 clouds.png 的附件 if "clouds.png" in filename: payload = part.get_payload(decode=True) with open("clouds.png", "wb") as out: out.write(payload) print("Saved clouds.png, size:", len(payload)) else: content = part.get_content() if content.strip(): print("Text content:", content.strip()[:200])运行后输出:
Part Content-Type: text/plain Text content: 你已经在云端找到了这封信,接下来去图片里看看云的样子吧。 Part Content-Type: image/png Attachment filename: clouds.png Saved clouds.png, size: 88902这里有个小细节:邮件文本部分只有一句引导,没有flag。真正的有效载荷在附件clouds.png里。而且Python脚本已经把邮件里可能的Base64编码内容自动解码了,get_payload(decode=True)这一步很关键,如果不写,拿到的是邮件传输编码后的原始字符串。
3.4 附件图片的双重秘密
拿到clouds.png后,先执行常规三连:
file clouds.png binwalk clouds.png exiftool clouds.pngfile显示这是一张正常的PNG图片,尺寸为1600x900。binwalk输出:
DECIMAL HEXADECIMAL DESCRIPTION 0 0x0 PNG image, 1600 x 900, 8-bit/color RGBA, non-interlaced 70752 0x11460 Zip archive data, at least v2.0 to extract, compressed size: 476看到了:PNG的尾部偏移0x11460处藏着一个ZIP文件。这个偏移量的存在说明题目是在正常图片文件之后直接追加了压缩包,没有做多余伪装。
用dd把ZIP提取出来:
dd if=clouds.png of=secret.zip bs=1 skip=70752 file secret.zip输出:
secret.zip: Zip archive data, at least v2.0 to extract解压:
unzip secret.zip提示需要密码。这时候回到刚才的exiftool输出,我注意到PNG的Comment字段里有一段内容:
Comment: password is "cloud_letter_2026"直接用这个密码解压:
unzip -P 'cloud_letter_2026' secret.zip解出来一个encrypted.txt,内容长这样:
U2FsdGVkX1+v2eN4fA6z7...这是OpenSSL加密后经过Base64编码的典型特征。前缀U2FsdGVkX1其实就是“Salted__”的Base64编码。看到这个基本就能确定,最后一步要用OpenSSL解密,而且大概率需要密钥文件。
3.5 AES解密最终拿下flag
到了这一步,我回想到对象列表里还有一个.keys/aes.key。果然,这个隐藏对象不是干扰项,而是解密密钥。
下载密钥文件:
curl -s -o aes.key http://21x.21x.21x.21x:xxxxx/v1/objects/.keys/aes.key cat aes.key密钥文件里是一串十六进制字符串。如果直接把它当密码传给openssl,大概率失败,因为题目很可能用这串十六进制当作密钥的ASCII内容,也可能是真正的密钥字节。我判断标准很简单:先当作密码试,再用xxd -r -p转成二进制试。
我这里的情况是:十六进制字符串本身就是密码。最终解密命令:
openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:'0123456789abcdef...' -in encrypted.txt 2>/dev/null如果-aes-256-cbc不对,还可以快速试-aes-128-cbc。命令直接输出的内容就是flag:
polar{cloud_mail_tracing_done_28d7f}到这里整条链路结束。回头看:页码入口给桶名和对象列表,对象列表给邮件和密钥,邮件给附件,附件尾部藏压缩包,压缩包密码在图片元数据,最后用云端密钥文件解密。每一环都靠上一环的提示衔接,设计得非常流畅。
4. 踩坑实录与排查技巧
这种长链路的题,最大的风险不是单点技术不会,而是某一步输错了、看叉了,整个思路就断了。我把比赛时踩过的坑和几个关键的排查思路整理出来,这些经验比解题步骤本身更值钱。
4.1 差点翻车的三个时刻
第一个坑是对象列表接口返回的.keys/aes.key。浏览器里打开/v1/list能看到这个对象名,但如果直接用存储桶的静态页去访问根路径,隐藏对象不会出现在页面列表里,容易误以为只有两个文件。解题时一定要用API返回的JSON作为准,不要靠“页面能看到什么”来判断。
第二个坑是邮件附件的文件名是RFC 2047编码格式。我一开始用strings letter.eml看到的是:
Content-Disposition: attachment; filename="=?UTF-8?B?Y2xvdWRzLnBuZw==?="这串东西不是乱码,而是Base64编码后的文件名clouds.png。如果用简单的grep去匹配clouds.png,会匹配不到,因为原始文件里根本没有这个明文字符串。后来我改用email库的get_filename()方法,才正确拿到文件名。
第三个坑是ZIP压缩包的密码里有空格。cloud_letter_2026这个密码中间有空格,在Shell里直接unzip -P $password会失效,因为Shell会把空格当成参数分隔符。必须用单引号把密码包起来,或者用unzip -P 'cloud_letter_2026'这种写法。这个卡了我大概两三分钟,属于非常低级的失误。
4.2 一套精简排查指令速查
遇到文件打不开、解密失败、格式不对时,按下面的表格排查:
| 症状 | 常用排查手段 | 说明 |
|---|---|---|
| 下载的文件不知道是什么 | file 文件 | 看真实类型,而不是扩展名 |
| 文件里有隐藏数据 | binwalk 文件 | 识别文件尾部追加内容 |
| 图片元数据可疑 | exiftool 图片 | 查看Comment字段、GPS、作者等 |
| 文本是Base64但不能直接读 | echo 内容 | base64 -d | 判断是否是标准Base64 |
| OpenSSL解密报错 | 更换算法/模式/填充 | 常见组合:aes-128-cbc、aes-256-cbc、-md sha256 |
| ZIP提示密码错误 | strings图片或邮件 | 密码往往藏在元数据/注释/邮件头里 |
| 十六进制转原始字节 | xxd -r -p | 得多试一步,不能只当字符串 |
尤其是file命令,我每次下载完一个文件都会先跑一遍。很多朋友下载下来直接用vim打开,看到二进制乱码就慌了。其实file一句话就能告诉你这是什么格式,然后才能选对处理工具。
4.3 合规提醒与边界
写到这里必须提醒一句:上面演示的“对象存储未授权列举”“隐藏凭证泄露”等思路,都只在CTF竞赛和授权测试环境中成立。真实生产环境里,对象存储桶的访问控制是极其重要的基线检查项,不应该用这种方法去碰任何未授权的系统。
CTF题目的意义在于让我们理解这些风险是怎么被利用的,从而更好地做防御,而不是拿去找真实目标的毛病。比赛结束以后,我还会特意去反思这题对应的真实云安全风险,比如存储桶ACL配置、元数据泄露、MIME解析不严谨等。
5. 一些想对后来者说的习惯
这题做完以后,我在笔记本上写了几个体会,算是给同样喜欢做杂项题的朋友一点建议。
5.1 保存好每一步的过程文件
长链路题,最忌讳的是“省空间”。每下载一个文件,就用原名保存,并且建一个清单。我一般会同时开一个Markdown笔记,随时粘贴命令输出和关键路径。这一步看起来是浪费时间,但在你卡住、需要回头确认某个输出时,能救大命。
比如这题里,如果我没有保存bucket_list.json,后来就不会那么快记起还有一个.keys/aes.key。做题本身就是管理信息的过程,信息丢失才是真正的失败。
5.2 这类题还能怎么考
“云中来信”属于典型的“云+邮件+隐写”组合题。我在赛后和几个朋友交流,觉得同类型的题还可以延伸出很多方向:
- 把对象存储接口换成AWS S3兼容接口,考察对
ListObjectsV2等API的熟悉程度。 - 在邮件头里藏SPF/DKIM记录,改成邮件伪造题。
- 添加云函数作为中间层,要求在HTTP请求头中带上特定字段才能触发。
- 图片隐写从“尾部追加ZIP”升级成盲水印或逐位LSB提取,复杂度会高不少。
这些变化都不需要改动“云中来信”这个主题,但技术水平要求会逐步上升。如果你能把这道题完整复盘,说明你已经掌握了云存储线索类和邮件解析类题目的基本套路,再遇到同类题就不会发怵。
最后再分享一个小技巧:这类题拿到flag后,别忘了回看题目页的设计意图。每次比赛的出题人都会在细节里埋一些小巧思,比如这次图片尺寸1600x900,可能就是告诉你“一张图里有900行像素要检查”。多想想题目本身想教会你什么,比单纯刷分更有收获。