보안 핵심 정리

소프트웨어 보안은 결국 신뢰할 수 없는 환경에서 시스템을 안전하게 동작시키는 문제라고 생각하면 된다.

인터넷에 연결된 프로그램은 사용자가 보내는 데이터를 믿을 수 없다. 서버와 통신하는 클라이언트도 믿을 수 없고, 저장된 데이터가 항상 안전하다고 가정할 수도 없다.

그래서 보안은 특정 라이브러리 하나를 사용하는 것으로 끝나는 문제가 아니다.

입력값을 어떻게 처리하는지, 인증을 어떻게 하는지, 권한을 어떻게 확인하는지, 데이터를 어떻게 저장하고 전송하는지까지 시스템 전체를 봐야 한다.


보안에서 먼저 구분해야 하는 것

보안을 이야기할 때 자주 등장하는 개념이 CIA Triad다.

Confidentiality
기밀성-> 허가되지 않은 사람이 데이터를 볼 수 없어야 함

Integrity
무결성-> 데이터가 허가 없이 변경되지 않아야 함

Availability
가용성-> 필요한 사람이 시스템을 사용할 수 있어야 함

예를 들어 게임 서버라면

기밀성-> 다른 플레이어의 개인정보를 볼 수 없어야 함

무결성-> 클라이언트가 자신의 재화를 임의로 변경할 수 없어야 함

가용성-> 공격이 발생해도 서버가 정상적으로 서비스되어야 함

보안은 단순히 “해킹을 막는 것”보다 넓은 개념이다.


공격 표면

시스템이 외부와 상호작용하는 모든 지점은 공격 대상이 될 수 있다.

이런 지점을 Attack Surface라고 볼 수 있다.

기능이 많아질수록 일반적으로 공격 표면도 커진다.

그래서 보안을 적용할 때는 먼저

어디에서 외부 입력을 받는가?
어떤 데이터가 중요한가?
누가 접근할 수 있는가?
어떤 권한이 필요한가?
외부 시스템과 무엇을 주고받는가?

를 파악하는 것이 좋다.


인증과 인가

보안에서 가장 자주 헷갈리는 개념이 AuthenticationAuthorization이다.

Authentication

“당신이 누구인가?”를 확인하는 과정이다.

사용자 -> 로그인 정보 제출 -> 인증 -> 사용자 식별

Authorization

“당신이 이 작업을 할 권한이 있는가?”를 확인하는 과정이다.

사용자 -> 삭제 요청 -> 권한 확인 -> 허용 / 거부

둘은 별개의 문제다.

로그인에 성공했다고 해서 모든 작업을 수행할 수 있는 것은 아니다.


세션과 토큰

웹 서비스에서는 인증 상태를 유지하기 위해 세션이나 토큰을 사용할 수 있다.

세션 기반 방식에서는 서버가 로그인 상태를 관리하고 클라이언트는 세션을 식별할 수 있는 값을 전달한다.

Client -> Session ID -> Server -> Session 확인

토큰 기반 방식에서는 인증 정보를 담은 토큰을 클라이언트가 전달하고 서버가 이를 검증한다.

대표적으로 JWT가 있다.

Client -> Access Token -> Server -> Token 검증 -> Request 처리

중요한 것은 세션이냐 토큰이냐 자체가 보안을 결정하는 것은 아니라는 점이다.

토큰을 안전하게 저장하지 않거나, 만료와 폐기를 제대로 처리하지 않으면 문제가 생길 수 있다.


비밀번호 저장

비밀번호는 데이터베이스에 평문으로 저장하면 안 된다.

Bad

password -> Database

Good

password -> Password Hash -> Database

일반적인 비밀번호 저장에는 Argon2, bcrypt, scrypt 같은 비밀번호 해싱 알고리즘이 사용된다.

해싱은 암호화와 다르다.

암호화는 키를 이용해 데이터를 변환하고 다시 복호화할 수 있지만, 비밀번호 해싱은 원래 비밀번호를 복원하는 것을 목적으로 하지 않는다.

