Platform
웹 애플리케이션 보안과 클라우드 운영 방어선
웹 취약점 실습에서 확인한 공격 흐름을 Docker, 인증, 네트워크, 클라우드 책임 공유 모델의 운영 방어선으로 연결해 정리합니다.
웹 보안은 취약점 이름을 외우는 방식으로는 오래 남지 않는다. SQL Injection, 파일 업로드, JWT 서명 검증 누락, 디렉터리 트래버설 같은 항목은 각각 독립된 공격처럼 보이지만, 운영 환경에서 보면 대부분 같은 질문으로 돌아온다. 사용자가 보낸 입력은 어디까지 신뢰할 수 있는가. 애플리케이션은 실패 상황에서 어떤 정보를 외부에 보여주는가. 컨테이너와 클라우드 인프라는 공격자가 얻은 작은 틈을 얼마나 멀리 확장하게 두는가.
DVWA, PortSwigger Web Security Academy, bWAPP 같은 실습 환경을 Docker로 띄워보면 이 연결이 더 분명해진다. 취약한 애플리케이션 하나를 컨테이너 안에서 실행하는 것은 단순한 해킹 실습이 아니라, 웹 요청이 애플리케이션 런타임, 파일 시스템, 네트워크 포트, 인증 토큰, 데이터베이스 권한으로 이어지는 경로를 관찰하는 작업에 가깝다.
Docker로 격리된 실습 환경을 먼저 만든 이유
보안 실습은 의도적으로 취약한 서비스를 실행한다. 그래서 로컬 PC나 공용 네트워크에 무심코 노출하면 실습 자체가 위험해진다. 컨테이너를 사용하면 취약한 애플리케이션을 독립된 네트워크와 파일 시스템 안에서 띄우고, 필요한 포트만 제한적으로 열 수 있다.
docker network create security-lab
docker run -d \
--name dvwa \
--network security-lab \
-p 127.0.0.1:8888:80 \
vulnerables/web-dvwa
docker run -d \
--name bwapp \
--network security-lab \
-p 127.0.0.1:8080:80 \
mupersei/bwapp여기서 중요한 부분은 127.0.0.1:8888:80처럼 바인딩 주소를 명시하는 것이다. -p 8888:80으로 실행하면 호스트의 모든 인터페이스에서 접근 가능해질 수 있다. 개인 노트북에서는 차이가 작아 보이지만, 클라우드 VM에서 같은 명령을 실행하면 NSG, 보안 그룹, 방화벽 설정에 따라 인터넷에 취약한 서비스가 그대로 열릴 수 있다.
실습용 VM을 클라우드에 만들 때도 같은 원칙이 적용된다. SSH는 내 IP로 제한하고, HTTP 포트는 반드시 필요한 경우에만 열어둔다. 보안 실습용 이미지는 재사용하지 않고, 실습이 끝나면 컨테이너와 VM을 지우는 편이 낫다.
docker ps
docker logs dvwa --tail 50
docker inspect dvwa --format '{{json .NetworkSettings.Networks}}'
docker stop dvwa bwapp
docker rm dvwa bwapp
docker network rm security-lab컨테이너는 격리의 시작점이지 완전한 보안 경계가 아니다. Docker 데몬 소켓이 노출되어 있거나 컨테이너가 root 권한으로 실행되고, 호스트 디렉터리가 과도하게 마운트되어 있으면 웹 애플리케이션 취약점이 호스트 침해로 이어질 수 있다. 그래서 실습에서조차 non-root 실행, 불필요한 capability 제거, 읽기 전용 파일 시스템 같은 설정을 습관처럼 적용해보는 것이 좋다.
services:
app:
image: my-web-lab:0.1.0
ports:
- "127.0.0.1:8080:8080"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp정보 노출은 작은 실수가 아니라 공격의 출발점이다
정보 노출 취약점은 겉으로 보기에는 치명적이지 않아 보일 때가 많다. 예외 메시지에 프레임워크 버전이 보이거나, /robots.txt에 민감한 경로가 적혀 있거나, 백업 파일 하나가 웹 루트에 남아 있는 정도로 보일 수 있다. 하지만 공격자는 이 작은 정보를 조합해서 다음 행동을 결정한다.
PortSwigger 실습에서 제품 ID 파라미터에 예상하지 못한 문자열을 넣으면 서버 오류가 발생하고, 오류 메시지에 프레임워크 버전이 노출된다. 또 /cgi-bin/phpinfo.php 같은 디버그 페이지가 남아 있으면 환경 변수, 확장 모듈, 서버 경로, 때로는 secret 값까지 확인할 수 있다. /backup 디렉터리에 .bak 파일이 남아 있고 디렉터리 인덱싱이 켜져 있다면 소스 코드와 DB 접속 정보가 유출될 수 있다.
이 문제의 방어는 “오류를 숨긴다” 정도로 끝나지 않는다. 애플리케이션, 웹 서버, 배포 파이프라인, 비밀값 관리가 같이 맞물려야 한다.
server_tokens off;
location ~ /\.(git|svn|hg) {
deny all;
return 404;
}
location ~* \.(bak|old|backup|swp|sql)$ {
deny all;
return 404;
}애플리케이션에서는 사용자에게 일반화된 오류만 보여주고, 세부 내용은 구조화된 로그로 남긴다. 로그에는 요청 ID를 포함해 추적 가능성을 남기되, 토큰과 비밀번호 같은 값은 마스킹해야 한다.
app.use((err, req, res, next) => {
req.log.error(
{
requestId: req.id,
path: req.path,
method: req.method,
errorName: err.name,
},
"request failed",
);
res.status(500).json({
message: "Internal server error",
requestId: req.id,
});
});클라우드에서는 secret을 이미지나 저장소에 넣지 않고 Secret Manager, Key Vault, Parameter Store 같은 관리형 서비스로 분리한다. Git 이력에 한 번 들어간 secret은 파일에서 지웠다고 안전해지지 않는다. 토큰을 폐기하고 새로 발급해야 하며, 가능하면 secret scanning과 pre-commit hook을 통해 유입 자체를 줄인다.
인증은 로그인 화면보다 넓은 문제다
무차별 대입 공격을 실습하면 로그인 실패 횟수 제한이 없는 시스템이 얼마나 쉽게 무너지는지 확인할 수 있다. DVWA의 낮은 보안 단계에서는 자동화 스크립트나 Burp Suite Intruder로 비밀번호 목록을 반복 대입할 수 있다. 중간 단계에서는 실패 후 대기 시간이 생기지만, 사전 공격은 여전히 가능하다. 높은 단계에서는 CSRF 토큰과 랜덤 대기 시간이 추가되어 공격 비용이 올라간다.
운영 관점에서는 이 방어선을 여러 층으로 나눠야 한다. 애플리케이션은 계정별 실패 횟수와 IP별 요청률을 모두 봐야 한다. API Gateway나 WAF는 비정상적인 요청 패턴을 먼저 줄이고, 인증 서비스는 계정 잠금, MFA, 세션 폐기 정책을 관리한다.
const key = `login:${ip}:${email}`;
const attempts = await redis.incr(key);
await redis.expire(key, 60);
if (attempts > 10) {
throw new TooManyRequestsException("Too many login attempts");
}JWT도 비슷하다. JWT는 서버 세션 저장소를 줄이고 서비스 간 인증 정보를 전달하기 좋지만, 서명 검증이 빠지면 토큰은 단순한 문자열 조작 대상이 된다. 실습에서는 payload의 sub 값을 관리자 계정으로 바꾸거나 header의 alg를 none으로 조작해 권한 상승을 시도한다. 운영 코드에서는 허용할 알고리즘을 명시하고, issuer, audience, expiration을 모두 검증해야 한다.
import jwt from "jsonwebtoken";
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});쿠키 기반 세션을 쓴다면 HttpOnly, Secure, SameSite 설정이 기본값이어야 한다. 세션 ID는 재로그인, 권한 변경, MFA 완료 같은 이벤트에서 회전해야 하고, 로그아웃 시 서버 측 세션도 폐기해야 한다. 클라이언트에 저장된 토큰 하나만 지우는 방식은 여러 디바이스와 탈취 시나리오에서 부족하다.
Set-Cookie: session=opaque-session-id; HttpOnly; Secure; SameSite=Lax; Path=/입력값은 검증하고, 쿼리는 조립하지 않는다
SQL Injection은 오래된 취약점이지만 여전히 중요하다. 원리는 단순하다. 사용자 입력이 SQL 문자열 안에 그대로 붙으면, 입력값이 데이터가 아니라 쿼리 구조가 된다.
SELECT *
FROM users
WHERE user_id = '$id';여기에 ' OR '1'='1 같은 값이 들어가면 조건은 항상 참이 될 수 있다. UNION을 이용하면 다른 테이블의 컬럼을 조회하려고 시도할 수도 있다. 방어의 핵심은 Prepared Statement다. 입력값을 문자열로 이어 붙이지 않고, 쿼리 구조와 값을 분리한다.
const user = await db.query(
"SELECT id, email, name FROM users WHERE email = $1",
[email],
);입력 검증은 SQL Injection만을 위한 장치가 아니다. 페이지 번호, 정렬 기준, 파일명, redirect URL, callback URL처럼 “단순 문자열”로 보이는 값도 시스템 내부의 다른 리소스를 가리킬 수 있다. 정렬 기준은 허용 목록으로 매핑하고, 파일명은 실제 저장 경로와 분리된 식별자로 관리하는 편이 안전하다.
const sortColumns = {
createdAt: "created_at",
name: "name",
} as const;
const sortColumn = sortColumns[query.sort as keyof typeof sortColumns] ?? "created_at";SQL만 조심한다고 입력값 문제가 끝나지는 않는다. 운영 도구나 내부 관리 기능에서 ping, nslookup, convert, ffmpeg, tar 같은 OS 명령을 실행하는 경우가 있다. 이때 사용자 입력이 shell command 문자열에 그대로 붙으면 Command Injection이 된다.
// avoid
exec(`ping -c 1 ${host}`);공격자는 host 자리에 example.com; cat /etc/passwd 같은 값을 넣어 원래 의도와 다른 명령을 실행하려고 시도할 수 있다. 방어의 기본은 shell을 거치지 않고 인자를 배열로 분리하는 것이다.
import { spawn } from "node:child_process";
function pingHost(host: string) {
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
throw new Error("invalid host");
}
return spawn("ping", ["-c", "1", host], {
shell: false,
timeout: 3000,
});
}더 좋은 방법은 애플리케이션 서버에서 OS 명령 실행 자체를 줄이는 것이다. 이미지 리사이즈, 파일 변환, 백업 압축 같은 작업은 별도 worker나 제한된 sandbox에서 실행하고, 실행 계정의 권한을 최소화한다. 컨테이너라면 read-only filesystem, dropped capabilities, CPU/memory limit, seccomp profile을 함께 본다.
services:
worker:
image: image-worker:0.1.0
read_only: true
cap_drop:
- ALL
pids_limit: 128
mem_limit: 512m
security_opt:
- no-new-privileges:trueCommand Injection은 웹 입력 검증 문제처럼 보이지만, 실제 피해 범위는 런타임 권한과 인프라 권한이 결정한다. 애플리케이션 프로세스가 클라우드 관리자 권한을 가진 credential에 접근할 수 있다면 작은 입력 취약점이 계정 침해로 커질 수 있다. 그래서 secret 접근 권한, instance role, container role, 파일 시스템 권한을 함께 줄여야 한다.
파일 경로와 업로드는 별도의 신뢰 경계로 본다
디렉터리 트래버설은 사용자 입력이 파일 경로에 섞일 때 발생한다. 이미지 파일명을 받는 파라미터에 ../../../etc/passwd 같은 값을 넣어 서버 파일을 읽으려는 방식이다. 일부 필터가 ../만 제거하면 중첩 문자열이나 이중 URL 인코딩으로 우회될 수 있다. 그래서 블랙리스트 필터보다 중요한 것은 사용자가 경로를 직접 결정하지 못하게 하는 구조다.
import path from "node:path";
const baseDir = path.resolve("/srv/app/uploads");
const requested = path.resolve(baseDir, filename);
if (!requested.startsWith(baseDir + path.sep)) {
throw new BadRequestException("invalid file path");
}파일 업로드는 더 조심해야 한다. 확장자만 검사하면 shell.php.jpg 같은 이름을 놓칠 수 있고, MIME 타입은 클라이언트가 조작할 수 있다. 파일 시그니처 검사, 확장자 허용 목록, 저장소 분리, 실행 권한 제거, 업로드 파일의 공개 URL 분리까지 함께 봐야 한다.
location /uploads/ {
types {
image/jpeg jpg jpeg;
image/png png;
image/webp webp;
}
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
}가능하면 업로드 파일은 애플리케이션 서버의 웹 루트가 아니라 오브젝트 스토리지에 저장한다. S3나 Azure Blob Storage를 사용할 때도 public bucket/container로 열어두기보다, 애플리케이션이 검증한 뒤 제한된 시간의 signed URL을 발급하는 구조가 더 안전하다.
클라우드 책임 공유 모델은 체크리스트가 아니라 경계선이다
클라우드 보안을 정리할 때 가장 먼저 확인해야 할 것은 책임 공유 모델이다. IaaS에서는 OS 패치, 네트워크 보안, 애플리케이션 런타임, 계정 권한을 사용자가 직접 챙겨야 한다. PaaS에서는 운영체제 일부가 제공자 책임으로 넘어가지만, 인증, 데이터, 애플리케이션 설정, 네트워크 노출은 여전히 사용자 책임이다. SaaS는 제공자가 더 많은 영역을 담당하지만, 계정 보안과 데이터 접근 정책은 남는다.
이 경계선을 모르면 관리형 서비스를 쓰면서도 잘못된 기대를 하게 된다. 예를 들어 Azure App Service를 사용하면 VM 패치를 직접 하지 않아도 되지만, 환경 변수에 들어간 secret 관리, 인증 설정, outbound 연결, private endpoint 구성, 로그 접근 권한은 여전히 설계 대상이다. AKS/EKS를 사용하면 control plane 일부는 관리형이지만, 노드 이미지, RBAC, NetworkPolicy, workload identity, ingress TLS는 별도로 다뤄야 한다.
보안 그룹이나 NSG는 “포트를 열어주는 설정”이 아니라 네트워크 신뢰 경계를 표현하는 장치다. 웹 서버는 인터넷에서 443만 받아야 하고, 애플리케이션 서버는 로드밸런서나 프라이빗 서브넷에서만 접근 가능해야 한다. 데이터베이스는 애플리케이션 서브넷에서만 접근하고, 관리 접속은 Bastion이나 VPN을 거치게 한다.
Secret과 권한은 배포 파이프라인에서 먼저 새어 나간다
정보 노출 취약점을 실습하면 오류 메시지, 백업 파일, .git 디렉터리, phpinfo() 같은 디버그 페이지에서 민감한 값이 얼마나 쉽게 드러나는지 확인하게 된다. 운영 환경에서는 이 문제가 단순히 “파일을 지우자”로 끝나지 않는다. secret이 코드, 이미지, 로그, CI/CD 변수, 환경 변수, 오브젝트 스토리지, 백업 파일 중 어디에 남을 수 있는지 경로를 따라가야 한다.
가장 피해야 할 구조는 애플리케이션 코드나 이미지 안에 credential이 들어가는 것이다. 한 번 Git history나 container image layer에 들어간 secret은 단순히 현재 파일에서 지워도 남아 있을 수 있다. secret은 저장소가 아니라 secret manager에 두고, 애플리케이션은 런타임 identity로 필요한 값만 읽는 구조가 낫다.
Do not
- commit .env files
- bake DB passwords into Docker images
- print tokens in startup logs
- store long-lived cloud keys in CI scripts
Prefer
- GitHub secret scanning
- masked CI/CD variables
- Azure Key Vault or AWS Secrets Manager
- workload identity / managed identity
- short-lived credentials and rotation권한도 같은 방식으로 봐야 한다. 애플리케이션이 DB에 접근한다고 해서 DDL 권한까지 필요하지는 않다. 파일 업로드 서비스가 Blob Storage를 사용한다고 해서 전체 storage account 관리자 권한이 필요한 것도 아니다. 읽기, 쓰기, 삭제, 목록 조회, 키 관리 권한을 분리해야 장애와 침해 범위를 줄일 수 있다.
grant select, insert, update on payment_requests to app_user;
grant select, insert on payment_status_history to app_user;
revoke drop any table from app_user;클라우드에서도 동일하다. 배포 파이프라인은 인프라 변경 권한을 가질 수 있지만, 런타임 애플리케이션은 자기 데이터 경로에 필요한 권한만 가져야 한다. GitHub Actions, Vercel, Azure, AWS를 연결할 때도 장기 access key를 저장하는 방식보다 OIDC 기반 federation이나 managed identity를 우선 검토하는 편이 좋다.
관찰 가능성이 없으면 방어선도 검증하기 어렵다
보안 설정은 배포 시점에 한 번 맞춰두고 끝나는 일이 아니다. 로그인 실패가 급증하는지, 특정 API에 비정상적인 4xx가 몰리는지, 업로드 실패가 반복되는지, JWT 검증 오류가 갑자기 늘어나는지 관찰해야 한다. 이런 신호가 없으면 방어선이 실제로 작동하는지 알 수 없다.
애플리케이션 로그에는 최소한 요청 ID, 사용자 식별자, 클라이언트 IP, 경로, 상태 코드, 처리 시간, 실패 유형이 구조화되어 남아야 한다. 단, 비밀번호, Authorization header, cookie, 개인식별정보는 로그에 남기지 않는다.
{
"level": "warn",
"event": "login_failed",
"requestId": "req_01HX...",
"ip": "203.0.113.10",
"userId": null,
"reason": "invalid_password",
"path": "/auth/login"
}Prometheus와 Grafana를 쓴다면 보안 이벤트도 일반 운영 지표와 함께 본다. 인증 실패율, 429 응답 수, WAF 차단 수, 5xx 오류율, DB connection error는 장애와 보안을 동시에 설명할 수 있는 지표다. OpenTelemetry를 붙이면 특정 요청이 edge, application, database를 지나며 어디서 실패했는지 추적하기 쉬워진다.
운영 알림은 너무 넓게 잡으면 금방 무시된다. 그래서 “보안 이벤트”를 모두 같은 심각도로 다루기보다, 사용자가 실수할 수 있는 이벤트와 즉시 확인해야 하는 이벤트를 나눠야 한다.
Notice
- single validation failure
- user typo on login
- expected 404 for missing resource
Warning
- repeated login failures for one account
- JWT validation failures from same IP range
- upload blocked by file signature mismatch
Critical
- secret-looking value printed in logs
- public access enabled on private storage
- admin endpoint hit from unknown network
- sudden spike in 403 / 429 / 5xx after deployment이 기준이 있으면 장애 대응과 보안 대응이 분리되지 않는다. 예를 들어 배포 직후 403이 급증하면 인증 정책이나 RBAC 변경이 원인일 수 있고, 특정 IP에서 429가 반복되면 brute force나 scraping 시도를 의심할 수 있다. 로그, 메트릭, 트레이스, 감사 이벤트가 같은 request ID나 user ID로 이어져야 원인을 좁힐 수 있다.
정리
웹 취약점 실습은 공격 기법을 익히기 위한 목적도 있지만, 더 중요한 것은 시스템 경계선을 보는 눈을 만드는 데 있다. 정보 노출은 secret 관리와 배포 파이프라인의 문제로 이어지고, 인증 취약점은 rate limit, MFA, 세션 정책, 토큰 검증으로 연결된다. SQL Injection과 파일 업로드 취약점은 입력값 검증, 저장소 분리, 실행 권한 제거, 최소 권한 DB 계정으로 이어진다. Docker 실습 환경은 컨테이너 격리, 네트워크 바인딩, non-root 실행, 이미지 스캔 같은 운영 습관으로 확장된다.
클라우드에서는 이 모든 것이 책임 공유 모델 위에 놓인다. 제공자가 물리 인프라와 관리형 서비스의 일부를 맡아주더라도, 애플리케이션의 입력값, 인증 흐름, 데이터 접근 권한, 네트워크 노출, secret 전달 방식, 로그와 알림은 여전히 설계해야 한다. 보안은 특정 도구 하나를 추가하는 일이 아니라, 요청이 들어와 데이터에 닿고 로그로 남기까지의 경로를 끊임없이 좁히고 검증하는 일에 가깝다.
이 글의 무단 복제, 재배포, 자동 수집을 허용하지 않습니다. 인용이 필요한 경우 출처와 원문 링크를 함께 남겨주세요.