1. Lab 개요

이번 실습은 상품 카테고리 필터에서 발생하는 SQL Injection 취약점을 이용하여, 현재 Query가 조회하고 있는 상품 테이블이 아닌 다른 테이블의 데이터를 조회하는 문제이다.

 

앞선 실습에서는 UNION SELECT를 이용하여 다음 두 가지를 확인하였다.

  • 기존 Query가 반환하는 컬럼의 개수
  • 문자열 데이터를 출력할 수 있는 컬럼

 

이번 Lab에서는 이를 실제 데이터 조회에 활용한다.

 

The database contains a different table called users, with columns called username and password.
To solve the lab, perform a SQL injection UNION attack that retrieves all usernames and passwords, and use the information to log in as the administrator user.

데이터베이스에는 users 라는 이름의 다른 테이블이 있으며, 그 테이블에는 username 와 password 라는 이름의 열이 있습니다.
이 실습 과제를 해결하려면 SQL 인젝션 UNION 공격을 수행하여 모든 사용자 이름과 비밀번호를 추출하고, 해당 정보를 사용하여 해당 administrator 사용자(관리자)로 로그인하십시오.

 

Lab 설명을 통해 데이터베이스에 다음과 같은 테이블이 존재한다는 정보가 제공된다.

Table : users
 

 

또한 users 테이블에는 다음 두 개의 컬럼이 존재한다.

username
password
 

 

따라서 이번 실습의 목표는 UNION SELECT를 이용하여 users 테이블의 username, password 값을 조회하고, 그중 administrator 계정의 비밀번호를 확인하여 관리자 계정으로 로그인하는 것이다.


 

2. 실습 환경 및 취약한 파라미터 확인

Lab에 접속하면 상품 목록과 함께 여러 카테고리 필터를 확인할 수 있다.

 

특정 카테고리를 선택하면 다음과 같은 요청이 발생한다.

/filter?category=Accessories
 

 

여기서 SQL Injection 공격 지점은 다음 파라미터이다.

category
 

 

서버에서는 해당 값을 SQL Query에 사용하여 선택한 카테고리의 상품을 조회하는 것으로 볼 수 있다.

 

따라서 Burp Suite에서 해당 요청을 확인한 뒤 Repeater로 전송하여 category 파라미터를 변조한다.


 

3. SQL Injection 취약점 확인