또한 단순한 SHA-256 같은 일반적인 해시 함수를 비밀번호 저장 용도로 사용하는 것도 적절하지 않다.

비밀번호 공격에 대응하기 위해 계산 비용을 의도적으로 높인 전용 password hashing 알고리즘을 사용해야 한다.


Salt

같은 비밀번호를 사용하는 사용자가 있다면 단순 해싱만 했을 경우 결과도 같아진다.

password -> hash -> 같은 결과

그래서 비밀번호마다 랜덤한 Salt를 추가한다.

password + random salt -> password hash

그러면 같은 비밀번호라도 서로 다른 해시 결과를 갖게 된다.

Salt는 비밀번호를 숨기는 비밀키가 아니다.

일반적으로 해시와 함께 저장해도 된다. 중요한 것은 사용자마다 충분히 랜덤한 salt를 사용하는 것이다.


암호화

암호화는 데이터를 읽을 수 없는 형태로 변환하고 적절한 키를 가진 주체만 원래 데이터로 복원할 수 있도록 하는 방식이다.

Plaintext -> Encryption + Key -> Ciphertext

복호화하면 다시 원래 데이터가 나온다.

Ciphertext -> Decryption + Key -> Plaintext

암호화는 크게 대칭키와 비대칭키로 나눌 수 있다.

대칭키 암호화

암호화와 복호화에 같은 키를 사용한다.

대표적으로 AES가 있다.

비대칭키 암호화

공개키와 개인키를 사용한다.

Public Key
Private Key

TLS, 전자서명, 키 교환 등의 영역에서 사용된다.

실제 시스템에서는 하나의 암호 방식만 사용하는 것이 아니라 각각의 장점을 조합한다.


HTTPS와 TLS

HTTP는 네트워크에서 데이터를 전달하지만 기본 HTTP 자체는 암호화된 통신을 제공하지 않는다.

HTTPS는 TLS를 사용해 통신을 보호한다.

Client == TLS == Server

TLS는 통신 과정에서 데이터를 암호화하고 서버의 신원을 검증하며 통신 데이터의 변조를 탐지할 수 있도록 한다.

로그인 정보나 결제 정보처럼 민감한 데이터뿐만 아니라 일반적인 웹 서비스에서도 HTTPS를 사용하는 것이 기본적인 보안 구성이다.


입력값 검증

외부에서 들어오는 데이터는 기본적으로 신뢰하지 않는 것이 좋다.

Client -> Input -> Validation -> Application

예를 들어 서버가

level = 10

을 기대한다고 해서 클라이언트가 항상 10을 보낼 것이라고 믿으면 안 된다.

level = -999
level = 999999
level = "hello"

같은 값이 들어올 수 있다는 것을 전제로 처리해야 한다.

입력값 검증은 단순히 사용자의 실수를 막는 기능이 아니다.

공격자가 의도적으로 비정상적인 데이터를 보내는 상황도 포함한다.


SQL Injection

대표적인 입력값 공격이 SQL Injection이다.

문자열을 직접 SQL 문장에 이어 붙이면 공격자가 SQL의 구조를 변경할 수 있다.

사용자 입력
   ->
SQL 문자열에 직접 삽입
   ->
의도하지 않은 SQL 실행

예를 들어

"SELECT * FROM users WHERE name = '" + username + "'"

같은 방식은 위험할 수 있다.

대신 Parameterized Query / Prepared Statement를 사용해 SQL 코드와 데이터를 분리하는 것이 일반적인 해결 방법이다.

SQL 구조 + 사용자 데이터 -> DB

사용자 입력을 단순히 문자열 치환으로 SQL에 집어넣지 않는 것이 핵심이다.


XSS

XSS(Cross-Site Scripting)는 공격자가 웹 페이지에서 악성 스크립트가 실행되도록 만드는 공격이다.

예를 들어 사용자가 입력한 문자열이 그대로 HTML에 출력되는 구조라면 문제가 발생할 수 있다.

User Input -> Server -> HTML -> Browser

