1. 지옥은 '점 하나'에서 시작되었다
개발 초기, 제가 만든 로그인 페이지는 완벽해 보였습니다. 아이디와 비밀번호를 받아서 DB에서 확인하는 아주 간단한 로직이었죠.
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`;
어느 날 출근해 보니, 관리자 계정으로 로그인한 로그가 수백 개 찍혀 있었습니다. 비밀번호는 전혀 유출되지 않았는데 말이죠. 로그를 확인한 저는 경악했습니다. 누군가 아이디 란에 이런 걸 입력했습니다.
admin' --
이게 들어가자 완성된 쿼리는 이렇게 변했습니다.
SELECT * FROM users
WHERE username = 'admin' --' AND password = '...'
-- 뒤로는 모조리 주석 처리(무시)되어 버렸습니다.
비밀번호 검사 로직이 통째로 사라진 겁니다.
고작 작은따옴표(') 하나 때문에, 제 보안은 휴지 조각이 되었습니다.
2. SQL Injection의 원리 - 데이터가 코드가 될 때
이 공격이 가능한 이유는 "데이터(사용자 입력)를 코드(SQL 명령어)로 오해했기 때문"입니다.
컴퓨터는 멍청해서, 저 'admin'이 이름인지 SQL 문법의 끝인지 구분하지 못합니다.
쉽게 설명하면 "빈칸 채우기 게임(Mad LIbs)"과 같습니다.
- 원래 문장: "나는 [ ]를 먹었다."
- 사용자 입력: "사과"
- 결과: "나는 사과를 먹었다." (정상)
하지만 악의적인 사용자는 이렇게 말합니다.
- 사용자 입력: "사과를 먹었다. 그리고 너를 죽일 것이다"
- 결과: "나는 사과를 먹었다. 그리고 너를 죽일 것이다를 먹었다." (???)
문장의 구조 자체가 바뀌어 버렸습니다. 이것이 SQL Injection의 본질입니다.
2.5. 보이지 않는 위험: Blind SQL Injection
공격자가 꼭 데이터를 화면에서 봐야만 해킹할 수 있는 건 아닙니다. 만약 로그인 실패 시 "아이디가 존재하지 않습니다"와 "비밀번호가 틀렸습니다" 메시지가 다르다면? 공격자는 이를 이용해 스무고개 하듯이 데이터를 알아낼 수 있습니다.
id=admin AND 1=1-> "비밀번호 틀림" (참)id=admin AND 1=2-> "아이디 없음" (거짓)
이 반응 차이(0.1초의 시간 차이 포함)만으로도 DB의 모든 내용을 빼낼 수 있습니다. 그래서 에러 메시지는 뭉뚱그려서("로그인 정보가 올바르지 않습니다") 보여줘야 합니다.
3. 유일한 해결책: Prepared Statement
이 문제를 막는 방법은 입력값을 검사하는 게 아닙니다. (블랙리스트 방식은 100% 뚫립니다). 유일하고 가장 확실한 해결책은 Prepared Statement를 쓰는 것입니다.
이건 "빈칸 채우기"가 아니라 "변수 바인딩"입니다.
// 1. 쿼리의 구조를 먼저 보냄 (미리 컴파일)
const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
// 2. 나중에 데이터를 '값'으로만 전달
db.execute(query, [username, password]);
이렇게 하면 DB는 username 자리에 무엇이 들어오든 무조건 단순 문자열로 취급합니다.
아까처럼 admin' --를 넣으면 어떻게 될까요?
SELECT * FROM users
WHERE username = 'admin\' --' AND password = '...'
DB는 "사용자 이름이 admin' -- 인 사람"을 찾습니다. 당연히 그런 사용자는 없으니 로그인은 실패합니다.
공격 코드가 평범한 텍스트로 무력화된 것입니다.
4. WAF(웹 방화벽)의 역할과 한계
Web Application Firewall (WAF)는 SQL Injection 같은 공격 패턴을 탐지해서 차단해 줍니다. 하지만 WAF는 보조 수단일 뿐입니다.
- 공격자는 WAF 우회(Bypass) 기술을 끊임없이 개발합니다. (예: 인코딩 변경, 긴 요청으로 버퍼 오버플로우 유발 등)
- WAF가 뚫리면 애플리케이션은 무방비 상태가 됩니다.
그래서 "WAF가 있으니까 코드 대충 짜도 돼"는 위험한 생각입니다. 최후의 보루는 언제나 코드(Prepared Statement)여야 합니다.
5. ORM을 쓰면 만사형통?
"저는 ORM(TypeORM, Prisma, JPA) 쓰니까 괜찮죠?" 네, 대부분은 괜찮습니다.
ORM은 내부적으로 99% Prepared Statement를 사용합니다.
// Prisma 예시 (안전함)
const user = await prisma.user.findFirst({
where: {
username: input // 자동으로 이스케이프 처리됨
}
});
하지만 방심은 금물입니다. ORM을 쓰더라도 Raw Query(직접 SQL 작성) 기능을 쓸 때는 여전히 위험합니다.
// ❌ 위험한 코드 (JPA 예시)
em.createNativeQuery("SELECT * FROM users WHERE name = '" + name + "'");
// ✅ 안전한 코드
em.createNativeQuery("SELECT * FROM users WHERE name = :name")
.setParameter("name", name);
개발자가 편하려고 문자열을 + 로 합치는 순간, ORM의 보호막은 사라집니다.
5. 마무리 - 개발자의 게으름이 보안 구멍을 만든다
SQL Injection은 20년도 더 된 공격 기법이지만, 여전히 OWASP Top 10의 상위권을 차지합니다. 이유는 단순합니다. 개발자가 귀찮아서 문자열을 그냥 합치기 때문입니다.
보안은 대단한 기술이 아닙니다. "사용자가 입력한 모든 값은 더럽다"고 가정하는 태도. 그리고 귀찮더라도 원칙(Prepared Statement)을 지키는 끈기. 그것이 여러분의 소중한 데이터를 지킵니다.
지금 당장 여러분의 코드에서 ${variable} 처럼 변수가 쿼리에 직접 박혀있는 곳을 찾으세요.
그곳이 바로 해커가 들어올 대문입니다.
My Database Was Wiped Out Because of a Single Quote (SQL Injection)
1. Hell Started with a Single Quote
As a junior developer, my login page looked perfect. It simply took an ID and password and checked them against the database.
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`;
One morning, I found hundreds of login logs as the administrator. But the admin password hadn't been leaked. I checked the logs and was horrified. Someone had entered this in the ID field:
admin' --
Once this was inserted, the query transformed into:
SELECT * FROM users
WHERE username = 'admin' --' AND password = '...'
Everything after -- was treated as a comment (ignored).
The password check logic completely vanished.
Because of a single single-quote ('), my security became useless.
2. Principle of SQL Injection: When Data Becomes Code
This attack works because "Data (User Input) is mistaken for Code (SQL Commands)."
Computers are dumb; they can't tell if 'admin' is a name or the end of SQL syntax.
It's like playing "Mad Libs".
- Original Sentence: "I ate a [ ]."
- User Input: "apple"
- Result: "I ate a apple." (Normal)
But a malicious user says:
- User Input: "apple. And I will kill you"
- Result: "I ate a apple. And I will kill you." (???)
The structure of the sentence itself changed. This is the essence of SQL Injection.
2.5. Silent Killer: Blind SQL Injection
Attackers don't always need to see the data to steal it. If your login error messages distinguish between "User not found" and "Wrong password", you are vulnerable. Attackers can play "Twenty Questions" with your database.
id=admin AND 1=1-> "Wrong password" (True)id=admin AND 1=2-> "User not found" (False)
Even the Time Difference (Time-based Blind SQLi) can leak data. Always genericize error messages ("Invalid credentials") to prevent this.
3. The Only Solution: Prepared Statements
Checking input values (Regex, Blacklists) is not the solution. (They can always be bypassed). The only and most certain solution is using Prepared Statements.
This isn't "Filling in blanks," it's "Variable Binding."
// 1. Send query structure first (Pre-compile)
const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
// 2. Send data only as 'values' later
db.execute(query, [username, password]);
Now, the DB treats whatever is in username as strictly a string.
If we input admin' -- like before?
SELECT * FROM users
WHERE username = 'admin\' --' AND password = '...'
The DB looks for "A user whose name is literally admin' --". Of course, no such user exists, so login fails.
The attack code is neutralized into plain text.
4. The Role and Limits of WAF
Web Application Firewall (WAF) monitors network traffic and blocks SQL Injection patterns. However, WAF is just First Aid, not a Cure.
- Attackers constantly find ways to bypass WAF (e.g., encoding, fragmentation).
- If WAF is breached, your application is naked.
Thinking "I have AWS WAF, so I can write bad code" is a recipe for disaster. Your last line of defense must always be the Code (Prepared Statements).
5. Advanced: Second Order SQL Injection
Standard SQLi happens immediately (Reflected). Second Order is a time-bomb.
- Attacker stores malicious input: User registers with username
admin' --. - Application accepts it: It's just a string, so it saves to DB.
- Trigger: Later, an admin views the user list.
- Exploit: The application reads the username and uses it in another query without sanitization.
SELECT * FROM logs WHERE user = 'admin' --'.
Lesson: Data from the Database is also untrusted input. Always assume everything is tainted.
6. Real World Horror Story: TalkTalk Hack (2015)
The British telecom giant TalkTalk was hacked by a 17-year-old using a simple SQL Injection. They lost £77 million and 100,000 customer records.
The Cause: A legacy web page aimed at customers was left unpatched. It used an outdated database driver that didn't support prepared statements properly, and input fields were not sanitized. The attacker used an automated tool (SQLMap) to identify the vulnerability and dump the database.
Takeaway: Legacy code is often where security dies. If you have "that old PHP page" no one touches, shut it down or fix it today.
7. The Defense in Depth: WAF
Even if your code is perfect, 0-day vulnerabilities exist in libraries you use. You need a shield: Web Application Firewall (WAF).
- AWS WAF / Cloudflare WAF: They inspect incoming traffic patterns.
- They can block requests containing suspicious SQL patterns like
' OR 1=1. - It's your first line of defense before the request even hits your server.
Pro Tip: Enable Cloudflare's "OWASP Core Ruleset" today. It handles 90% of common attacks.
8. Are ORMs Safe?
"I use an ORM (TypeORM, Prisma, JPA), so I'm safe, right?" Yes, mostly.
ORMs use Prepared Statements internally 99% of the time.
// Prisma Example (Safe)
const user = await prisma.user.findFirst({
where: {
username: input // Automatically escaped
}
});
But don't let your guard down. Even with ORMs, if you use Raw Queries, you are still at risk.
// ❌ Dangerous Code (JPA Example)
em.createNativeQuery("SELECT * FROM users WHERE name = '" + name + "'");
// ✅ Safe Code
em.createNativeQuery("SELECT * FROM users WHERE name = :name")
.setParameter("name", name);
The moment a developer gets lazy and concatenates strings with +, the ORM's shield vanishes.
5. Conclusion: Developer Laziness Creates Security Holes
SQL Injection is an attack technique over 20 years old, yet it still ranks high in OWASP Top 10. The reason is simple. Developers are too lazy and just concatenate strings.
Security isn't about fancy tech. It's the attitude of assuming "All user input is dirty." And the persistence to follow principles (Prepared Statements) even when it's annoying. That is what protects your precious data.
Go search your code right now for places where variables like ${variable} are directly embedded in queries.
That is the open gate for hackers.
9. Recommended Reading
- OWASP Top 10 - Injection: The official documentation on injection flaws.
- "Web Application Hacker's Handbook": The bible of web security.
- SQLMap: An open-source penetration testing tool that automates the process of detecting and exploiting SQL injection flaws. (Use it on your own code only!).