Skip to content

Commit a118d55

Browse files
committed
new(trueblog): add the diagnosis of appending .txt to file
1 parent b908230 commit a118d55

7 files changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
---
2+
title: 从前端解决下载无后缀文件出现后缀的问题.txt
3+
date: 2025/08/31 21:14:00
4+
updated: 2025/08/31 23:22:00
5+
tags:
6+
- non-ctf
7+
- tricks
8+
thumbnail: /assets/trueblog/chromium/doc.png
9+
excerpt: >-
10+
最近我们的新生训练赛就要开始了,题目陆陆续续到了测试阶段,对于pwn题,
11+
一些简单题就只需要提供一个binary文件,没有后缀,下载下来就能直接打。结果上传一看,
12+
限制了文件类型。好不容易能上传任意文件了,下载下来却发现多了一个`.txt`后缀。
13+
ELF怎么可能有后缀呢...
14+
---
15+
16+
# 发现bug.txt
17+
18+
最近我们的新生训练赛就要开始了,题目陆陆续续到了测试阶段,对于pwn题,
19+
一些简单题就只需要提供一个binary文件,没有后缀,下载下来就能直接打。结果上传一看,
20+
限制了文件类型。好不容易能上传任意文件了,下载下来却发现多了一个`.txt`后缀。
21+
ELF怎么可能有后缀呢?检查http响应,没有问题:
22+
23+
```http
24+
HTTP/1.1 200 OK
25+
Date: Sun, 31 Aug 2025 13:20:35 GMT
26+
Server: Apache/2.4.52 (Ubuntu)
27+
Accept-Ranges: bytes
28+
Content-Disposition: attachment; filename="shellcode"
29+
Content-Length: 16552
30+
Content-Type: application/zip
31+
Last-Modified: Sun, 31 Aug 2025 13:13:15 GMT
32+
Keep-Alive: timeout=5, max=100
33+
Connection: Keep-Alive
34+
```
35+
36+
然而实际下载的文件却变成了`shellcode.txt`
37+
38+
于是我跑到[我自己的网站](/2025/02/13/RocketCup2025)上,
39+
有一个`putenv`文件,尝试下载,http响应是这样的:
40+
41+
```http
42+
HTTP/1.1 200 OK
43+
Date: Sun, 31 Aug 2025 13:24:26 GMT
44+
Server: openresty
45+
Content-Disposition: inline; filename=putenv
46+
Content-Length: 16552
47+
Content-Type: application/octet-stream
48+
Last-Modified: Sat, 08 Feb 2025 06:30:59 GMT
49+
```
50+
51+
下载下来是没有后缀名的。这不是都正确声明`Content-Disposition`了吗,
52+
为什么还是不能下载无后缀名的文件呢?
53+
54+
# 尝试复现.txt
55+
56+
尝试修改自己的python服务,使响应和平台的一致,并没有任何效果。既不是因为`Content-Type`
57+
又不是因为`Content-Disposition`,真正的原因是什么呢?
58+
59+
# 硬调chromium.txt
60+
61+
为了深入理解其中发生了什么,只能调试浏览器了,看看下载文件中发生了什么。由于chromium
62+
是开源的,可以就着源码,拿符号对着看。问题是,浏览器这么多进程,怎么调试呢?
63+
阅读[官方的调试指南](https://chromium.googlesource.com/chromium/src/+/HEAD/docs/linux/debugging.md)
64+
里面提到可以关闭沙箱,并限制renderer进程,然而这仍然不够,还需要搭配上gdb调试的一些设置,
65+
它们是:
66+
67+
```plaintext stub
68+
set breakpoint always-inserted on
69+
set detach-on-fork off
70+
set non-stop on
71+
```
72+
73+
这样就能让所有进程同时运行,同时将断点下在所有进程中,并且监控所有进程。
74+
75+
那么方法有了,调试什么函数呢?直接使用copilot读取仓库并寻找下载时指定文件名的函数,
76+
定位到了`net::GenerateFileNameImpl`函数,然后直接跟到`GetSuggestedFilenameImpl`
77+
其中的重要内容大致是
78+
79+
```cpp /net/base/filename_util_internal.cc
80+
std::u16string GetSuggestedFilenameImpl(
81+
const GURL& url,
82+
const std::string& content_disposition,
83+
const std::string& referrer_charset,
84+
const std::string& suggested_name,
85+
const std::string& mime_type,
86+
const std::string& default_name,
87+
bool should_replace_extension,
88+
ReplaceIllegalCharactersFunction replace_illegal_characters_function) {
89+
90+
...
91+
std::string filename; // In UTF-8
92+
bool overwrite_extension = false;
93+
bool is_name_from_content_disposition = false;
94+
// Try to extract a filename from content-disposition first.
95+
if (!content_disposition.empty()) {
96+
HttpContentDisposition header(content_disposition, referrer_charset);
97+
filename = header.filename();
98+
if (!filename.empty())
99+
is_name_from_content_disposition = true;
100+
}
101+
// Then try to use the suggested name.
102+
if (filename.empty() && !suggested_name.empty())
103+
filename = suggested_name;
104+
105+
...
106+
107+
// extension should not appended to filename derived from
108+
// content-disposition, if it does not have one.
109+
// Hence mimetype and overwrite_extension values are not used.
110+
if (is_name_from_content_disposition)
111+
GenerateSafeFileName("", false, &result);
112+
else
113+
GenerateSafeFileName(mime_type, overwrite_extension, &result);
114+
115+
std::u16string result16;
116+
if (!FilePathToString16(result, &result16)) {
117+
result = base::FilePath(default_name_str);
118+
if (!FilePathToString16(result, &result16)) {
119+
result = base::FilePath(kFinalFallbackName);
120+
FilePathToString16(result, &result16);
121+
}
122+
}
123+
return result16;
124+
}
125+
```
126+
127+
接着我们使用`gdb -x stub -args /usr/lib/chromium/chromium --no-sandbox --single-process`
128+
启动浏览器调试,把断点下在`net::GenerateFileNameImpl`,下载调试符号,让浏览器跑起来。
129+
启动完成以后尝试下载一下文件,观察到此时进入调试态,注意寄存器的状态是什么样的。
130+
131+
{% note blue fa-box %}
132+
虽然Arch提供了chromium的调试符号包,但是调试符号中只有函数名,这意味着我们没有源码可以看,只能盲调。
133+
通过pwndbg的`nextcall`命令,可以很方便地观察调用了哪些函数,就着源码看也还行。
134+
{% endnote %}
135+
136+
例如以下是下载我网站上文件时寄存器的状态,根据源码,rdx是指向`Content-Disposition`的字符串,
137+
rsi是指向url的字符串,r9是指向mime_type的字符串,即`application/octet-stream`。
138+
139+
![regs](/assets/trueblog/chromium/putenv_regs.png)
140+
141+
接下来不断使用`nextcall`去找我们感兴趣的函数,以此侧面观察控制流(记得在
142+
`GetSuggestedFilenameImpl`步入)。在这里我们就能观察到解析了`Content-Disposition`。
143+
144+
![header](/assets/trueblog/chromium/parse_header.png)
145+
146+
之后调用了`net::EnsureSafeExtension`函数,把参数对应过去,mime_type是空字符串,
147+
后缀名被`Content-Disposition`强制指定了,因此没有添加后缀名。(#L32)
148+
149+
![extension](/assets/trueblog/chromium/putenv_ext.png)
150+
151+
接着看看平台上下载文件的例子,首先观察mime_type是`text/plain`,而且链接前面是`blob`,
152+
并且没有`Content-Disposition`头的信息。
153+
154+
![regs](/assets/trueblog/chromium/blob_regs.png)
155+
156+
继续执行,直接到了`EnsureSafeExtension`的地方,此时我们发现走了另一个分支(
157+
`!is_name_from_content_disposition`),阅读源码可知在这里由于传入的mime_type是`text/plain`,
158+
因此后续发现prefered extension是`.txt`,导致被强制加上了.txt后缀。(#L34)
159+
160+
![extension](/assets/trueblog/chromium/blob_ext.png)
161+
162+
{% note green fa-lightbulb %}
163+
在chromium查找扩展名的过程中,由于`application/octet-stream`是“通用二进制数据”,
164+
因此检查直接返回了,不要求后缀名。
165+
{% endnote %}
166+
167+
# 错误设置的下载方式
168+
169+
原先平台上下载方式是把请求响应包在blob里,然后模拟链接点击下载,
170+
mime_type是在链接中设置的,查阅
171+
[Mozilla HTTP 文档](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Reference/Elements/a#download)
172+
发现需要在blob中设置才行。
173+
174+
<img src="/assets/trueblog/chromium/doc.png" width="60%">
175+
176+
# 修复这个小bug
177+
178+
给blob加上mime_type就行。
179+
180+
```diff
181+
@@ -418,14 +418,13 @@
182+
}
183+
184+
// 创建 blob URL
185+
- const blob = new Blob([response.data]);
186+
+ const blob = new Blob([response.data], { type: 'application/octet-stream' });
187+
const url = window.URL.createObjectURL(blob);
188+
189+
// 创建临时下载链接
190+
const link = document.createElement('a');
191+
link.href = url;
192+
link.download = filename;
193+
- link.type = 'application/octet-stream';
194+
document.body.appendChild(link);
195+
link.click();
196+
```
197+
198+
# 参考
199+
200+
1. [2025 新年火箭杯 | RocketDevlog](/2025/02/13/RocketCup2025)
201+
2. [Chromium Docs - Tips for debugging on Linux](https://chromium.googlesource.com/chromium/src/+/HEAD/docs/linux/debugging.md)
202+
3. [net::GetSuggestedFilenameImpl | GitHub](https://github.com/chromium/chromium/blob/main/net/base/filename_util_internal.cc#L219)
203+
4. [&lt;a&gt;:锚元素](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Reference/Elements/a#download)
101 KB
Loading
97.7 KB
Loading
127 KB
Loading
120 KB
Loading
104 KB
Loading
84.5 KB
Loading

0 commit comments

Comments
 (0)