웹 애플리케이션에서는 상황에 맞는 출력 인코딩과 입력 처리, Content Security Policy 등의 방어 방법을 사용한다.

중요한 것은 “입력값에 특정 문자열이 있으면 제거한다” 같은 단순한 필터 하나로 모든 XSS를 막으려고 하면 안 된다는 것이다.

데이터가 어떤 컨텍스트에서 출력되는지를 고려해야 한다.


CSRF

CSRF(Cross-Site Request Forgery)는 사용자가 로그인한 상태라는 점을 악용해서 의도하지 않은 요청을 보내게 만드는 공격이다.

예를 들어 사용자가 어떤 사이트에 로그인되어 있는 상태에서 공격자가 만든 페이지를 방문했다고 하자.

사용자 -> 악성 페이지 -> 사용자의 브라우저 -> 기존 사이트에 요청

방어 방법으로 CSRF Token, SameSite Cookie 등의 방법을 사용할 수 있다.


권한 검증

보안 문제에서 자주 발생하는 것이 권한 검증 누락이다.

예를 들어 서버가

GET /user/123

이라는 요청을 받았다고 하자.

사용자가 로그인되어 있다는 사실만 확인하고 데이터를 반환하면 문제가 될 수 있다.

인증됨 + 권한 확인 없음 = 다른 사용자의 데이터 접근 가능

서버는

현재 사용자 + 요청 대상 + 필요한 권한

을 함께 확인해야 한다.

클라이언트에서 버튼을 숨기는 것만으로 권한을 보호할 수는 없다.

클라이언트는 공격자가 수정할 수 있기 때문이다.

권한 검증은 신뢰할 수 있는 서버 측에서 수행해야 한다.


게임에서 특히 중요한 것

온라인 게임에서는 클라이언트를 완전히 신뢰하면 안 된다.

클라이언트가 다음과 같은 값을 보낸다고 하자.

damage = 100
gold = 999999
position = (10000, 10000)

서버가 이 값을 그대로 받아들이면 치팅이 가능해진다.

Client -> "내가 100의 데미지를 줬다" -> Server -> 그대로 적용

이 구조보다 서버가 게임 규칙을 검증하는 구조가 안전하다.

Client -> 공격 요청 -> Server -> 거리 / 쿨다운 / 스탯 / 상태 검증 -> Damage 계산 -> 게임 상태 변경

이런 구조를 Server Authoritative 방식이라고 한다.

클라이언트는 입력과 요청을 전달하고, 중요한 게임 상태와 판정은 서버가 결정한다.


최소 권한 원칙

프로그램이나 사용자가 필요한 것보다 더 많은 권한을 갖지 않도록 하는 원칙을 Principle of Least Privilege라고 한다.

예를 들어 게임 서버가 데이터베이스에서 읽기만 하면 되는 기능이라면 모든 테이블에 대한 수정 권한까지 줄 필요가 없다.

필요 권한 + 최소 범위

로 제한한다.

권한이 과도하게 주어져 있으면 계정이나 프로세스가 침해되었을 때 피해 범위도 커진다.


세션과 인증 정보 보호

인증 정보는 탈취되면 계정 자체가 공격자에게 넘어갈 수 있다.

따라서

  • HTTPS 사용
  • 안전한 쿠키 설정
  • 적절한 만료 시간
  • Refresh Token 관리
  • 세션 무효화
  • 인증 정보 로그 노출 방지

등을 고려해야 한다.

특히 로그에 다음과 같은 값을 그대로 남기는 것은 피해야 한다.

Authorization: Bearer <token>
password=...
session_id=...

로그는 운영에서 상당히 유용하지만 동시에 민감한 데이터가 쌓이기 쉬운 장소이기도 하다.


비밀정보 관리

API Key, Database Password, Private Key 같은 값은 소스 코드에 직접 넣지 않는 것이 좋다.

const char* password = "my-secret-password";

이런 코드는 Git에 올라가는 순간 문제가 될 수 있다.

대신 환경 변수나 Secret Manager 같은 별도의 비밀정보 관리 시스템을 사용한다.

