데이터베이스 시리즈에서 이미 스프레드시트보다 DBMS를 사용해야 하는 여러 가지 이유를 다뤘습니다.
이번에는 한 걸음 더 나아가, DBMS를 사용할 때 반드시 주의해야 할 점,
특히 데이터베이스 보안에 대해 이야기하고자 합니다.
요즘 대부분의 웹 서버는 필수적으로 데이터베이스를 사용하고 있습니다.
하지만, 이러한 데이터베이스를 노리는 악의적인 공격자들도 많아졌고,
SQL Injection 같은 악의적인 쿼리 삽입,
권한 없는 사용자의 불필요한 데이터 접근,
개발자의 코드 미숙 등으로 인한 보안 취약점이 실무 현장에서도 여전히 자주 발생하고 있습니다.
이번 글에서는
데이터베이스 보안의 대표적인 위험과 그에 대한 방어 기법을
공격/방어 예시와 함께 구체적으로 정리해보았습니다.
SQL Injection이란 응용 프로그램 보안 상의 허점을 의도적으로 이용해, 악의적인 SQL문을 실행되게 함으로써 데이터베이스를 비정상적으로 조작하는 코드 인젝션 공격 방법입니다.
핵심 원리:
- 사용자가 입력한 데이터가 그대로 쿼리에 들어가서
- 의도하지 않은 SQL 명령을 실행할 수 있게 만듭니다.
⚠️ 본 실습은 해킹 연습용으로 허가된 사이트(해킹 실습 플랫폼)에서만 진행했습니다.
실제 운영 중인 웹사이트에서는 절대로 따라 하시면 안 됩니다!🛑 비인가된 사이트에서의 해킹 시도는 불법이며, 법적 처벌을 받을 수 있습니다.
항상 윤리적 해커로서, 올바른 환경에서만 연습해 주세요.
자 제가 잠깐동안 해커가 되어서 여러분의 소중한 은행 계좌에 접근해 돈을 빼보겠습니다.
일단 로그인을 시도 해보지만, 아이디 비밀번호를 모르니 성공 할 리가 없죠.
이때 아이디에 '(따옴표)를 넣어서 한번 다시 시도 해봤습니다.
이렇게 했더니 로그인 처리에 관해 이상한 에러 메시지를 웹상에서 드러냈습니다.
딱 봐도 SQL 에러 코드인거 같은데 왜 이런 현상이 나왔을까요?
그 이유는 개발자가 관게형 데이터베이스를 활용해 아이디와 비밀번호를 받을때
SELECT * FROM accounts
WHERE id = '유저가 입력한 id' AND pw = '유저가 입력한 pw'
이렇게 받는데,
위에서 제가 로그인 할때 사용했던 aaa'를 넣게 된다면 어떻게 될까요?
내부에서 실행되는 쿼리
SELECT * FROM accounts
WHERE id = 'aaa' ' AND pw = '****'
이렇게 쿼리문이 실행되면서 Sysntax error를 터트리게 됩니다.
자 그럼 이 사이트는 SQL Injection이 가능한 사이트라고 판단이 됩니다.
(뒤에 이유 설명)
다시 로그인 페이지로 넘어와서 이번에는 이렇게 입력해보겠습니다.
SQL에서 '--'는 뒤에 문장을 주석 처리 하겠다는 의미입니다.
tuser이란 사람이 있으면 그 즉시 로그인을 진행하게 만들고 비밀번호를 확인 하는 쿼리문은 모두 주석처리가 됩니다.
SELECT * FROM accounts
WHERE id = 'tuser -- ' AND pw = '****'
성공
이번에는 다른 방법을 활용해 로그인을 해보겠습니다.
OR 1=1은 항상 참(True)입니다.
이렇게 되면 예를들어 이렇게 데이터베이스에 tuser이란 회원 정보가 들어있다고 가정하면 Select * 조건에 만족하는 모든 행을 가져올겁니다.
이후 대부분의 로그인 로직에서는 아래와 같이 회원을 반환하는 로직을 작성합니다.
if (rs.next()) {
// 로그인 성공 처리
}
즉, username이 tuser가 아니더라도,
WHERE 조건을 만족하는 첫 번째(맨 위) 회원의 정보로 로그인 처리가 됩니다.
Admin 계정 로그인 성공
자 이제 어드민 계정으로 1억 달러 정도 제 계좌로 보내겠습니다.
이제 저는 부자가 되었습니다. 그 동안 벨로그를 읽어주셔서 감사합니다.
이번에는 Union select 문법을 활용해서 다른 사이트에서 SQL Injection 공격을 진행 해보겠습니다.
해당사이트는 쿼리 파라미터에 작가의 id값을 넣어서 작가를 조회하는 방식으로 운영되는 방식인것을 미리 확인 했습니다.
그렇다면 작가 번호란에 Union Select문을 넣어서 SQL Injection 공격을 해보겠습니다.
짜잔 이렇게 해당 사이트의 어떤 유저의 아이디와 비밀번호를 훔치는데 성공 했습니다.
어떻게 된 일 일까요?
아마 해당 사이트는 작가 이름의 검색 쿼리를 이렇게 작성 했을겁니다.
SELECT * FROM 어쩌구
WHERE artis= ' '
이때 제가 url에 넣은 유니온 쿼리를 넣어서 다시 풀 쿼리로 보겠습니다.
SELECT * FROM 어쩌구
WHERE artist=-1 UNION
SELECT 1,uname,pass FROM users
WHERE uname = 'test'
해당 쿼리를 해석해보면
author_no = -1 조건이므로 원래 테이블에서는 결과가 없음(혹은 무의미한 결과)
UNION 키워드는 두 SELECT의 결과를 합쳐서 반환
SELECT 1, uname, pass FROM users WHERE uname = 'test'
1은 첫 번째 컬럼(숫자, 자리 맞추기용)
두 번째/세 번째 컬럼에 users 테이블의 uname(아이디), pass(비밀번호)
이때 select문 두개의 컬럼 두개가 같아야 합니다. 그래서 컬럼 갯수를 강제로 맞추는 노가다로 진행해야 합니다.
훔친 아이디와 비밀번호로 로그인 해보기
성공~
이제 공격법을 봤으니 그에 맞춰서 예방법을 알아보도록 하겠습니다.
먼저 결론부터 말하자면 ProparedStatement 방식으로 쿼리를 작성해야 합니다.
String sql = "SELECT * FROM users WHERE username='" + username + "' AND password='" + password + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
이런 방식으로 코드를 작성하면 위에서 다룬 예시처럼 SQL Injection 공격에 취약한 구조가 됩니다.
PreparedStatement는 쿼리문을 미리 컴파일하고, 실행 시 파라미터(값)를 따로 바인딩하는 방식입니다.
주로 아래와 같이 사용합니다.
String sql = "SELECT * FROM users WHERE username=? AND password=?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, username);
pstmt.setString(2, password);
ResultSet rs = pstmt.executeQuery();
이렇게 동적 쿼리문을 사용하면 쿼리문과 데이터(입력값)을 명확하게 분리해서 처리 합니다.
-- 저장 프로시저 정의 (MySQL 예시)
CREATE PROCEDURE get_user_by_name(IN username VARCHAR(50))
BEGIN
SELECT * FROM users WHERE username = username;
END;
// Java에서 호출 예시
CallableStatement cstmt = conn.prepareCall("{call get_user_by_name(?)}");
cstmt.setString(1, username);
ResultSet rs = cstmt.executeQuery();
CREATE VIEW user_public_view AS
SELECT id, name, email FROM users;
-- 일반 유저 계정에만 뷰 접근 권한 부여
GRANT SELECT ON user_public_view TO 'normal_user'@'%';
-- 원본 테이블(users)에는 권한 부여하지 않기
REVOKE ALL PRIVILEGES ON users FROM 'normal_user'@'%';
즉 테이블을 볼 수 있는 계정에 제한을 걸어주는 방법입니다. 계정 권한에 대한 내용은 아래에서 다시 다루도록 하겠습니다.
ORM이란 객체 지향 언어(예: Java)에서 데이터베이스의 데이터를 객체로 매핑해서 다루게 해주는 기술입니다.
대표적으로 Java에서는 JPA, Hibernate, MyBatis(Mapper) 등을 사용합니다.
ORM을 사용하면 직접 SQL 쿼리를 작성하지 않고,
메서드 호출이나 JPQL(Java Persistence Query Language) 등으로 데이터를 조회/저장할 수 있습니다.
// 예시: JPA Repository를 이용한 사용자 조회
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsernameAndPassword(String username, String password);
}
// 실제 서비스 코드에서
Optional<User> user = userRepository.findByUsernameAndPassword(username, password);
이처럼 JPA의 메서드 파라미터에 사용자 입력값을 전달하면,
JPA 내부에서 쿼리문과 값(파라미터)을 명확히 분리해서 데이터베이스에 전달합니다.
동적 쿼리문과 마찬가지로 실제 입력값이 쿼리에 직접 삽입 되는 것이 아니라, 내부적으로 동적쿼리처럼 쿼리와 파라미터가 분리되어서 처리됩니다.
데이터베이스의 계정과 권한 관리는 실무에서 가장 기본적이면서도 중요한 보안 요소입니다.
적절한 권한 분리와 관리 없이는, 내부자에 의한 정보 유출이나 실수, 외부 공격 시 대규모 피해로 이어질 수 있습니다.
각 DB 계정에는 업무상 꼭 필요한 최소한의 권한만을 부여해야 합니다.
예를 들어, 단순 조회 서비스라면 SELECT만, 데이터 입력 서비스라면 INSERT/UPDATE만, 운영 자동화 계정에는 DDL 권한을 부여하지 않는 것이 안전합니다.
DDL(테이블 구조 변경), DCL(권한 부여/회수) 권한은 반드시 관리자 계정에만 부여 해야 합니다.
-- 조회 전용 계정
GRANT SELECT ON mydb.* TO 'readonly_user'@'%';
-- 데이터 입력/수정만 가능한 계정
GRANT INSERT, UPDATE, DELETE ON mydb.orders TO 'order_manager'@'%';
-- 필요 없는 권한은 반드시 회수
REVOKE ALL PRIVILEGES ON mydb.* FROM 'readonly_user'@'%';
구분 | SQL 예시 | 설명 |
---|---|---|
조회 권한 부여 | GRANT SELECT ON mydb.* TO 'readonly_user'@'%'; | 전체 DB(mydb) 조회만 가능 |
데이터 입력/수정 | GRANT INSERT, UPDATE ON mydb.orders TO 'order_manager'@'%'; | 특정 테이블(orders)에 입력/수정만 가능 |
DDL 권한 부여 | GRANT CREATE, ALTER ON mydb.* TO 'admin_user'@'%'; | DB 구조 변경 권한(테이블 생성/수정 등) 부여 |
권한 회수 | REVOKE ALL PRIVILEGES ON mydb.* FROM 'readonly_user'@'%'; | 모든 권한 회수 |
특정 권한 회수 | REVOKE INSERT, UPDATE ON mydb.orders FROM 'order_manager'@'%'; | 테이블(orders) 입력/수정 권한만 회수 |
특정 뷰만 허용 | GRANT SELECT ON user_public_view TO 'normal_user'@'%'; | 뷰(user_public_view)에만 SELECT 허용 |
원본 테이블 차단 | REVOKE ALL PRIVILEGES ON users FROM 'normal_user'@'%'; | users 테이블 직접 접근은 차단 |
좋은 글 감사합니다