먼저 category 파라미터에 작은따옴표(')를 추가하여 서버의 반응을 확인하였다.

Accessories'
 

해당 요청을 전송하자 서버에서 500 Internal Server Error가 발생하였다.

 

이는 입력값에 의해 SQL Query의 문법 구조가 깨졌을 가능성을 나타낸다.

 

이후 다음과 같이 정상적으로 참이 되는 조건과 주석을 추가하였다.

Accessories' OR 1=1--
 

 

해당 요청에서는 다시 정상적인 200 OK 응답이 반환되었다.

 

이를 통해 category 입력값이 SQL Query 구조에 영향을 줄 수 있으며, 해당 파라미터에서 SQL Injection이 가능함을 확인할 수 있다.


 

4. 기존 Query의 컬럼 수 확인

UNION SELECT를 이용해 다른 테이블의 데이터를 조회하려면 먼저 기존 Query와 동일한 개수의 컬럼을 반환해야 한다.

따라서 NULL 값을 이용하여 컬럼 수를 확인하였다.

 

먼저 하나의 컬럼으로 요청하였다.

' UNION SELECT NULL--

 

 

해당 요청에서는 오류가 발생하였다.

 

다음으로 두 개의 컬럼을 지정하였다.

' UNION SELECT NULL,NULL--
 

 

이번에는 정상적인 200 OK 응답이 반환되었다.

 

따라서 기존 SQL Query가 반환하는 컬럼의 개수는 다음과 같다.

2개
 

즉 이후 UNION SELECT 공격에서도 두 개의 값을 반환하도록 구성해야 한다.


 

5. 문자열 데이터 호환 여부 확인

이번 Lab에서 조회하려는 username과 password는 모두 문자열 데이터이다.

따라서 두 컬럼 모두 문자열 데이터를 반환할 수 있는지도 확인할 필요가 있다.

 

다음과 같이 두 컬럼에 임의의 문자열을 삽입하였다.

' UNION SELECT 'abc','def'--
 

 

요청을 전송한 결과 정상적인 응답이 반환되었다.

따라서 두 컬럼 모두 문자열 데이터와 호환되는 컬럼임을 확인할 수 있다.

 

이를 정리하면 다음과 같다.

1번째 컬럼 → 문자열 사용 가능
2번째 컬럼 → 문자열 사용 가능
 

따라서 users 테이블의 username과 password를 각각 두 컬럼에 대응시켜 조회할 수 있다.


 

6. users 테이블 데이터 조회

The database contains a different table called users, with columns called username and password.
To solve the lab, perform a SQL injection UNION attack that retrieves all usernames and passwords, and use the information to log in as the administrator user.

데이터베이스에는 users 라는 이름의 다른 테이블이 있으며, 그 테이블에는 username 와 password 라는 이름의 열이 있습니다.
이 실습 과제를 해결하려면 SQL 인젝션 UNION 공격을 수행하여 모든 사용자 이름과 비밀번호를 추출하고, 해당 정보를 사용하여 해당 administrator 사용자(관리자)로 로그인하십시오.

 

이번 Lab에서는 데이터베이스에 users 테이블이 존재하고, 해당 테이블에 다음 컬럼이 존재한다는 정보가 제공된다.

username
password
 

 

따라서 다음과 같은 Payload를 구성하였다.

' UNION SELECT username,password FROM users--

 

구문을 나누어 보면 다음과 같다.

UNION SELECT
 

 

기존 SQL Query의 결과에 새로운 Query 결과를 추가한다.

username,password
 

 

조회할 두 개의 컬럼을 지정한다.

FROM users
 

데이터를 가져올 users 테이블을 지정한다.

 

마지막으로,

--
 

이후에 이어지는 기존 SQL Query를 주석 처리한다.

 

따라서 전체 Payload는 다음과 같다.

' UNION SELECT username,password FROM users--

 

7. 사용자 계정 정보 확인

 

 

Payload를 전송하면 기존 상품 데이터와 함께 users 테이블의 데이터가 추가로 반환된다.

 

즉 원래 화면에는 상품명과 상품 설명 등이 출력되지만, UNION SELECT를 통해 추가된 행에는 다음과 같이 사용자 정보가 표시된다.

administrator    [password]
carlos           [password]
wiener           [password]
 

 

실제 계정명과 비밀번호 값은 Lab이 생성될 때마다 달라질 수 있다.

이번 실습에서는 이 중 다음 계정을 확인한다. (관리자 계정 정보이다.)

administrator
 

 

그리고 해당 행에 함께 출력된 비밀번호 값을 확인한다.

 

administrators
l5bfjn3kthu92sderd3u

 

이로써 SQL Injection을 통해 원래 상품 Query와 직접적인 관계가 없는 users 테이블의 계정 정보까지 조회할 수 있음을 확인하였다.


 

8. administrator 계정 로그인

확인한 관리자 계정 정보를 이용하여 My account 페이지에서 로그인을 시도한다.

 

Username에는 다음 값을 입력한다.

administrator
 

 

Password에는 앞선 SQL Injection을 통해 확인한 비밀번호를 입력한다.

Username : administrator
Password : l5bfjn3kthu92sderd3u
 

 

 

Log in 버튼을 선택하면 관리자 계정으로 로그인이 성공한다.


 

9. 결과 확인

로그인 이후 현재 사용자가 administrator로 표시되는 것을 확인할 수 있다.

또한 Lab 상태가 Solved로 변경되면서 실습이 정상적으로 완료되었다.

 

이번 실습에서는 단순히 SQL Query의 조건을 조작하는 수준을 넘어,

' UNION SELECT username,password FROM users--
 

구문을 이용하여 다른 테이블에 저장된 사용자 계정 정보까지 조회할 수 있었다.


10. 핵심 정리

이번 실습의 핵심은 UNION 기반 SQL Injection을 이용하여 현재 Query가 조회하는 테이블이 아닌 다른 테이블의 데이터를 가져오는 것이다.

 

먼저 기존 Query의 컬럼 수를 확인하였다.

' UNION SELECT NULL--
500 Internal Server Error
 

 

다음으로 두 개의 컬럼을 사용하였다.

' UNION SELECT NULL,NULL--
200 OK
 

 

따라서 기존 Query가 반환하는 컬럼은 2개임을 확인하였다.

 

또한 다음 Payload를 이용하여 두 컬럼 모두 문자열을 처리할 수 있음을 확인하였다.

' UNION SELECT 'abc','def'--
 

 

이후 Lab에서 제공한 테이블 및 컬럼 정보를 이용하여 다음 Payload를 구성하였다.

' UNION SELECT username,password FROM users--
 

 

그 결과 users 테이블에 저장된 사용자 이름과 비밀번호가 응답에 포함되었으며, 그중 administrator 계정의 비밀번호를 확인할 수 있었다.

 

최종적으로 해당 계정 정보를 이용하여 로그인함으로써 Lab을 해결하였다.

 

전체 흐름을 정리하면 다음과 같다.

category 파라미터 확인
        ↓
SQL Injection 취약점 확인
        ↓
UNION SELECT 컬럼 수 확인
        ↓
2개 컬럼 확인
        ↓
두 컬럼의 문자열 호환 여부 확인
        ↓
users 테이블의 username/password 조회
        ↓
administrator 비밀번호 확인
        ↓
관리자 계정 로그인
        ↓
Solved

 

 

 

1. Lab 개요

이번 실습은 상품 카테고리 필터에서 발생하는 SQL Injection 취약점을 이용하여, 기존 SQL Query가 반환하는 컬럼 중 문자열(String) 데이터를 저장할 수 있는 컬럼을 확인하는 문제이다.

 

이전 실습에서는 UNION SELECT 구문에 NULL 값을 하나씩 추가하여 기존 Query가 총 3개의 컬럼을 반환한다는 것을 확인하였다.

' UNION SELECT NULL,NULL,NULL--
 

 

하지만 실제로 다른 테이블의 문자열 데이터를 조회하려면 단순히 컬럼 개수만 알아서는 부족하다.

UNION으로 결합되는 각 컬럼은 기존 Query의 컬럼과 호환되는 데이터 타입을 가져야 하기 때문이다.

 

따라서 이번 실습의 목표는 3개의 컬럼 중 하나씩 문자열 값을 삽입하여, 어떤 컬럼이 문자열 데이터를 처리할 수 있는지 확인하는 것이다.

Lab에서는 임의의 문자열 값을 제공하며, 최종적으로 해당 문자열이 Query 결과에 출력되도록 하면 실습이 완료된다.


 

2. 실습 환경 및 취약한 파라미터 확인

Lab에 접속하면 여러 상품과 함께 카테고리 필터를 확인할 수 있다.

 

특정 카테고리를 선택하면 다음과 같은 형태의 요청이 발생한다.

/filter?category=Gifts
 

여기서 SQL Injection 공격 지점은 다음 파라미터이다.

category
 

 

이전 실습과 동일하게 서버에서는 해당 값(category)을 SQL Query의 조건으로 사용하는 것으로 볼 수 있다.

SELECT ...
FROM products
WHERE category = 'Gifts'
 

 

따라서 이번 실습에서도 category 파라미터를 변조하여 UNION 기반 SQL Injection을 수행한다.


 

3. 기존 Query의 컬럼 수 확인

먼저 이전 실습에서 사용했던 Payload를 이용하여 기존 Query의 컬럼 수를 다시 확인하였다.

' UNION SELECT NULL,NULL,NULL--
 

 

해당 요청을 전송했을 때 정상적인 응답이 반환되므로,

기존 Query가 반환하는 컬럼 수는 다음과 같다.

3개
 

 

따라서 이후 테스트에서도 반드시 UNION SELECT 뒤에 총 3개의 값을 지정해야 한다.

예를 들어 다음과 같은 형태가 된다.

UNION SELECT value1,value2,value3
 

 

이제 이 3개의 위치 중 어느 위치가 문자열 데이터를 받을 수 있는지 확인해야 한다.


 

4. 첫 번째 컬럼 문자열 호환 여부 확인

먼저 첫 번째 컬럼에 임의의 문자열을 넣고 나머지 컬럼에는 NULL을 사용하였다.

 
' UNION SELECT 'test',NULL,NULL--
 

 

이를 각 컬럼별로 보면 다음과 같다.

1번째 컬럼 : 'test'
2번째 컬럼 : NULL
3번째 컬럼 : NULL
 

 

요청을 전송한 결과 서버에서 500 Internal Server Error가 발생하였다.

이는 첫 번째 컬럼의 데이터 타입이 문자열과 호환되지 않는다는 것을 의미한다.

따라서 다음 컬럼으로 문자열 값을 이동하여 확인한다.


 

5. 두 번째 컬럼 문자열 호환 여부 확인

이번에는 첫 번째 값을 다시 NULL로 변경하고, 두 번째 컬럼에 문자열을 삽입하였다.

' UNION SELECT NULL,'test',NULL--
 

 

각 컬럼의 값은 다음과 같다.

1번째 컬럼 : NULL
2번째 컬럼 : 'test'
3번째 컬럼 : NULL
 

 

요청을 전송한 결과 이번에는 200 OK 응답이 반환되었다.

 

즉, 두 번째 컬럼은 문자열 타입과 호환되는 것을 확인할 수 있다.

결과적으로 다음과 같이 정리할 수 있다.

1번째 컬럼 → 문자열 사용 불가
2번째 컬럼 → 문자열 사용 가능
 

따라서 이후 문자열 데이터를 출력하려면 두 번째 컬럼을 사용하면 된다.


 

6. Lab에서 제공된 문자열 출력

이번 Lab에서는 특정 랜덤 문자열을 Query 결과에 출력하도록 요구한다.

make the database retrieve the string: 'jYFcnD'
“데이터베이스가 jYFcnD라는 문자열을 조회 결과에 포함해서 반환하도록 만들어라.”

 

예를 들어 Lab에서 다음과 같은 문자열이 제공되었다고 가정하면,

jYFcnD
 

 

앞서 문자열 사용이 가능하다고 확인한 두 번째 컬럼에 해당 값을 삽입한다.

 

 

Payload 구조를 보면 다음과 같다.

1번째 컬럼 : NULL
2번째 컬럼 : 'jYFcnD'
3번째 컬럼 : NULL
 

서버에서 Query가 정상적으로 실행되면 두 번째 컬럼을 통해 해당 문자열이 애플리케이션 응답에 포함된다.

※ 실제 문자열은 Lab을 실행할 때마다 제공되는 값을 사용하면 된다.


 

7. 결과 확인

 

Payload를 전송한 결과 Lab에서 제공한 문자열이 응답 화면에 표시되는 것을 확인하였다.

jYFcnD
 

이는 UNION SELECT의 두 번째 컬럼이 애플리케이션 화면에 출력되며, 동시에 문자열 데이터를 처리할 수 있는 컬럼이라는 것을 의미한다.

 

또한 Lab 상태가 Solved로 변경되면서 실습이 정상적으로 완료되었다.


8. 핵심 정리

이번 실습의 핵심은 UNION 기반 SQL Injection에서 문자열 데이터를 출력할 수 있는 컬럼을 찾는 것이다.

 

이전 실습을 통해 기존 Query가 3개의 컬럼을 반환한다는 것을 확인하였다.

' UNION SELECT NULL,NULL,NULL--
 

이후 각 컬럼에 문자열 값을 하나씩 삽입하면서 서버 응답을 확인하였다.

 

첫 번째 컬럼에 문자열을 삽입하면,

' UNION SELECT 'test',NULL,NULL--
500 Internal Server Error
 

 

오류가 발생하였다.

반면 두 번째 컬럼에 문자열을 삽입하면,

' UNION SELECT NULL,'test',NULL--
200 OK
 

정상적인 응답이 반환되었다.

 

따라서 두 번째 컬럼이 문자열 데이터와 호환되는 컬럼임을 확인할 수 있었다.

최종적으로 Lab에서 제공된 문자열을 두 번째 컬럼에 삽입하였다.

' UNION SELECT NULL,'jYFcnD',NULL--
 

 

그 결과 해당 문자열이 애플리케이션 응답에 출력되면서 Lab을 해결할 수 있었다.

이번 실습의 흐름을 간단히 정리하면 다음과 같다.

컬럼 수 확인
        ↓
3개 컬럼 확인
        ↓
'text', NULL, NULL
        ↓
500 Error
        ↓
NULL, 'text', NULL
        ↓
200 OK
        ↓
2번째 컬럼이 문자열 호환
        ↓
Lab에서 제공된 문자열 삽입
        ↓
Solved

 

 

 

1. Lab 개요

 

이번 실습은 상품 카테고리 필터에서 발생하는 SQL Injection 취약점을 이용하여, 원본 SQL Query가 반환하는 컬럼(Column)의 개수를 확인하는 문제이다.

 

SQL Injection에서 UNION SELECT 구문을 이용하면 기존 Query의 결과에 공격자가 작성한 Query의 결과를 추가할 수 있다.

 

하지만 UNION을 사용하기 위해서는 두 Query가 반환하는 컬럼의 개수가 동일해야 한다.

 

예를 들어 기존 Query가 3개의 컬럼을 반환한다면 다음과 같이 동일하게 3개의 값을 지정해야 한다.

UNION SELECT NULL, NULL, NULL
 

따라서 이번 실습의 목표는 UNION SELECT에 NULL 값을 하나씩 추가하면서 서버의 응답을 확인하고, 기존 Query가 몇 개의 컬럼을 반환하는지 파악하는 것이다.


2. 실습 환경 확인

Lab에 접속하면 여러 상품과 함께 상품을 분류할 수 있는 카테고리 필터를 확인할 수 있다.

 

 

특정 카테고리를 선택하면 해당 카테고리에 속하는 상품만 조회되는 구조이다.

 

 

예를 들어 Tech gifts 카테고리를 선택하면 URL에서 다음과 같은 요청을 확인할 수 있다.

/filter?category=Tech gifts
 

 

여기서 핵심이 되는 파라미터는 다음과 같다.

category
 

 

서버에서는 해당 값을 이용하여 개념적으로 다음과 같은 SQL Query를 실행하는 것으로 볼 수 있다.

SELECT ...
FROM products
WHERE category = 'Tech gifts'
 

 

따라서 이번 실습에서는 category 파라미터를 SQL Injection 공격 지점으로 사용한다.


 

3. SQL Injection 취약점 확인

본격적인 UNION 공격을 수행하기 전에, 먼저 해당 파라미터에서 SQL Injection이 발생하는지 확인하였다.

 

category 파라미터에 작은따옴표(')를 추가하여 요청을 전송하였다.

Tech gifts'
 

 

그 결과 정상적인 상품 페이지 대신 서버에서 500 Internal Server Error가 발생하였다.

이는 입력한 작은따옴표로 인해 서버 내부 SQL Query의 문법 구조가 깨졌을 가능성을 나타낸다.

 

추가적으로 정상적인 SQL 조건을 구성할 수 있는지 확인하기 위해 다음과 같은 형태의 입력값을 사용할 수 있다.

Tech gifts' AND 1=1--
 

1=1은 항상 참(TRUE)이 되는 조건이며, --는 이후 SQL 구문을 주석 처리한다.

정상적인 응답이 반환된다면 사용자 입력값이 SQL Query 구조에 영향을 주고 있음을 확인할 수 있다.

 

이번 Lab에서는 이를 통해 category 파라미터에서 SQL Injection이 가능한 것을 확인한 뒤, UNION SELECT 공격을 진행하였다.


4. UNION SELECT를 이용한 컬럼 수 확인

이번 실습의 핵심은 UNION SELECT를 이용하여 기존 Query가 반환하는 컬럼의 개수를 확인하는 것이다.

UNION을 사용하기 위해서는 기존 Query와 새로 추가하는 Query의 컬럼 수가 동일해야 한다.

 

먼저 하나의 NULL 값을 이용하여 다음과 같이 요청하였다.

' UNION SELECT NULL--

 

 

 

하지만 서버에서는 500 Internal Server Error가 발생하였다.

 

이는 기존 Query가 반환하는 컬럼의 개수가 1개가 아니라는 것을 의미한다.


 

 

다음으로 NULL 값을 하나 추가하여 두 개의 컬럼을 지정하였다.

' UNION SELECT NULL, NULL--
 

 

 

해당 요청 역시 서버에서 500 Internal Server Error가 발생하였다.

 

따라서 기존 Query가 반환하는 컬럼의 개수는 2개도 아닌 것을 확인할 수 있다.

 


 

 

마지막으로 NULL 값을 하나 더 추가하여 다음과 같이 요청하였다.

' UNION SELECT NULL, NULL, NULL--
 

 

이번에는 이전과 달리 오류가 발생하지 않고 정상적인 응답이 반환되었다.

 

즉,

NULL 1개 → 오류
NULL 2개 → 오류
NULL 3개 → 정상 응답
 

이라는 결과를 확인할 수 있었다.

 

따라서 기존 SQL Query가 반환하는 컬럼의 개수는 3개임을 알 수 있다.


 

5. NULL 값을 사용하는 이유

UNION SELECT 공격에서는 다음과 같이 숫자나 문자열을 사용할 수도 있다.

UNION SELECT 1, 2, 3
 

 

하지만 UNION을 사용하기 위해서는 단순히 컬럼의 개수뿐 아니라 각 컬럼의 데이터 타입도 서로 호환되어야 한다.

 

예를 들어 기존 Query의 첫 번째 컬럼이 문자열인데 공격자가 숫자 값을 넣는 경우, 데이터 타입 문제로 인해 Query가 실패할 수 있다.

 

이 때문에 컬럼 수를 확인하는 단계에서는 일반적으로 다음과 같이 NULL 값을 사용한다.

UNION SELECT NULL, NULL, NULL
 

NULL은 대부분의 데이터 타입과 호환될 수 있기 때문에, 실제 컬럼의 데이터 타입을 알지 못하는 상황에서도 비교적 안전하게 사용할 수 있다.

 

따라서 UNION 기반 SQL Injection에서는 컬럼 수를 탐색할 때 NULL 값을 하나씩 추가하는 방식이 자주 사용된다.


 

6. 결과 확인

다음 Payload를 전송한 결과,

' UNION SELECT NULL, NULL, NULL--
 

 

서버에서 정상적인 응답이 반환되었으며 Lab이 Solved 상태로 변경되었다.

 

이를 통해 기존 SQL Query가 반환하는 컬럼의 개수는 다음과 같다는 것을 확인하였다.

3개
 

 

즉, 이후 UNION SELECT 공격을 수행하려면 기본적으로 다음과 같이 3개의 컬럼을 구성해야 한다.

UNION SELECT value1, value2, value3
 

 


7. 핵심 정리

이번 실습의 핵심은 UNION 기반 SQL Injection을 수행하기 전에 기존 Query가 반환하는 컬럼의 개수를 파악하는 것이다.

UNION 연산을 사용하려면 기존 Query와 공격자가 삽입한 Query의 컬럼 개수가 동일해야 한다.

 

따라서 다음과 같이 NULL 값을 하나씩 증가시키며 서버의 응답을 확인하였다.

' UNION SELECT NULL--
500 Internal Server Error
 
' UNION SELECT NULL, NULL--
500 Internal Server Error
 
' UNION SELECT NULL, NULL, NULL--
정상 응답
 

 

결과적으로 NULL 값을 3개 사용했을 때 Query가 정상적으로 실행되었으므로, 기존 Query가 반환하는 컬럼은 총 3개임을 확인할 수 있었다.

 

이번 Lab에서 기억할 핵심은 다음과 같다.

  • UNION을 사용하려면 두 Query의 컬럼 개수가 동일해야 한다.
  • 컬럼 수를 확인할 때는 NULL 값을 하나씩 추가하면서 응답을 확인할 수 있다.
  • NULL은 대부분의 데이터 타입과 호환되기 때문에 컬럼 구조를 모르는 상황에서 사용하기 적합하다.
  • 오류가 사라지는 시점의 NULL 개수를 통해 기존 Query의 컬럼 수를 파악할 수 있다.

1. Lab 개요

 

이번 실습은 로그인 기능에서 발생하는 SQL Injection 취약점을 이용하여, 정상적인 비밀번호 인증 과정을 우회하고 administrator 계정으로 로그인하는 문제이다.

 

일반적인 로그인 기능에서는 사용자가 입력한 아이디와 비밀번호를 이용하여 서버에서 다음과 같은 SQL Query를 실행할 수 있다.

SELECT * FROM users
WHERE username = 'administrator'
AND password = 'password'
 

정상적인 경우에는 username과 password가 모두 일치해야 로그인이 성공한다.

 

하지만 이번 Lab에서는 로그인 요청의 username 값이 SQL Query에 안전하게 처리되지 않고 사용되기 때문에 SQL Injection이 발생할 수 있다.

 

따라서 이번 실습의 목표는 username 파라미터에 SQL Injection 구문을 삽입하여 비밀번호 검증 조건을 무력화하고 administrator 계정으로 로그인하는 것이다.

 


2. 실습 환경 확인

Lab에 접속하면 일반적인 쇼핑몰 형태의 메인 화면을 확인할 수 있다.

 

화면 우측 상단에는 다음과 같은 메뉴가 존재한다.

  • Home
  • My account

 

이번 실습은 상품 조회 기능이 아니라 로그인 기능에서 발생하는 SQL Injection 취약점을 다루기 때문에 My account 메뉴를 선택하여 로그인 페이지로 이동하였다.

 

로그인 페이지에서는 다음과 같이 두 개의 입력값을 확인할 수 있다.

  • Username
  • Password

사용자가 해당 값을 입력하고 Log in 버튼을 누르면, 입력한 계정 정보를 서버로 전송하여 인증을 수행하는 구조이다.

 

이번 문제에서는 이 로그인 과정에서 사용되는 username 파라미터가 SQL Query에 안전하게 처리되지 않고 사용되는 점이 핵심이다.

 

따라서 이후 Burp Suite를 이용하여 로그인 요청을 확인한 뒤, username 파라미터를 변조하여 SQL Injection을 수행하고 비밀번호 인증 우회를 시도한다.

 

3. 로그인 요청 및 취약한 파라미터 확인

로그인 페이지에서 임의의 Username과 Password를 입력한 뒤 Burp Suite를 이용해 로그인 요청을 확인하였다.

로그인 요청에는 다음과 같이 사용자가 입력한 계정 정보가 전달된다.

username=test
password=test

 

 

 

이번 실습에서는 이 중 다음 파라미터가 핵심이다.

username
 

 

일반적인 로그인 기능에서는 서버가 입력받은 Username과 Password를 이용하여 다음과 같은 SQL Query를 실행할 수 있다.

SELECT * FROM users
WHERE username = 'administrator'
AND password = 'password'
 

 

즉, 정상적인 경우에는 username과 password가 모두 일치해야 사용자 인증이 성공한다.

 

하지만 이번 Lab에서는 사용자 입력값이 Parameterized Query와 같은 안전한 방식으로 처리되지 않고 SQL Query에 직접 사용되는 취약점이 존재한다.

 

따라서 username 파라미터의 입력값을 조작하면 기존 SQL Query의 구조를 변경할 수 있으며, 이를 이용해 비밀번호 검증 조건을 우회할 수 있다.


4. 관리자 계정명 추정

SQL Injection을 통해 로그인 우회를 시도하기 전에, 우선 어떤 관리자 계정으로 로그인을 시도할지 확인할 필요가 있다.

 

임의의 Username과 Password를 입력하여 로그인을 시도하면 다음과 같은 메시지가 출력된다.

Invalid username or password.
 

이 메시지는 Username이 존재하지 않는 경우와 Password가 틀린 경우를 구분하지 않고 동일한 오류 메시지를 반환한다.

 

따라서 로그인 화면의 응답만을 이용해서 특정 Username의 존재 여부를 직접 확인하기는 어렵다.

 

예를 들어,

admin
administrator
root
 

와 같은 값을 하나씩 입력하더라도 결과가 모두 동일하게 나타날 수 있기 때문에, 이를 통해 실제 존재하는 관리자 계정을 식별할 수는 없다.

 

이번 Lab에서는 관리자 계정으로 일반적으로 많이 사용되는 계정명 중 하나인 다음 값을 사용하여 관리자 계정을 유추하였다.

administrator
 

 

PortSwigger Lab의 목표에서도 관리자 계정이 administrator로 제시되어 있으며, 이후 SQL Injection을 통해 해당 계정의 비밀번호 검증을 우회하는 방식으로 실습을 진행한다.


5. SQL Injection 수행

앞서 확인한 관리자 계정명 administrator를 대상으로 비밀번호 검증을 우회하기 위해, username 파라미터에 다음과 같은 값을 입력하였다.

administrator'--
 

Password 입력란에는 임의의 값을 입력하였다.

예를 들어 다음과 같이 입력할 수 있다.

Username : administrator'--
Password : test
 

Password 값을 입력하는 이유는 로그인 페이지에서 빈 입력값을 허용하지 않는 경우가 있기 때문이다.

하지만 이번 공격에서는 Password 조건 자체가 SQL 주석 처리되기 때문에 실제로 입력한 Password 값은 인증 과정에 영향을 주지 않는다.

 

입력한 Payload를 각각 살펴보면 다음과 같다.

administrator
 

로그인을 시도할 사용자 계정명이다.

이번 Lab에서는 목표 계정이 administrator로 주어져 있으므로 해당 값을 그대로 사용한다.

다음의 작은따옴표는 기존 SQL Query에서 사용되고 있던 문자열을 종료한다.

'
 

 

이후 다음 구문을 사용한다.

--
 

--는 SQL에서 이후 내용을 주석으로 처리하는 데 사용된다.

 

따라서 다음 Payload를 입력하면,

administrator'--
 

 

기존 SQL Query는 개념적으로 다음과 같이 변경된다.

SELECT * FROM users
WHERE username = 'administrator'--'
AND password = 'test'
 

 

-- 이후의 내용이 주석 처리되면서 다음 조건은 실행되지 않는다.

AND password = 'test'
 

 

결과적으로 실제 인증 조건은 사실상 다음과 같이 남게 된다.

SELECT * FROM users
WHERE username = 'administrator'
 

 

즉, administrator 계정의 존재 여부만 확인하게 되고 비밀번호 검증 과정은 수행되지 않는다.


6. 결과 확인

SQL Injection Payload를 적용한 상태에서 로그인 요청을 서버로 전송하였다.

그 결과 정상적인 비밀번호를 입력하지 않았음에도 administrator 계정으로 로그인이 성공하였다.

 

로그인 이후 화면에서는 현재 로그인된 사용자 정보가 다음과 같이 표시되는 것을 확인할 수 있다.

administrator
 

또한 Lab 상태가 Solved로 변경되면서 실습이 정상적으로 완료되었다.

 

즉,

AND password = '...'
 

조건이 --에 의해 주석 처리되면서 비밀번호 검증이 무력화되었고, 그 결과 비밀번호를 알지 못하는 상태에서도 관리자 계정으로 인증할 수 있었다.


7. 핵심 정리

이번 실습의 핵심은 로그인 입력값이 SQL Query에 직접 사용될 경우 SQL Injection을 이용해 인증 로직 자체를 변경할 수 있다는 점이다.

 

정상적인 로그인 Query가 다음과 같다면,

SELECT * FROM users
WHERE username = 'administrator'
AND password = 'password'
 

 

username에 다음 값을 입력하였다.

administrator'--
 

 

각 부분의 의미는 다음과 같다.

  • administrator : 로그인을 시도할 관리자 계정
  • ' : 기존 문자열 종료
  • -- : 이후 SQL Query를 주석 처리

 

결과적으로 SQL Query는 다음과 같은 형태가 된다.

SELECT * FROM users
WHERE username = 'administrator'--'
AND password = 'test'
 

 

따라서 비밀번호를 확인하는 다음 조건이 실행되지 않는다.

AND password = 'test'
 

 

최종적으로 서버는 사실상 다음 조건만 확인하게 된다.

username = 'administrator'
 

이를 통해 비밀번호 인증을 우회하여 administrator 계정으로 로그인할 수 있었다.

 

이번 Lab은 이전 상품 조회 SQL Injection 실습과 달리 OR 1=1을 사용해 전체 조건을 참으로 만드는 방식이 아니라, --를 이용해 비밀번호 검증 조건 자체를 주석 처리하는 것이 핵심이다.

 

 

1. Lab 개요

 

이번 실습은 상품 카테고리 필터에서 발생하는 SQL Injection 취약점을 이용하여, 정상적으로는 노출되지 않는 미출시 상품을 조회하는 문제이다.

애플리케이션에서 특정 카테고리를 선택하면 서버에서는 다음과 같은 SQL Query가 실행된다.

SELECT * FROM products
WHERE category = 'Gifts'
AND released = 1
 

여기서 released = 1 조건에 의해 출시된 상품만 조회된다.

따라서 이번 실습의 목표는 SQL Injection을 통해 해당 조건을 우회하고, released = 0인 미출시 상품까지 화면에 표시하는 것이다.

 

 

2. 실습 환경 확인

Lab에 접속하면 상품 목록과 함께 다음과 같은 카테고리 필터를 확인할 수 있다.

  • All
  • Accessories
  • Corporate gifts
  • Food & Drink
  • Gifts

카테고리를 선택하면 해당 카테고리에 해당하는 상품만 조회되는 구조이다.

이번 문제에서는 이 카테고리 필터에 전달되는 값이 SQL Query에 사용되는 입력값이라는 점이 핵심이다.

 

 

3. 취약한 파라미터 확인

 

카테고리 중 하나인 Corporate gifts를 선택하면 URL에서 다음과 같은 요청을 확인할 수 있다.

/filter?category=Corporate+gifts
 

 

여기서 확인할 수 있는 핵심 파라미터는 다음과 같다.

category
 

 

즉, category 파라미터에 전달되는 값이 서버의 SQL Query에서 다음 부분에 사용되는 것으로 볼 수 있다.

WHERE category = 'Corporate gifts'
 

따라서 이번 실습에서는 category 파라미터를 SQL Injection 공격 지점으로 사용하였다.

 

 

4. SQL Injection 수행

 

category 파라미터에 다음과 같은 값을 입력하였다.

' OR 1=1--
 

 

각 부분의 의미를 보면 다음과 같다.

 

기존 SQL Query에서 사용되고 있던 문자열을 작은따옴표로 종료한다.

'

 

 

1=1은 항상 참(TRUE)이므로, 기존의 카테고리 조건과 관계없이 WHERE 절의 조건을 참으로 만들 수 있다.

OR 1=1

 

 

--

SQL의 주석을 의미한다.

 

따라서 뒤에 존재하는 기존 조건을 주석 처리하여 실행되지 않도록 한다.

원래 SQL Query가 다음과 같았다면,

SELECT * FROM products
WHERE category = 'Gifts'
AND released = 1
 

 

입력값이 적용된 후에는 개념적으로 다음과 같은 형태가 된다.

SELECT * FROM products
WHERE category = '' OR 1=1--'
AND released = 1
 

 

-- 이후의 내용이 주석 처리되면서,

AND released = 1
 

조건 역시 적용되지 않게 된다.

 

결과적으로 WHERE 조건은 사실상 OR 1=1에 의해 항상 참이 되어, 출시 여부와 관계없이 상품이 조회된다.

 

 

5. 결과 확인

SQL Injection을 수행한 이후 기존 화면에서는 확인할 수 없었던 상품들이 추가로 나타나는 것을 확인하였다.

 

즉,

released = 1
 

조건이 무력화되면서 정상적으로는 표시되지 않아야 하는 미출시 제품까지 조회되었다.

이를 통해 Lab이 정상적으로 해결되었다.

 

 

6. 핵심 정리

이번 실습의 핵심은 사용자 입력값인 category가 SQL Query의 WHERE 절에 직접 사용되고 있다는 점이다.

 

정상적인 SQL Query에서는

category = 'Gifts' AND released = 1
 

과 같이 카테고리와 출시 여부를 모두 확인하지만,

 

' OR 1=1--
 

형태의 입력값을 이용하면

  • ' : 기존 문자열 종료
  • OR 1=1 : WHERE 조건을 항상 참으로 변경
  • -- : 이후 SQL 조건을 주석 처리

할 수 있다.

 

결과적으로 released = 1이라는 제한 조건이 적용되지 않아 숨겨진 미출시 상품까지 조회할 수 있었다.

Spring Data JPA 동시성 테스트 에러: expected: X but was: Y

🐛 에러 상황

스프링 부트 프로젝트에서 트랜잭션 서비스 통합 테스트를 작성하는 중, 동시성 테스트에서 아래와 같은 에러가 발생했다.

org.opentest4j.AssertionFailedError:
expected: 35L
 but was: 1L
Expected :35L
Actual   :1L

 

 

관련 테스트 코드

@Slf4j
@SpringBootTest
@ExtendWith(SpringExtension.class)
@ActiveProfiles("payment-test") // application-payment-test.yml 설정 사용
public class TransactionServiceIntgTest {
    // ===== 의존성 주입 =====
    // SUT
    @Autowired TransactionService transactionService;       // 실제 트랜잭션 서비스
    // 의존성 주입
    @Autowired TransactionRepository transactionRepository; // 실제 트랜잭션 리포지토리
    @Autowired WalletService walletService;                 // 실제 지갑 서비스
    @Autowired WalletRepository walletRepository;           // 실제 리포지토리

    // 각 테스트 격리
    @AfterEach
    void tearDown() {
        transactionRepository.deleteAll();                  // 트랜잭션 먼저 삭제
        walletRepository.deleteAll();                       // 지갑 삭제
    }

    @Test
    @DisplayName("동시에 같은 orderId로 충전 요청 시, 트랜잭션은 1건만 생성되고 잔액은 정확히 1회만 반영된다")
    void charge_concurrent_sameOrderId_isIdempotent_andUnique() throws InterruptedException {
        // given
        // 사용자 지갑 생성
        CreatedWalletResponse wallet = walletService.createWallet(new CreateWalletRequest(1L));
        // 생성된 지갑 ID
        Long walletId = wallet.id();

        // 동일 orderId 준비
        // 충전 금액 1000원
        String orderId = "order-123";
        BigDecimal amount = BigDecimal.valueOf(1000);

        // 동시성 환경 준비
        int threadCount = 20;                                                 // 동시에 20개의 스레드에서 충전 시도
        ExecutorService executor = Executors.newFixedThreadPool(threadCount); // 스레드풀 생성
        CountDownLatch latch = new CountDownLatch(threadCount);               // 모든 스레드 완료 대기용

        List<ChargeTransactionResponse> results = new ArrayList<>();          // 결과 수집용 (동기화 필요)

        // when
        // 모든 스레드에서 동시에 요청 시작
        for (int i = 0; i < threadCount; i++) {
            // 각 스레드에서 충전 시도
            executor.submit(() -> {
                try {
                    log.debug("[{}] charge() 호출 시작", Thread.currentThread().getName());

                    // 실제 서비스 호출
                    ChargeTransactionResponse response = transactionService.charge(
                            new ChargeTransactionRequest(walletId, orderId, amount)
                    );

                    log.debug("[{}] charge() 성공 → walletId={}, balance={}",
                            Thread.currentThread().getName(), response.walletId(), response.balance());

                    // 결과 수집 (동기화 필요)
                    synchronized (results) {
                        results.add(response);
                    }
                } catch (DataIntegrityViolationException e) {
                    // 중복 예외 무시 (멱등성 테스트이므로 무시)

                    log.warn("[{}] DataIntegrityViolationException 발생: {}",
                            Thread.currentThread().getName(), e.getMessage());

                } finally {
                    latch.countDown(); // 완료 표시
                }
            });
        }

        // 모든 스레드 완료 대기
        latch.await();

        // then
        // 디버깅 출력
        log.info("최종 results.size() = {}", results.size());
        results.forEach(r -> log.info("→ result walletId={}, balance={}", r.walletId(), r.balance()));

        // 최종적으로 DB에 트랜잭션은 1건만 존재하는지 확인
        assertThat(results).hasSizeGreaterThanOrEqualTo(1);

        // 멱등성 보장: 모든 응답의 walletId는 동일해야 한다
        assertThat(results.get(0).walletId()).isEqualTo(walletId);
        // 멱등성 보장: 모든 응답의 잔액은 동일해야 한다
        assertThat(results.get(0).balance()).isEqualByComparingTo(BigDecimal.valueOf(1000));

        // 디버깅 출력
        System.out.printf("✅ concurrent charge test: orderId=%s, txCount=%d, finalBalance=%s%n",
                orderId, results.size(), results.get(0).balance());
    }
}

 

 

테스트 시나리오는 다음과 같다:

  • 여러 개의 스레드가 동시에 같은 donationId / orderId로 결제·충전 요청을 보낸다.
  • 멱등성이 보장되어야 하므로 트랜잭션은 1건만 생성되고, 지갑 잔액은 정확히 한 번만 반영되어야 한다.
  • 그러나 실제 결과에서 walletId 값이 엉뚱하게 반환되거나, 아예 트랜잭션이 생성되지 않는 문제가 반복적으로 발생했다.

🔎 원인 분석

테스트에서 반복적으로 로그를 찍어본 결과, 일부 스레드에서 지갑이 존재하지 않는다 (WalletNotFoundException) 는 에러가 발생하거나, DB 반영이 꼬이는 현상이 있었다.

 

즉, 문제의 핵심은:

  • transactionService.charge() or payment() 호출 과정에서
  • DB에 insert된 Wallet 엔티티가 아직 flush되지 않아, 다른 스레드에서 해당 지갑을 찾을 수 없는 경우가 발생한 것.

JPA는 트랜잭션 커밋 시점에만 flush를 보장한다.

따라서 테스트 코드에서 walletService.createWallet() 호출 직후, 다른 스레드가 동시에 findWalletByWalletId() 를 호출하면 아직 DB에 반영되지 않은 상태일 수 있다.


✅ 해결 방법

해결책은 간단했다.

// 지갑 생성 직후 강제로 flush 수행
CreatedWalletResponse wallet = walletService.createWallet(new CreateWalletRequest(1L));
Long walletId = wallet.id();

walletRepository.flush();  // 💡 DB에 즉시 반영

walletRepository.flush() 를 호출하여 지갑 엔티티가 DB에 즉시 반영되도록 보장했다.

 

그 결과:

  • 다른 스레드에서 findWalletByWalletId() 호출 시 반드시 DB에서 조회 가능
  • 멱등성 테스트에서도 더 이상 WalletNotFoundException 이 발생하지 않음
  • 기대했던 대로 트랜잭션은 정확히 1건만 생성되고, 잔액도 올바르게 반영됨

✅ 요약

에러 상황

  • 동시성 테스트(charge_concurrent_sameOrderId_isIdempotent_andUnique)에서
    WalletNotFoundException 혹은 AssertionFailedError (expected: X but was: Y) 발생.
  • 원인: 메인 스레드에서 만든 Wallet 엔티티가 아직 DB에 제대로 반영되지 않은 상태에서,
    다른 스레드들이 동시에 findWalletByWalletId()를 호출 → DB에서 못 찾음.

해결 방법

  1. flush() 추가 
    • Wallet을 생성한 직후 DB에 강제로 반영(commit 아님).
    • 따라서 이후 다른 스레드에서 트랜잭션을 열어도 해당 Wallet row를 조회할 수 있게 됨.
    • → expected != actual 에러 해결됨.
walletRepository.flush();

❌ 왜 @Transactional을 쓰지 않았는가?

  1. Spring Test 기본 동작
    • @Transactional을 테스트에 붙이면 테스트 메서드 전체를 하나의 트랜잭션으로 감쌈.
    • 테스트 끝나면 무조건 롤백.
    • 즉, 테스트 본문에서 insert된 Wallet은 커밋되지 않은 상태로만 존재.
  2. 동시성 테스트와 충돌
    • 멀티스레드로 실행된 서비스 호출은 각각 자기 트랜잭션에서 실행됨.
    • 하지만 메인 트랜잭션은 아직 커밋되지 않았으므로, 다른 스레드에서는 Wallet을 볼 수 없음.
    • 결과적으로 WalletNotFoundException이 발생.
  3. 정리
    • 동시성 테스트에서는 각 스레드가 DB에서 커밋된 데이터를 주고받아야 함.
    • 따라서 @Transactional을 테스트 클래스/메서드에 붙이지 않고,
      대신 Wallet 생성 후 flush()로 강제 반영해줌.

📝 교훈

  1. JPA의 flush 타이밍은 커밋 시점까지 지연될 수 있다. 멀티스레드/동시성 테스트에서는 flush 타이밍이 중요하다.
  2. 테스트 환경에서 동시에 여러 스레드가 같은 데이터를 접근한다면, flush()로 DB 반영을 명시적으로 강제하는 것이 안전하다.
  3. 처음에는 통과했는데 반복 실행에서 실패
    → 동시성 테스트는 한 번의 성공으로 넘어가면 안 되고, 여러 번 연속 실행해서 안정성을 확인해야 함.

 

이 테스트에 대한 문제를 뒤늦게 발견한 이유는, 첫 테스트 땐 문제 없이 통과했었기 때문이다.

첫 테스트 이후 재차 테스트를 시도했어야 했는데, 그러지 않았고 결국 뒤늦게 문제를 발견하게 되었다.

 

🔄 왜 처음엔 통과했을까?

  1. 첫 실행 시에는 통과
    • 테스트 클래스에서 Wallet 생성 직후 곧바로 동시성 테스트가 실행됨.
    • MySQL(InnoDB)나 H2 같은 DB는 트랜잭션 내에서 insert한 데이터가 세션 캐시에 남아있을 수 있고,
      때로는 다른 스레드가 DB에 반영된 것처럼 접근 가능해지는 운 좋은 케이스가 발생.
    • 그래서 단발성 실행에서는 테스트가 우연히 통과할 수 있음.
  2. 연속 실행 시에는 실패
    • 두 번째 테스트 실행부터는 트랜잭션 롤백/커밋 타이밍, DB 세션 정리, 캐시 초기화 등이 달라짐.
    • 즉, Wallet 엔티티가 아직 flush/commit 되지 않은 상태에서 다른 스레드들이 조회 → WalletNotFoundException 발생.
    • 테스트는 항상 DB 레벨에서 일관된 상태를 가정해야 하는데, flush 없이는 그게 깨짐.

🚀 결론

  • 에러 메시지 expected: X but was: Y 는 단순히 Assertion 실패가 아니라,
  • DB 반영 타이밍 불일치로 인한 동시성 문제였다.
  • 동시성 테스트에서는 테스트 메서드에 @Transactional 금지.
  • 필요한 데이터는 saveAndFlush() 또는 flush()로 DB에 즉시 반영.
  • 그래야 멀티스레드 환경에서 모든 스레드가 동일한 데이터를 볼 수 있고, 멱등성 검증이 가능하다.

+ Recent posts