Application -> Secret Manager -> Secret

그리고 비밀정보가 Git에 커밋된 경우 단순히 파일을 삭제하는 것만으로 충분하지 않을 수 있다.

Git의 과거 커밋에 이미 값이 남아 있기 때문이다.

그 경우 해당 비밀정보를 즉시 폐기하고 새로운 값으로 교체하는 것이 우선이다.


로깅과 모니터링

보안은 공격을 막는 것만으로 끝나지 않는다.

공격이 발생했을 때 알아낼 수 있어야 한다.

Request -> Log -> Monitoring -> Alert -> Investigation

예를 들어 짧은 시간 동안 동일한 계정으로 로그인 실패가 수천 번 발생한다면 이상 징후로 볼 수 있다.

로그에는 다음과 같은 정보가 유용할 수 있다.

  • 요청 시간
  • 요청 경로
  • 사용자 식별자
  • 결과
  • 오류 코드
  • IP 등의 요청 정보

다만 비밀번호나 인증 토큰 같은 민감한 데이터를 무분별하게 기록해서는 안 된다.


Rate Limiting

API가 외부에 공개되어 있다면 요청 횟수를 제한할 필요가 있다.

Client -> Request -> Rate Limit -> Allow / Reject

예를 들어 로그인 API에 초당 수천 번의 요청이 들어오는 것을 그대로 처리하게 두면 무차별 대입 공격이나 리소스 고갈 공격에 악용될 수 있다.

Rate Limiting은 특정 API의 요청 수를 제한해서 시스템의 자원과 기능을 보호한다.

로그인, 인증 코드 발송, 비밀번호 재설정 같은 기능에서 특히 중요하다.


DoS와 DDoS

서비스가 정상적으로 동작하지 못하도록 과도한 요청이나 리소스 사용을 유발하는 공격을 DoS(Denial of Service)라고 한다.

여러 시스템에서 동시에 공격하는 형태가 DDoS다.

서버 입장에서는 정상 사용자와 공격 트래픽을 구분하고, 불필요한 요청을 최대한 빨리 차단해야 한다.

Rate Limiting, 방화벽, 로드 밸런서, CDN, DDoS 방어 서비스 등 여러 계층에서 대응한다.


보안은 한 번의 방어로 끝나지 않는다

실제 시스템에서는 하나의 방어책에 의존하지 않는다.

예를 들어 로그인 시스템이라면

HTTPS  -> Input Validation  -> Authentication  -> Authorization  -> Rate Limiting  -> Logging  -> Monitoring

처럼 여러 방어 계층을 구성할 수 있다.

이런 접근을 Defense in Depth라고 한다.

하나의 방어가 뚫렸을 때 다음 방어 계층이 피해를 제한할 수 있도록 만드는 것이다.


보안을 보는 기본 관점

보안 문제를 만났을 때 특정 공격 이름부터 외우는 것보다 다음 질문을 먼저 던지는 편이 좋다.

무엇을 보호해야 하는가?
누구를 신뢰할 것인가?
어디에서 외부 입력이 들어오는가?
누가 이 데이터에 접근할 수 있는가?
권한 검증은 어디에서 하는가?
민감한 데이터는 어떻게 저장되는가?
네트워크에서는 어떻게 보호되는가?
공격이 발생하면 어떻게 발견하는가?
침해가 발생했을 때 피해 범위를 어떻게 줄이는가?

이 질문들이 결국 보안 설계의 대부분을 관통한다.

보안은 특정 해킹 기법을 막는 기술 하나가 아니다.

신뢰하지 말아야 할 것을 신뢰하지 않고, 필요한 권한만 주고, 입력을 검증하고, 중요한 데이터를 보호하고, 문제가 발생했을 때 피해를 제한하고 추적할 수 있도록 시스템을 만드는 것에 가깝다.

특히 개발자 입장에서는 “보안 기능을 추가한다”는 생각보다 처음부터 시스템의 설계 조건으로 보안을 포함시키는 것이 훨씬 중요하다.