
OWASP Top 10 (2025): 웹 보안 위협 총정리
인젝션, 인증 취약점, XSS부터 최신 위협까지 — OWASP Top 10을 실제 코드 예시와 함께 하나씩 뜯어보고, 각 취약점을 어떻게 막는지 정리했다.

인젝션, 인증 취약점, XSS부터 최신 위협까지 — OWASP Top 10을 실제 코드 예시와 함께 하나씩 뜯어보고, 각 취약점을 어떻게 막는지 정리했다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

비트코인은 블록체인의 일부입니다. 이중 지불 문제(Double Spending), 작업 증명(PoW)과 지분 증명(PoS)의 차이, 스마트 컨트랙트, 그리고 Web 3.0이 가져올 미래까지. 개발자 관점에서 본 블록체인의 모든 것.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

"기능 먼저, 보안은 나중에." 개발하다 보면 자연스럽게 이렇게 생각하게 된다. 근데 보안 취약점이 발견되는 타이밍은 대부분 두 가지 중 하나다: 코드 리뷰 때, 아니면 실제로 뚫렸을 때.
OWASP(Open Worldwide Application Security Project)는 웹 애플리케이션에서 가장 자주 발생하는 취약점 10가지를 정기적으로 발표한다. 이걸 알면 "어디를 조심해야 하는가"에 대한 체크리스트가 생긴다.
2025년 기준으로 최신 OWASP Top 10을 하나씩 코드와 함께 살펴보자.
2021년부터 1위. 제일 흔하고, 제일 치명적이다.
// 취약한 예시 - 사용자 ID를 URL 파라미터로 받아 그대로 쿼리
app.get("/api/users/:id/profile", async (req, res) => {
const { id } = req.params;
// 로그인한 사용자가 자기 자신의 프로필인지 확인 안 함!
const user = await db.users.findById(id);
res.json(user);
});
// 공격자가 이렇게 날리면 다른 사람 프로필을 볼 수 있음
// GET /api/users/9999/profile
이게 IDOR(Insecure Direct Object Reference) 취약점이다.
// 취약한 예시 2 - 관리자 체크 미흡
app.delete("/api/posts/:id", async (req, res) => {
const post = await db.posts.findById(req.params.id);
// role 체크가 없다!
await post.delete();
res.json({ success: true });
});
// 수정: 항상 요청자의 권한과 소유권 확인
app.get("/api/users/:id/profile", authenticate, async (req, res) => {
const requestedId = req.params.id;
const currentUserId = req.user.id;
// 본인 프로필이거나 관리자만 접근 가능
if (requestedId !== currentUserId && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
const user = await db.users.findById(requestedId);
res.json(user);
});
// 수정: 권한 미들웨어 분리
const requireRole = (role: string) => (req: Request, res: Response, next: NextFunction) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
app.delete("/api/posts/:id", authenticate, async (req, res) => {
const post = await db.posts.findById(req.params.id);
// 본인 글이거나 관리자만 삭제 가능
if (post.authorId !== req.user.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
await post.delete();
res.json({ success: true });
});
핵심 원칙: 서버에서는 항상 "이 사람이 이 자원에 접근할 권한이 있는가?"를 확인해라.
예전엔 "민감한 데이터 노출"이라 불렸다. 암호화가 없거나, 약하거나, 잘못 구현된 경우.
// 1. 비밀번호를 평문으로 저장
await db.users.create({
email: "user@example.com",
password: "mypassword123", // 절대 안 됨
});
// 2. MD5/SHA1 같은 약한 해시 사용
import crypto from "crypto";
const hash = crypto.createHash("md5").update(password).digest("hex");
// 3. HTTP로 민감한 데이터 전송
fetch("http://api.example.com/payment", { // HTTPS 아님!
method: "POST",
body: JSON.stringify({ cardNumber: "1234-5678-9012-3456" }),
});
// 4. 민감한 데이터를 로그에 출력
console.log(`사용자 로그인: ${user.email}, 비밀번호: ${password}`);
import bcrypt from "bcrypt";
// 비밀번호 저장
const SALT_ROUNDS = 12;
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
await db.users.create({ email, password: hashedPassword });
// 비밀번호 검증
const isValid = await bcrypt.compare(inputPassword, storedHash);
// 민감한 필드는 응답에서 제거
const user = await db.users.findById(id);
const { password: _, ...safeUser } = user;
res.json(safeUser);
// 환경변수로 비밀 관리 (절대 코드에 하드코딩 금지)
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) throw new Error("JWT_SECRET not set");
// 데이터베이스 컬럼 레벨 암호화 (개인정보)
const encryptedPhone = encrypt(user.phone, process.env.ENCRYPTION_KEY!);
await db.users.update({ encryptedPhone });
function encrypt(text: string, key: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(key, "hex"), iv);
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
}
SQL 인젝션이 대표적이지만, OS 커맨드 인젝션, LDAP 인젝션, NoSQL 인젝션도 포함된다.
// 취약한 코드 - 문자열 직접 연결
const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
db.query(query);
// 공격: email = "admin' --"
// 생성되는 쿼리: SELECT * FROM users WHERE email = 'admin' --' AND password = '...'
// 비밀번호 체크가 주석 처리되어 우회!
// 안전한 코드 - Prepared Statements
const result = await db.query(
"SELECT * FROM users WHERE email = $1 AND password = $2",
[email, password] // 파라미터 바인딩
);
// ORM 사용 (기본적으로 안전)
const user = await User.findOne({
where: { email, password },
});
// Prisma
const user = await prisma.user.findFirst({
where: { email },
});
// MongoDB 취약한 예시
app.post("/login", async (req, res) => {
const { email, password } = req.body;
// 공격자가 { "$ne": null }을 보내면 모든 사용자 매칭
const user = await User.findOne({ email, password });
});
// 요청: { "email": "admin@example.com", "password": { "$ne": null } }
// → 비밀번호 없이 로그인 가능!
// 안전한 코드 - 타입 검증
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(100),
// 문자열만 허용하므로 객체/연산자 인젝션 불가
});
app.post("/login", async (req, res) => {
const { email, password } = loginSchema.parse(req.body);
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: "Invalid credentials" });
}
});
코드 레벨 버그가 아니라, 설계 단계에서 보안을 고려하지 않은 경우.
취약한 설계:
1. 이메일 입력
2. 이메일로 "임시 비밀번호 123456" 전송
→ 임시 비밀번호가 예측 가능하거나 재사용됨
→ 이메일이 탈취되면 계정도 탈취
// 안전한 설계: 토큰 기반 재설정
import crypto from "crypto";
// 1. 재설정 요청
app.post("/forgot-password", async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user) {
// 이메일 존재 여부를 노출하지 않음 (user enumeration 방지)
return res.json({ message: "If this email exists, a reset link was sent." });
}
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 1000 * 60 * 15); // 15분
await PasswordResetToken.create({ userId: user.id, token, expiresAt });
await sendEmail(email, `Reset: https://app.com/reset?token=${token}`);
res.json({ message: "If this email exists, a reset link was sent." });
});
// 2. 재설정 실행
app.post("/reset-password", async (req, res) => {
const { token, newPassword } = req.body;
const resetToken = await PasswordResetToken.findOne({
token,
expiresAt: { $gt: new Date() }, // 만료 체크
used: false, // 재사용 방지
});
if (!resetToken) {
return res.status(400).json({ error: "Invalid or expired token" });
}
await User.update(resetToken.userId, {
password: await bcrypt.hash(newPassword, 12),
});
// 토큰 사용 처리 (재사용 불가)
await resetToken.update({ used: true });
res.json({ message: "Password updated" });
});
기본 설정 그대로 두거나, 불필요한 기능을 켜두거나, 에러 메시지가 너무 상세한 경우.
// 취약한 설정들
// 1. 상세한 에러 메시지
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({
error: err.message,
stack: err.stack, // 스택 트레이스 노출 금지!
query: req.query, // 요청 정보 노출 금지!
});
});
// 2. 기본 포트/경로에 관리자 페이지
app.get("/admin", (req, res) => {
// 인증 없는 관리자 페이지
});
// 3. CORS 와일드카드
app.use(cors({ origin: "*" })); // 모든 출처 허용
// 안전한 설정들
// 1. 환경별 에러 처리
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
const isDev = process.env.NODE_ENV === "development";
console.error(err); // 서버 로그에만 기록
res.status(500).json({
error: isDev ? err.message : "Internal Server Error",
// 프로덕션에서 스택 트레이스 숨김
});
});
// 2. CORS 화이트리스트
const allowedOrigins = ["https://myapp.com", "https://app.myapp.com"];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
}));
// 3. Helmet으로 보안 헤더 설정
import helmet from "helmet";
app.use(helmet());
// X-Frame-Options, X-Content-Type-Options, HSTS 등 자동 설정
# 취약점 확인
npm audit
# 출력 예시
found 3 vulnerabilities (1 moderate, 2 high)
- lodash < 4.17.21 : Prototype Pollution (high)
- axios < 0.21.2 : SSRF vulnerability (high)
# 수정
npm audit fix
npm update axios
# GitHub Dependabot 설정 (.github/dependabot.yml)
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
# 보안 패치 자동 PR 생성
open-pull-requests-limit: 10
// 1. 브루트 포스 보호 없음
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (user && user.password === password) { // 평문 비교도 문제
res.json({ token: generateToken(user) });
}
});
// 2. 약한 JWT 설정
const token = jwt.sign({ userId: user.id }, "secret"); // 약한 시크릿
// 만료 시간 없음!
// 안전한 인증
import rateLimit from "express-rate-limit";
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 5회 실패 후 차단
message: "Too many login attempts, please try again later",
});
app.post("/login", loginLimiter, async (req, res) => {
const { email, password } = loginSchema.parse(req.body);
const user = await User.findOne({ email });
// 이메일 존재 여부를 분리하지 않음 (타이밍 공격 방지)
const isValid = user && await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: "15m", algorithm: "HS256" }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: "7d" }
);
// Refresh Token을 httpOnly 쿠키로 (XSS 방지)
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
CI/CD 파이프라인 보안, 패키지 무결성 검증 실패.
# package-lock.json 사용 (의존성 고정)
npm ci # npm install 대신 npm ci 사용
# Subresource Integrity (SRI) - CDN 스크립트 무결성 검증
<script
src="https://cdn.jsdelivr.net/npm/react@19.0.0/umd/react.production.min.js"
integrity="sha384-abc123..."
crossorigin="anonymous"
></script>
# GitHub Actions - 공식 액션만 사용, 특정 SHA 고정
- uses: actions/checkout@v4 # 태그보다 SHA가 더 안전
# - uses: actions/checkout@abc123def456 # SHA 고정
// 취약한 로깅 - 아무것도 기록하지 않음
app.post("/login", async (req, res) => {
// 로그인 시도를 기록하지 않으면 브루트 포스를 감지 불가
});
// 안전한 로깅
import winston from "winston";
const securityLogger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "security.log" }),
],
});
app.post("/login", loginLimiter, async (req, res) => {
const { email } = req.body;
const ip = req.ip;
try {
const user = await authenticateUser(email, req.body.password);
// 성공 로그
securityLogger.info("login_success", {
userId: user.id,
email: user.email,
ip,
timestamp: new Date().toISOString(),
});
res.json({ token: generateToken(user) });
} catch (err) {
// 실패 로그 - 브루트 포스 탐지에 사용
securityLogger.warn("login_failed", {
email,
ip,
reason: "invalid_credentials",
timestamp: new Date().toISOString(),
});
res.status(401).json({ error: "Invalid credentials" });
}
});
// 알림 임계치
const FAILED_LOGINS_THRESHOLD = 10;
// 같은 IP에서 10회 실패 → Slack 알림, IP 차단 등
서버가 사용자가 제공한 URL로 요청을 보낼 때 발생한다.
// 취약한 코드
app.post("/api/fetch-url", async (req, res) => {
const { url } = req.body;
// 사용자가 내부 서비스 URL을 보내면?
// http://169.254.169.254/latest/meta-data/ (AWS 메타데이터!)
// http://localhost:6379 (Redis!)
const response = await fetch(url);
res.json(await response.json());
});
// 안전한 코드
import { URL } from "url";
const ALLOWED_HOSTS = ["api.trusted-service.com", "cdn.example.com"];
const BLOCKED_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./, // AWS 메타데이터
];
function isUrlSafe(urlString: string): boolean {
try {
const url = new URL(urlString);
// HTTPS만 허용
if (url.protocol !== "https:") return false;
// 화이트리스트 확인
if (!ALLOWED_HOSTS.includes(url.hostname)) return false;
// 내부 IP 차단 (DNS 리바인딩 공격 대비는 별도 필요)
if (BLOCKED_RANGES.some((re) => re.test(url.hostname))) return false;
return true;
} catch {
return false;
}
}
app.post("/api/fetch-url", async (req, res) => {
const { url } = req.body;
if (!isUrlSafe(url)) {
return res.status(400).json({ error: "URL not allowed" });
}
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
res.json(await response.json());
});
| # | 취약점 | 핵심 대응 |
|---|---|---|
| A01 | Broken Access Control | 서버에서 항상 권한 검증 |
| A02 | Cryptographic Failures | bcrypt, AES-256, HTTPS |
| A03 | Injection | Prepared statements, 입력 유효성 검사 |
| A04 | Insecure Design | 위협 모델링, 보안 설계 패턴 |
| A05 | Security Misconfiguration | 최소 권한, Helmet, 에러 숨김 |
| A06 | Vulnerable Components | npm audit, Dependabot |
| A07 | Auth Failures | Rate limiting, 강한 JWT, MFA |
| A08 | Integrity Failures | npm ci, SRI, CI/CD 보안 |
| A09 | Logging Failures | 보안 이벤트 로깅, 알림 설정 |
| A10 | SSRF | URL 화이트리스트, 내부 IP 차단 |
OWASP Top 10은 "이것만 막으면 된다"가 아니라 "이것만큼은 반드시 막아야 한다"는 최소 기준이다. 하나씩 코드 리뷰 체크리스트에 넣어라. 특히 A01(접근 제어), A03(인젝션), A07(인증)은 가장 빈번하고 치명적이다.
보안은 기능이 아니라 습관이다.