신입 OJT 기간에 들었던 초보자용(?) 시큐어 코딩 교육을 다시 한 번 들었다.
그 때도 나름 열심히 듣는다고 들었는데, 프로젝트를 마친 시점에 다시 들으니 그때는 단순히 개념적으로만 와닿던 내용들이 내 코드에 대입되어 들리기 시작했다. 듣다보니 '내 코드는 저런 보안취약점을 모두 고려했나? 놓친 부분이 많은 것 같은데.. 수정해야겠다' 는 생각이 들었다.
꽤 긴 내용이었지만 그 중 중요하다고 생각하는 내용과 내 프로젝트에 부족했던 부분들 위주로 정리해보았다.
(정리하다 보니 계속계속 추가하게 되고.. 점점 길어졌당..)
Secure Coding(시큐어 코딩)이란?
시큐어 코딩이란 Secure(보안)과 Coding의 합성어로 소프트웨어 개발 단계에서 보안 취약점을 미리 파악하고, 잠재적으로 발생할 보안 위협에 대해 사전에 방지하는 것을 말한다. 보안에 여러 영역이 있지만 그 중 개발자가 담당하는 영역이다.
웹 애플리케이션 특성 상 불특정 다수가 접근 가능하고, 보안 위협에 대한 공격 방식이 다양해지고 있기 때문에 개발자는 자신이 만든 프로그램에서 취약점이 발생하지 않도록 신경써야 한다.
2. 방어적 프로그래밍(if-else문, switch문)
1. 사용자 입력데이터 검증 및 표현
1) Validation Check는 Client, Server 모두
웹 애플리케이션에서 사용자의 데이터를 입력받을 경우, 데이터 검증은 Client와 Server 모두에서 이루어져야 한다.
Client에서의 방어로직은 script 조작으로 얼마든지 무효화가 가능하여 비정상적인 요청이 그대로 전달될 수 있기 때문이다. 비정상적인 요청이 전달되어 비정상적인 쿼리가 실행될 수 있거나, DB에 전달되어 이후 잘못된 resource를 반환할 수 있다. Client에서의 유효성 검증이 불필요한 것은 아니지만, 완벽한 방어로직이 아니라 가이드라인 정도로 생각할 수 있다.
유효성 검증을 하지 않았을 경우 발생할 수 있는 몇 가지 예를 들자면
1) 필수값을 입력하지 않고 스크립트를 조작하여 데이터를 전송
2) 비밀번호 변경 서비스를 악용하여 타인의 비밀번호를 변경
3) 고객코드를 변조하여 타인의 데이터를 조회
2) SQL Injection
SQL Injection이란 사용자가 의도적으로 보안 취약점을 이용하여 악의적인 쿼리를 실행시키는 것을 말한다.
사용자의 입력값에 대한 유효성 검증이 확실하게 이루어지지 않을 경우 SQL Injection으로 인해 공격자에게 인가되지 않은 정보가 노출될 수 있다.
SQL Injection의 방법은 여러가지가 있다. MyBatis 프레임워크를 사용했을 경우로 예를 들어보겠다. MyBatis에서는 데이터를 바인딩 하는 방법이 ${데이터}와 #{데이터} 두 가지가 있다. $가 기존방식이고 #가 새로 도입된 방식인데, 두 방식의 차이점은 ${}는 입력값을 받은 그대로 바인딩 시킨다는 것이고, #{}는 입력값을 문자열로 바꿔서 처리한다.
아래와 같이 고객 정보 테이블을 조회하는 SQL 쿼리가 있을 때 공격자가 입력값을 '아무값' or 1=1 이라고 넣는 것이다. 그럼 WHERE 절이 WHERE CUST.ID = '아무값' or 1=1이 되어 모든 쿼리가 조회될 수 있다.
SELECT CUST.ID
, CUST.NAME
, CUST.EMAIL
, CUST.PHONE
FROM CUSTOMER CUST
WHERE CUST.ID = ${ID}
그 외에도 입력값을 '아무값 --'로 넣어 뒤의 쿼리문을 전부 주석으로 처리하게 할 수도 있고, 공격방식은 다양하다.
+ 나무위키를 찾아보니 아래와 같은 유머가 있었다ㅋㅋ
짧게 설명하자면 학교에서 학생 정보를 입력하는 쿼리가 아래와 같고
INSERT INTO STUDENTS(NAME) VALUES('학생이름');
아들 이름을 넣었을 때 이렇게 실행되면서 기존 테이블이 날아간 것이다.
INSERT INTO STUDENTS(NAME) VALUES('ROBERT');
DROP TABLE STUDENTS;
사실 나도 입사 초기에 시큐어 코딩 강의를 듣고도 프로젝트를 하다가 동적 쿼리가 필요한 부분이 있어서 나도모르게 ${}를 사용하고는 프로젝트 오픈 전 보안취약 테스트에 걸려 급히 #{}로 바꾸고 대신 조건문을 추가하였다. 당연히 #{}도 SQL Injection에 대한 해결책은 아니지만 ${}는 특히 취약하므로 사용을 자제해야 한다.
Spring Boot에서는 SQL Injection Dependency를 추가해주면 방어가 가능하다고 한다.
<!-- Maven일 경우 -->
<!-- https://mvnrepository.com/artifact/com.github.rkpunjal.sqlsafe/sql-injection-safe -->
<dependency>
<groupId>com.github.rkpunjal.sqlsafe</groupId>
<artifactId>sql-injection-safe</artifactId>
<version>1.0.2</version>
</dependency>
# Gradle일 경우
implementation group: 'com.github.rkpunjal.sqlsafe', name: 'sql-injection-safe', version: '1.0.2'
2. 방어적 프로그래밍(if-else문, switch문)
1) if-else 문에서 else문에 대한 로직 반드시 추가
if-else문을 이용하여 분기처리 시 최종적으로 else에 대한 판단을 반드시 추가해야 한다.
예를 들어 고객코드를 이용하여 검증 로직을 구현할 때 else에 대한 로직을 빠뜨리면 if-else에 해당하지 않는 새로운 고객유형이 입력되었을 경우 어떤 else if문에도 걸리지 않은채 유효성 검사 없이 스크립트가 실행될 수 있다. if-else 문을 사용하여 검증이 이루어질 경우 else문에 예외적인 상황에 대한 처리가 필요하다.
if(custGbcd.equals("ADMIN")) {
// 관리자 로직
} else if(custGbcd.equals("MANAGER")) {
// 매니저 로직
} else if(custGbcd.equals("CUSTOMER")) {
// 일반 사용자 로직
}
2) switch-case 문에서 각 case는 독립적으로 구현, default와 break
switch-case문에서도 마찬가지로 default에 대한 처리와 case문마다 독립적으로 구현하여 break를 선언해주는 것이 좋다. switch-case 문의 특성상 break가 없을 경우 다음 case문을 차례로 실행하게 되는데, 이 경우 예상치 못한 오류가 발생할 수 있다.
예를 들어 ADMIN과 MANAGER가 동일한 로직을 공유하여, ADMIN에서 break없이 다음 MANAGER로 넘어가도록 하였을 때, 기존 서비스에서는 문제가 없지만 추후 'SELLER'와 같은 새로운 조건이 추가되었을 때 비즈니스 로직상 오류가 발생할 수 있다.
switch(custType) {
case 'ADMIN':
doAdminSomething();
// 어드민과 매니저가 공통된 로직을 사용하여 break문을 추가하지 않았을 경우
// 추후 'SELLER'와 같은 조건이 추가될 때 비즈니스 로직에서 예기치 못한 오류가 발생할 수 있다.
case 'MANAGER':
doGeneralSomething();
break;
case 'CUSTOMER':
doCustomerSomething();
break;
default:
throw new UnknownTypeException();
}
따라서 위와 같이 코드를 작성하기보다 서로 독립적으로 구분하고 break를 지정해주는 것이 추후 발생할 상황에 대해 안정적으로 대응할 수 있다.
switch(custType) {
case 'ADMIN':
doAdminSomething();
doGeneralSomething();
break;
case 'SELLER':
doSellerSomething();
break;
case 'MANAGER':
doGeneralSomething();
break;
case 'CUSTOMER':
doCustomerSomething();
break;
default:
throw new UnknownTypeException();
}
3. File Upload / Download
사용자 프로필을 변경하거나 게시글의 첨부파일을 등록할 때 파일 업로드/다운로드 기능을 사용하곤 한다. 파일 업로드 시에도 악성 파일로 인한 보안 취약점 발생의 위험이 높으므로 각별히 신경써야 한다.
1) 확장자명(Extension)
파일 업로드 시에는 필수로 확장자명을 체크해야 한다. 그렇지 않을 경우 exec파일과 같이 악의적인 실행파일로 인해 시스템에 보안 위협이 발생할 수 있다.
이 때 허용되지 않는 확장자명(black list 방식)으로 체크하는 것이 아닌, 허용되는 확장자명(white list 방식)으로 체크해야 한다. 예를 들어 이미지 파일 업로드 시에는 .jpg, .png만 허용한다거나 첨부파일 업로드 시에는 .pdf, .jpg, .docs 등 특정 허용 리스트를 만들어 체크해야 한다. 블랙리스트 방식으로 체크할 경우 .php, .exec를 막았을 때 입력값이 .pHp, .EXEC와 같이 대소문자가 변경되어 들어온다면 우회가 가능하다.
추가로 확장자명을 검증할 때는 String.indexOf() 를 이용할 경우 '파일명.jpg.exec'와 같이 파일명을 여러 개 사용함으로써 우회가 가능하므로 String.lastIndexOf()나 endsWith()를 사용하거나 외부 라이브러리를 이용하여 확실히 검증 하는 것이 좋다.
2) Content-Type 우회
Content-Type을 이용하여 유효성 검증을 하는 경우에 Content-Type 변조를 통해 검증을 우회할 수 있다. 해당 파일의 원래 타입이 application/octet-stream이지만 업로드 시 프록시를 통해 가로채서 image/jpeg로 변조가 가능하다.
Apache Tika와 같은 라이브러리를 통해 파일의 위변조를 체크할 수 있다.
3) 그 외
확장자 대소문자 Null byte를 이용하는 방식이나 이미지 안에 악의적인 스크립트를 넣어 실행시키는 방식 등의 방식들도 존재한다.
→ 리눅스 시스템에서는 Null byte(%00)을 종료문자로 인식하기 때문에 파일명을 '파일명.jsp%00.jpg"로 보낼 경우 '파일명.jsp'로 변환되어 우회가 가능하다.
이 외에도 다양한 공격 방식이 존재하므로 파일을 업로드할 때는 파일 크기 제한, 파일명, 확장자 등을 통해 파일의 유효성을 반드시 검사하고, 서버 내부에 파일 실행 권한을 주지 않아 악성 파일을 실행시키지 못하도록 차단하는 방법을 통해 공격에 대한 대비가 반드시 필요하다.
4. 인증/인가(Authentication/Authorization)
인증과 인가의 차이를 쉽게 말하면 인증은 해당 사용자가 로그인을 했는지, 인가는 해당 사용자의 권한이다(고객인지, 관리자인지, ...)
1) 권한에 따른 적절한 인증 추가
중요한 기능에 대한 적절한 검증이 필요하다. 예를 들어 로그인 사용자에게만 노출되는 서비스의 경우 Interceptor 단에서 유효성 검증을 체크하거나, 데이터의 수정/삭제에 대해 인증한 사용자와 수정 대상이 일치하는지에 대한 인가 체크가 필요하다.
2) 반복된 시도에 대한 제한
적절한 유효성 검증이 추가되었다고 해도 반복된 시도에 대한 제한이 없다면 모든 경우의 수를 전부 탐색하는 방식으로 공격이 가능하다. 일정 횟수 이상 반복된 오류가 발생할 경우 횟수 제한을 두어 재인증을 요구하거나 잠금 처리를 하는 방식으로의 제한이 필요하다.
5. 부적절한 예외처리
1) 의미없는 예외처리
의미없는 예외처리란 예외 상황에 대응하는 로직의 부재이다. try-catch 문에서 catch 안에서 예외처리 발생에 대해 어떠한 로직도 수행하지 않을 경우 시스템이 오류를 발생시키지 않고 정상적으로 진행되기 때문에 비정상적인 로직이 실행되더라도 원인을 찾기 어려워진다. 예외 상황에 대해 로그를 쌓거나 적절한 상태값을 리턴하도록 설계가 필요하다.
2) 부적절한 예외처리(광범위한 예외처리)
프로그램에서 SQL Exception, Null point Exception, Authorization Exception 등 다양한 예외가 발생하는데 이를 세분화하지 않고 광범위하게 Exception으로 처리한다면 각 예외상황에 대한 적절한 조치가 이루어지지 않을 수 있다.
try {
// ...
} catch(Exception e) {
log.warn(e.getMessage());
}
따라서 아래와 같이 각 예외상황에 대해 세분화 하여 처리해주어야 한다.
try {
// ...
} catch(IOException e) {
log.warn("IOException: " + e.getMessage());
// 추가 처리 로직
} catch(SQLException e) {
log.warn("SQLException: " + e.getMessage());
// 추가 처리
}
'생각' 카테고리의 다른 글
프로젝트 회고 - (with 코드리뷰) (0) | 2021.11.15 |
---|
댓글