인증, 인가, 암호화, 세션 관리 등 애플리케이션의 핵심 보안 기능 구현 시 발생하는 취약점 및 안전한 코드 가이드입니다.
중요 기능이나 자원을 요청할 때 인증 여부를 확인하지 않아 중요 정보가 노출되거나 권한이 도용될 수 있습니다.
/* 취약한 코드 (Java) */
@RequestMapping(value = "/modify.do", method = RequestMethod.POST)
public ModelAndView memberModifyProcess(@ModelAttribute("MemberModel") MemberModel memberModel, HttpServletRequest request, HttpSession session) {
// 1. 로그인한 사용자를 불러온다.
String userId = (String) session.getAttribute("userId"); // 로그인 ID
String requestedUserId = memberModel.getUserId(); // 요청 ID
// 로그인한 사용자와 요청한 사용자 ID 일치 확인 누락
if (service.modifyMember(memberModel)) {
// 회원정보 수정 로직
}
// 만약 requestedUserId가 다른 사용자 ID라면 IDOR 발생
}
/* 안전한 코드 (Java) */
@RequestMapping(value = "/modify.do", method = RequestMethod.POST)
public ModelAndView memberModifyProcess(@ModelAttribute("MemberModel") MemberModel memberModel, HttpServletRequest request, HttpSession session) {
String userId = (String) session.getAttribute("userId");
String requestedUserId = memberModel.getUserId();
// 요청된 자원이 로그인된 사용자의 것인지 확인
if (!requestedUserId.equals(userId)) {
// 권한 없음 에러 처리
throw new SecurityException("접근 권한이 없습니다.");
}
if (service.modifyMember(memberModel)) {
// 안전하게 회원정보 수정
}
}
프로그램이 모든 실행 경로에 대해 접근 제어를 검사하지 않거나 불완전하게 검사하여 비인가자가 정보를 유출할 수 있습니다.
/* 취약한 코드 (Java) */
private BoardDao boardDao;
String action = request.getParameter("action");
String contentId = request.getParameter("contentId");
// 요청을 하는 사용자의 delete 작원 권한 확인 없이 수행하고 있어 안전하지 않다.
if (action != null && action.equals("delete")) {
boardDao.delete(contentId);
}
/* 안전한 코드 (Java) */
private BoardDao boardDao;
String action = request.getParameter("action");
String contentId = request.getParameter("contentId");
User user = (User)session.getAttribute("user");
// 사용자정보에서 해당 사용자가 delete작업의 권한이 있는지 확인한 뒤 삭제 작업을 수행한다.
// checkAccessControlList(user, action) 로직을 통해 권한을 검사해야 함
if (action != null && action.equals("delete") && checkAccessControlList(user, action)) {
boardDao.delete(contentId);
}
중요 자원(설정 파일 등)에 대해 최소 권한이 아닌 과도한 권한이 부여되어 비인가자에게 노출됩니다.
/* 취약한 코드 (Java) */
File file = new File("/home/setup/system.ini");
// 모든 사용자에게 실행, 읽기, 쓰기 권한을 허용하여 안전하지 않다.
file.setExecutable(true, false); // 모든 사용자 실행 허용
file.setReadable(true, false); // 모든 사용자 읽기 허용
file.setWritable(true, false); // 모든 사용자 쓰기 허용
/* 안전한 코드 (Java) */
File file = new File("/home/setup/system.ini");
// 소유자에게만 권한을 할당 (최소 권한의 원칙)
file.setExecutable(false); // 소유자 실행 권한 금지 (필요 시 true)
file.setReadable(true); // 소유자 읽기 권한 허용
file.setWritable(false); // 소유자 쓰기 권한 금지 (필요 시 true)
DES, RC5, MD5 등 정보보호 측면에서 취약한 암호화 알고리즘을 사용하여 중요 정보의 기밀성을 보장할 수 없습니다.
/* 취약한 코드 (Java) */
public byte[] encrypt(byte[] msg, Key k) {
byte[] rslt = null;
try {
// 키 길이가 짧아 취약한 암호화 알고리즘인 DES를 사용하여 안전하지 않다.
Cipher c = Cipher.getInstance("DES");
c.init(Cipher.ENCRYPT_MODE, k);
rslt = c.update(msg);
} catch (/* ... */) { /* ... */ }
return rslt;
}
/* 안전한 코드 (Java) */
public byte[] encrypt(byte[] msg, Key k) {
byte[] rslt = null;
try {
// 키 길이가 길어 강력한 알고리즘인 AES를 사용하여 안전하다.
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(Cipher.ENCRYPT_MODE, k);
rslt = c.update(msg);
} catch (/* ... */) { /* ... */ }
return rslt;
}
중요 정보(개인정보, 패스워드 등)를 평문으로 **저장**하거나 **전송**하여 인가되지 않은 사용자에게 노출됩니다.
/* 취약한 코드 (Java) */
String pwd = request.getParameter("pwd");
// ... (SQL 쿼리)
stmt.setString(2, pwd); // 입력받은 패스워드가 평문으로 DB에 저장되어 안전하지 않다.
stmt.executeUpdate();
/* 안전한 코드 (Java) */
String pwd = request.getParameter("pwd");
// 패스워드를 솔트값을 포함하여 SHA-256 해쉬로 변경하여 안전하게 저장한다.
MessageDigest md = MessageDigest.getInstance("SHA-256");
// md.update(salt); // 솔트 적용 로직
byte[] hashInBytes = md.digest(pwd.getBytes());
// ... (해시값을 문자열로 변환)
pwd = sb.toString();
// ...
stmt.setString(2, pwd);
stmt.executeUpdate();
/* 취약한 코드 (Java) */
try {
Socket s = new Socket("taranis", 4444);
PrintWriter o = new PrintWriter(s.getOutputStream(), true);
// 패스워드를 평문으로 전송하여 안전하지 않다.
String password = getPassword();
o.write(password);
} catch (/* ... */) { /* ... */ }
/* 안전한 코드 (Java) */
// 패스워드를 암호화 하여 전송
try {
Socket s = new Socket("taranis", 4444);
PrintStream o = new PrintStream(s.getOutputStream(), true);
// 패스워드를 강력한 AES암호화 알고리즘으로 암호화하여 전송
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
String password = getPassword();
byte[] encPassword = c.update(password.getBytes());
o.write(encPassword, 0, encPassword.length);
} catch (/* ... */) { /* ... */ }
소스코드 내부에 패스워드, 암호화 키 등 중요 정보를 상수로 직접 코딩하여 소스코드 유출 시 함께 노출됩니다.
/* 취약한 코드 (Java) */
public class MemberDAO {
// ...
private static final String USER = "SCOTT"; // DB ID;
private static final String PASS = "SCOTT"; // DB PW;
// ...
con = DriverManager.getConnection(URL, USER, PASS); // DB 패스워드가 소스코드에 평문으로 저장
}
/* 안전한 코드 (Java) */
public class MemberDAO {
// ...
public Connection getConn() {
// ...
// 암호화된 패스워드를 프로퍼티에서 읽어들여 복호화해서 사용해야한다.
String PASS = props.getProperty("EncryptedPswd");
byte[] decryptedPswd = cipher.doFinal(PASS.getBytes());
PASS = new String(decryptedPswd);
con = DriverManager.getConnection(URL, USER, PASS);
// ...
}
}
/* 취약한 코드 (Java) */
public String encriptString(String usr) {
// 암호화 키를 소스코드 내부에 사용하는 것은 안전하지 않다.
String key = "22df3023sf~2;asn!@#/>as";
if (key != null) {
byte[] bToEncrypt = usr.getBytes("UTF-8");
SecretKeySpec sKeySpec = new SecretKeySpec(key.getBytes(), "AES");
}
// ...
}
/* 안전한 코드 (Java) */
public String encriptString(String usr) {
// 암호화 키는 외부 파일에서 암호화 된 형태로 저장하고, 사용 시 복호화 한다.
String key = getPassword("./password.ini");
key = decrypt(key);
if (key != null) {
byte[] bToEncrypt = usr.getBytes("UTF-8");
SecretKeySpec sKeySpec = new SecretKeySpec(key.getBytes(), "AES");
}
// ...
}
키 길이가 짧으면 짧은 시간 안에 키를 찾아낼 수 있고 이를 이용해 공격자가 암호화된 데이터나 패스워드를 복호화 할 수 있게 됩니다.
/* 취약한 코드 (Java) */
public static void generateKey() {
try {
final KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM);
// RSA 키 길이를 1024 비트로 짧게 설정하는 경우 안전하지 않다.
keyGen.initialize(1024);
final KeyPair key = keyGen.generateKeyPair();
// ...
} catch (/* ... */) { /* ... */ }
}
/* 안전한 코드 (Java) */
public static void generateKey() {
try {
final KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM);
// 공개키 암호화에 사용하는 키의 길이는 적어도 2048비트 이상으로 설정한다.
keyGen.initialize(2048);
final KeyPair key = keyGen.generateKeyPair();
// ...
} catch (/* ... */) { /* ... */ }
}
예측 불가능한 난수가 필요한 상황(세션 ID, 암호화 키)에서 예측 가능한 난수(`java.util.Random`)를 사용하여 공격에 취약해집니다.
/* 취약한 코드 (Java) */
import java.util.Random;
public Static String getAuthKey() {
// 보안결정을 위한 난수로는 안전하지 않은 Random 클래스 사용
Random random = new Random();
String authKey = Integer.toString(random.nextInt());
// ...
}
/* 안전한 코드 (Java) */
import java.security.SecureRandom;
import java.security.MessageDigest;
public Static String getAuthKey() {
// 보안결정을 위한 난수로는 예측이 거의 불가능하게 암호학적으로 보호된 SecureRandom을 사용한다.
try {
SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
MessageDigest digest = MessageDigest.getInstance("SHA-256");
secureRandom.setSeed(secureRandom.generateSeed(128));
String authKey = new String(digest.digest((secureRandom.nextLong() + "").getBytes()));
} catch (/* ... */) { /* ... */ }
return authKey;
}
강한 비밀번호 조합 규칙(영문, 숫자, 특수문자 등)이 미흡하여 무차별 대입 공격(Brute Force)에 취약해집니다.
/* 취약한 코드 (Java) */
String pass = request.getParameter("pass");
UserVo userVO = new UserVo(id, pass);
// 비밀번호의 자릿수, 특수문자 포함 여부 등 복잡도를 체크하지 않고 등록
String result = registerDAO.register(userVO);
/* 안전한 코드 (Java) */
String pass = request.getParameter("pass");
// 비밀번호에 자릿수, 특수문자 포함 여부 등의 복잡도를 체크하고 등록하게 한다.
Pattern pattern = Pattern.compile("((?=.*[a-zA-Z])(?=.*[0-9@#$%]).{9, })");
Matcher matcher = pattern.matcher(pass);
if (!matcher.matches()) {
return "비밀번호 조합규칙 오류";
}
UserVo userVO = new UserVo(id, pass);
String result = registerDAO.register(userVO);
전자서명을 검증하지 않거나 검증 절차가 부적절하면 위변조된 파일/코드가 실행될 수 있습니다.
/* 취약한 코드 (Java) */
File f = new File(downloadedFilePath);
// 신뢰할 수 없는 곳에서 다운로드 한 JAR 파일의 서명을 확인하지 않고 사용
JarFile jf = new JarFile(f);
/* 안전한 코드 (Java) */
File f = new File(downloadedFilePath);
// JarFile 생성자에 boolean형 파라미터를 사용하여 전자서명을 확인
JarFile jf = new JarFile(f, true);
Enumeration ens = jf.entries();
while (ens.hasMoreElements()) {
JarEntry en = ens.nextElement();
if (!en.isDirectory()) {
// ...
CodeSigner[] signers = en.getCodeSigners(); // 서명 주체를 확인
}
}
SSL/TLS 통신 시 서버 측 인증서의 유효성을 확인하지 않거나 부적절하게 검증하여, 악성 호스트에 연결되거나 중간자 공격에 노출됩니다.
/* 취약한 코드 (Java) */
// PDF에 Java 코드 예제가 없어 C 코드 기반으로 유추
// (인증서 유효성 검증 없이 통신을 진행하는 로직)
URL url = new URL("https://untrusted.com/");
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setHostnameVerifier( (hostname, session) -> true ); // 모든 호스트명 허용 (매우 위험)
InputStream in = conn.getInputStream();
//...
/* 안전한 코드 (Java) */
private boolean verifySignature(X509Certificate toVerify, X509Certificate signingCert) {
/* 검증하려는 호스트 인증서(toVerify)와 CA인증서(signingCert)의 DN이 일치하는지 여부를 확인한다.*/
if (!toVerify.getIssuerDN().equals(signingCert.getSubjectDN())) return false;
try {
// 호스트 인증서가 CA인증서로 서명 되었는지 확인한다.
toVerify.verify(signingCert.getPublicKey());
// 호스트 인증서가 유효기간이 만료되었는지 확인한다.
toVerify.checkValidity();
return true;
} catch (GeneralSecurityException verifyFailed) {
return false;
}
}
개인정보, 인증 정보 등이 영속적인 쿠키(Persistent Cookie)에 저장되도록 만료 시간을 길게 설정하여 공격자에게 노출됩니다.
/* 취약한 코드 (Java) */
Cookie loginCookie = new Cookie("rememberme", "YES");
// 쿠키의 만료시간을 1년으로 과도하게 길게 설정하고 있어 안전하지 않다.
loginCookie.setMaxAge(60*60*24*365);
response.addCookie(loginCookie);
/* 안전한 코드 (Java) */
Cookie loginCookie = new Cookie("rememberme", "YES");
// 쿠키의 만료시간은 해당 기능에 맞춰 최소로 사용한다. (예: 24시간)
loginCookie.setMaxAge(60*60*24);
response.addCookie(loginCookie);
개발자가 편의를 위해 ID, 패스워드 등 보안 관련 내용을 주석문에 포함하여 소스코드 유출 시 함께 노출됩니다.
/* 취약한 코드 (Java) */
// 주석문으로 DB연결 ID, 패스워드의 중요한 정보를 노출시켜 안전하지 않다.
// DB연결 root / a1q2w3r3f2!@
con = DriverManager.getConnection(URL, USER, PASS);
/* 안전한 코드 (Java) */
// ID, 패스워드등의 중요 정보는 주석에 포함해서는 안된다.
con = DriverManager.getConnection(URL, USER, PASS);
패스워드 저장 시 솔트(Salt) 없이 해시하여 레인보우 테이블 공격에 취약해집니다.
/* 취약한 코드 (Java) */
public String getPasswordHash(String password) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
// 해쉬에 솔트를 적용하지 않아 안전하지 않다.
md.update(password.getBytes());
byte byteData[] = md.digest();
// ...
}
/* 안전한 코드 (Java) */
public String getPasswordHash(String password, byte[] salt) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(password.getBytes());
// 해쉬 사용 시에는 원문을 찾을 수 없도록 솔트를 사용하여야 한다.
md.update(salt);
byte byteData[] = md.digest();
// ...
}
원격에서 소스코드나 실행파일을 다운로드받아 실행 시 무결성 검사를 하지 않아 악의적인 코드가 실행됩니다.
/* 취약한 코드 (Java) */
URL[] classURLs = new URL[] { new URL("file:subdir/") };
// 원격에서 파일을 다운로드한 뒤 로드하면서, 대상 파일에 대한 무결성 검사를 수행하지 않아 파일변조 등으로 인한 피해가 발생할 수 있다.
URLClassLoader loader = new URLClassLoader(classURLs);
Class loadedClass = Class.forName("LoadMe", true, loader);
/* 안전한 코드 (Java) */
// ... (파일을 다운로드 받은 후)
byte[] loadFile = FileManager.getBytes(jarFile);
// Public Key로 복호화 (전자 서명 확인)
loadFile = decrypt(loadFile, publicKey);
// 복호화된 파일을 생성한다.
FileManager.createFile(loadFile, jarFile);
URLClassLoader loader = new URLClassLoader(classURLs);
Class loadedClass = Class.forName("MyClass", true, loader);
인증 시도 횟수를 제한하지 않아 공격자가 무차별 대입 공격(Brute Force)을 통해 계정 권한을 획득할 수 있습니다.
/* 취약한 코드 (Java) */
private static final int FAIL = -1;
public void login() {
// ...
int result = FAIL;
try {
// 인증 실패에 대해 제한을 두지 않아 안전하지 않다.
while (result == FAIL) {
// ...
result = verifyUser(username, password);
}
} catch (/* ... */) { /* ... */ }
}
/* 안전한 코드 (Java) */
private static final int FAIL = -1;
private static final int MAX_ATTEMPTS = 5; // 최대 시도 횟수 설정
public void login() {
// ...
int result = FAIL;
int count = 0;
try {
// 인증 실패 및 시도 횟수에 제한을 두어 안전하다.
while (result == FAIL && count < MAX_ATTEMPTS) {
// ...
result = verifyUser(username, password);
count++;
}
} catch (/* ... */) { /* ... */ }
}