Burp Suite: Server-side vulnerabilities (Part 4): 파일 업로드 취약점
File upload vulnerabilities
파일 업로드 취약점
user가 웹서버의 파일 시스템에 name, type, contents, size 등에 대한 충분한 검증 없이 업로드 하는 것을 파일 업로드 취약점이라고 한다. 이런 제한 사항을 강제하지 않으면 단순한 이미지 업로드 기능도 공격자가 임의의 파일 ~ 위험한 파일까지도 업로드할 수 있는 통로가 된다. 특히 RCE(Remote control execution) 공격이 가능한 서버 사이드 스크립트 공격이 가능해짐.
워낙 위험한 취약점이다 보니 실제 운영 중인 웹사이트(in the wild)에서 사용자가 어떤 파일이든 무제한으로 업로드할 수 있게 하는 경우는 드물고, 개발자들이 검증 로직을 제대로 구현했다고 생각하지만 본질적으로 취약하거나, 쉽게 우회될 수 있는 경우가 많은 것.
예를 들어서 개발자가 위험한 파일 유형을 블랙리스트로 차단했을 때, 확장자 검사 시 파싱 방식의 차이를 고려하지 않으면 공격자가 이를 우회할 수 있다. 드물게 사용되는 위험한 파일 유형을 실수로 누락하기 쉽다는 뜻인데.. 파싱 방식의 차이라!?
파일이 shell.php.jpg로 되어 있는 경우 검증 로직이 마지막 확장자인 jpg 파일만 보고 jpg 파일이구나~ 통과시키는 등을 뜻한다.
웹사이트가 파일 유형을 검사할 때, 공격자가 쉽게 조작할 수 있는 속성(예: Content-Type, 파일 헤더)을 기준으로 확인하는데 공격자는 Burp Suite의 Proxy나 Repeater 같은 도구를 통해 이런 값을 조작할 수 있다.
검증 로직 자체가 아무리 강력해도 웹사이트를 구성하는(form) 호스트와 경로간의 네트워크가 일관되게 적용되지 않았다면 이런 모순을 이용해 공격자가 악용할 수 있다.
Exploiting unrestricted file uploads to deploy a web shell
보안 즉면에서 최악의 시나리오는 공격자가 php, java, python 파일 같은 걸 코드로 실행할 수 있는 서버 사이드 스크립트를 웹사이트에 업로드할 수 있는 경우다. 이는 내가 만든 웹쉘을 서버에 매우 손쉽게 업로드할 수 있게 됨.
웹쉘은 공격자가 HTTP 요청을 통해 원격 웹 서버에서 임의의 명령을 실행할 수 있도록 해주는 악성 스크립트로 업로드에 성공하면 공격자가 서버에 대한 제어권을 가지게 될 가능성이 높다. 결국 원하는 파일을 읽고 쓸 수 있으며, 민감한 데이터를 외부로 빼낼 수 있고, 이 서버를 거점(pivot point)으로 삼아 내부 인프라뿐 아니라 외부 네트워크에 있는 다른 서버들로 공격을 확장할 수도 있게 된다.
다음 PHP 코드는 서버의 파일 시스템에서 임의의 파일을 읽는 데 사용된다.
<?php echo file_get_contents('/path/to/target/file'); ?>
서버에 존재하는 특정 파일을 읽고, 그 내용을 사용자(공격자)에게 응답으로 출력하는 역할을 함.
이 악성 파일을 서버에 업로드한 뒤, 해당 파일에 요청을 보내면 대상 파일의 내용이 HTTP 응답으로 반환된다.
<?php echo system($_GET['command']); ?>
이 스크립트는 쿼리 파라미터를 통해 임의의 시스템 명령어를 전달하여 실행할 수 있게 해준다.
GET /example/exploit.php?command=id HTTP/1.1
command=id를 붙여 요청을 보내면, 서버가 id 명령어를 실행한다.
system()은 php의 내장 함수로, 서버의 시스템 명령어를 실행해 결과를 출력한다. echo가 http 응답으로 출력한다~라고 보면됨
Lab
웹쉘 업로드를 통한 RCE 공격 시도하기
취약한 이미지 업로드 기능이 포함되어 있는데, 사용자가 업로드하는 파일에 대해 파일 이름이나 형식, 내용 등 유효성 검사 없이 서버 파일 시스템에 그대로 저장한다. PHP 웹쉘을 업로드하고 /home/carlos/secret 파일의 내용을 유출시키면 된다.
Lab 상단에 있는 secret 값을 제출하면 끝
wiener 계정으로 로그인하니 my-account 페이지에서 avatar에 이미지 파일을 선택해 업로드할 수 있다.
요런식으로 업로드가 가능
http history에 가서 MIME type에 images도 체크하라고 한다.
이런식으로 뜰것이다.
/files/avatars/t.jpg 처럼 get요청으로 이미지 업로드가 되는 것을 확인했다. 이걸 repeater로 보내보자.
여기에 웹쉘파일을 만들어서, carlos의 /home/carlos/secret 파일을 읽어오는 코드를 넣어봤다.
이런식으로..?
다시 업로드 했더니 다음과 같이 reponse에 값이 나온다.
Exploiting flawed validation of file uploads
실제 운영 환경에서는, 이전 실습처럼 파일 업로드 공격에 대해 아무런 방어도 없는 웹사이트는 드물다. 물론방어 체계가 존재한다고 해서 그것이 견고하다는 의미도 아님. 이런 방어 메커니즘의 허점을 악용해 웹쉘을 업로드하고 원격 코드 실행(RCE)을 할 수 있는 경우도 있는데, 이에 대해 배워보려고 한다.
HTML 폼을 제출할 때, 브라우저는 일반적으로 입력한 데이터를 application/x-www-form-urlencoded 형식의 POST 요청으로 전송한다. 이 방식은 이름이나 주소처럼 단순한 텍스트 데이터를 전송할 때는 적절한데, image 파일, pdf 문서처럼 큰 용량의 binary 데이터를 전송하기에는 적합하지 않다. 이런 경우에는 multipart/form-data 형식을 사용하는 것이 더 적절하다.
-> image, webshell 파일 업로드는 단순 텍스트 전송 방식(x-www-form-urlencoded)이 아니라, multipart/form-data 방식으로 전송해야 서버가 파일 내용까지 제대로 수신할 수 있음.
x-www-form-urlencoded이란? HTML 폼에서 데이터를 보낼 때 사용하는 MIME 타입(Content-Type) 중 하나로, 텍스트 기반의 키-값 쌍을 URL 인코딩 방식으로 인코딩해서 전송한다.
이미지를 업로드하고, 그 이미지에 대한 설명, 그리고 username을 입력하는 필드가 있는 form이 있다고 가정해보자.
POST /images HTTP/1.1
Host: normal-website.com
Content-Length: 12345
Content-Type: multipart/form-data; boundary=---------------------------012345678901234567890123456
---------------------------012345678901234567890123456
Content-Disposition: form-data; name="image"; filename="example.jpg"
Content-Type: image/jpeg
[...example.jpg의 바이너리 데이터...]
---------------------------012345678901234567890123456
Content-Disposition: form-data; name="description"
This is an interesting description of my image.
---------------------------012345678901234567890123456
Content-Disposition: form-data; name="username"
wiener
---------------------------012345678901234567890123456--
각 구간을 ----바이너리데이터로 나누고 있고, 각 파트를 Content-Disposition 헤더로 이름(name), 파일명(filename) 등을 정의한다.
파일과 관련된 파트에는 Content-Type이 명시되어 있어 MIME 타입(image/jpeg, text/plain, application/x-php 등)을 서버가 알 수 있도록 함.
4번째 라인 Content-Type: multipart/form-data; boundary=---------------------------012345678901234567890123456은 요청 본문이 multipart/form-data 형식이며, 각 입력 필드를 구분하는 boundary 문자열이 사용된다.
---------------------------012345678901234567890123456
Content-Disposition: form-data; name="image"; filename="example.jpg"
Content-Type: image/jpeg
[...example.jpg의 바이너리 데이터...]
여기까지는 업로드한 이미지 파일에 대한 정보로, Content-Disposition: form-data는 폼의 일부라는 의미
name="image": input 필드의 name 속성 값
filename="example.jpg": 업로드한 파일명
Content-Type: image/jpeg: 전송된 파일의 MIME 타입 (JPEG 이미지)
그 아래 줄부터는 이미지의 실제 이진(binary) 데이터가 들어갈 예정.
이미지 파일, 설명에 대한 텍스트 필드, 사용자 이름 필드 세가지가 들어감.
맨 밑에는 ---------------------------012345678901234567890123456-- 마지막 파트라는 것을 나타내는 구분선으로 맨 뒤에 --가 붙어서 multipart 데이터의 끝을 나타냄
위와 같이 메시지 본문은 폼의 각 입력 항목(input)에 대해 별개의 파트로 나뉘어진다. 각 파트에는 Content-Disposition 헤더가 포함되어 있으며, 이는 해당 파트가 어떤 입력 필드에 해당하는지를 설명해주는 기본 정보를 제공한다. 이러한 개별 파트는 자신만의 Content-Type 헤더를 가질 수도 있는데, 이 헤더는 해당 입력값으로 전송된 데이터의 MIME 타입을 서버에 알려주는 역할을 한다.
Content-Disposition | Content-Type | |
의미 | 이 데이터가 어떤 폼 필드에 대응되는지 설명 | 이 데이터의 **형식(MIME 타입)**을 설명 |
주요 목적 | 파일 이름, 필드 이름 등 메타정보 제공 | 서버가 데이터 해석 방식을 알도록 도움 |
자주 쓰는 위치 | multipart/form-data의 각 파트 | 파일 업로드 시 파일 데이터 바로 위에 |
예시 | form-data; name="avatar"; filename="cat.jpg" | image/jpeg |
서버 입장 | 어떤 input(name)이 어떤 값인지 파악 | 이 데이터가 텍스트인지, 이미지인지, 실행 파일인지 파악 |
웹사이트가 파일 업로드를 검증하는 한 가지 방법은, 해당 입력 필드에 포함된 Content-Type 헤더가 예상되는 MIME 타입과 일치하는지 확인하는 것이다. 서버가 이미지 파일만 받도록 설계되었다면 image/jpeg나 image/png 같은 타입만 허용할 수 있음.
근데 이때 content-type 값만 맹신하면 문제가 생긴다. 파일의 실제 내용이 이 MIME 타입과 일치하는지를 추가로 확인하지 않으면, Burp Repeater 같은 도구를 사용해 이 방어를 쉽게 우회할 수 있음.
Lab
웹쉘을 업로드 해 /home/carlos/secret 파일을 탈취하는 실습으로, 포인트는 Content-Type 검증 우회에 해당한다.
이미지 업로드 기능으로 제한하지만, 파일 타입(Content-Type)을 클라이언트 입력만으로 검증하기 때문에 서버가 실제 파일 내용까지는 검사하지 않는다.
이전 실습과 동일하게 php 파일만 업로드사면 다음과 같은 에러가 뜬다. 즉 image/jpeg, image/png 파일만 업로드할 수 있다는 뜻이다.
filename="exploit.php"의 Content-Type의 application/octet-stream 을 image/jpeg로 바꿔준다. 이 애플리케이션 옥텟 스트림이라는건 뭐냐면 일반적인 이진 데이터(binary data)를 의미하는 MIME 타입이다. 서버가 이 값을 보면: "어떤 형식인지 모르겠지만 일단 바이너리 파일이구나"라고 해석해요.
이미지도 아니고, 텍스트도 아니고, 정체불명의 바이너리 파일.. 보안이 강한 서버는 "octet-stream" 같은 애매한 타입의 파일 업로드를 거부할 수 있음. 명확한 이미지 타입 (image/jpeg, image/png 등)이 아닌 이상은 위험하다고 간주하기 때문
맨 ~위에 있는 Content-Type: multipart/form-data; boundary=... 부분을 수정해야 되는 줄 알았는데, 이건 전체 POST 요청의 구조를 설명하는 헤더로, 이번 요청은 여러 파트로 나뉘어져 있으니까 각 파트를 boundary로 구분할게~라는 뜻이다. 즉 요청 본문 전체에 대한 메타 정보고 실제 우회에 필요한 건 요청 본문 내 파일 파트에 있는 Content-Type이다.
my-account내에는 오류가 뜨지만
실제 response 값을 보면 성공한 것을 알 수 있다.
개발자도구에서도 볼 수 있음.
출처: portswigger