ZJUCTF 2025 Write-up
This is a write-up for ZJUCTF 2025. The original challenge descriptions can be found at https://mp.weixin.qq.com/s/Z91DTlJtvWClyCfnHpXTOQ for ZJU students or https://notes.sjtu.edu.cn/s/nji8es_7e for SJTU students.
All solution code and some challenge files referenced below are collected in the repository MingchenDai/ZJUCTF-2025.
MISC::Bingo
The challenge provides a
I implemented this brute-force search in C++; see
Bingo/solution/bingo_solver.cpp in the repository. The
program iterates over all bitmasks from 0 to
Some constraints in the statement are ambiguous (for example, column 3 and 4 of rank 3, and column 5 of rank 4), so the solver intentionally skips those unclear conditions and only enforces the precise ones. Even with this partial checking, the search narrows all possible boards down to a handful of candidates:
1 | Found solution #1: |
Thus the exhaustive search over all
ZJUCTF{10110_00010_00111_01010_01010}.
MISC::ZJUWLAN-Insecure
This challenge reproduces the authentication logic of the ZJU campus
network portal. From previous experience with the SJTU
jAccount login page, it is natural to inspect the
JavaScript used to construct the login request and, in particular, the
info field of the POST body.
By reading ZJUWLAN-Insecure/challenge/Port.js and
ZJUWLAN-Insecure/challenge/all.js (copied from the real ZJU
login page), we can see that info is produced as
follows:
- Build a JSON object containing
username,password,ip,acidandenc_ver. - Serialize it to a JSON string.
- Encrypt the string with the custom
xEncodealgorithm, using thetokenvalue as the key. - Apply a custom Base64 encoding, and finally prepend the string
{SRBX1}.
I wrote a Node.js script
ZJUWLAN-Insecure/solution/generate_info.js to reproduce
this encoding process and confirm the behavior.
For the challenge itself, we are instead given a network capture
zjuwlan-insecure.pcap. Inspecting the captured login
request allows us to extract both the token and the
info field directly from the HTTP POST:
token:517557d10a3aff098a898753317e8ef0b2822540d067490585c41d39d847ed7ainfo:{SRBX1}KxvFtemc1wBEGdNAbPEfd7s02umxP0Nagix+YxJsqbAEh5/fzuIYqad8xrqKW4yzfA9/I3xGKPMTNziE1wPFhfnCaX8CWsnglgKKjVozxsa46BrEY0n4kc/y2rdlbE7wWPBjdWxaZ4yfs8DLRovR7L==
To recover the original JSON, we simply invert the above
transformation: strip the {SRBX1} prefix, apply the custom
Base64 decoding, then run xEncode decryption with the known
token. I implemented this in another Node.js script
ZJUWLAN-Insecure/solution/decode_info.js.
Running the decoder with the extracted parameters yields:
1 | node decode_info.js --token 517557d10a3aff098a898753317e8ef0b2822540d067490585c41d39d847ed7a --info {SRBX1}KxvFtemc1wBEGdNAbPEfd7s02umxP0Nagix+YxJsqbAEh5/fzuIYqad8xrqKW4yzfA9/I3xGKPMTNziE1wPFhfnCaX8CWsnglgKKjVozxsa46BrEY0n4kc/y2rdlbE7wWPBjdWxaZ4yfs8DLRovR7L== |
Therefore, the password embedded in the login request is already the flag:
ZJUCTF{Thanks, TLS!}.
CRYPTO::Crypt-it
The challenge provides a ciphertext Crypt-it/flag.enc
and a corresponding RSA private key
Crypt-it/private_key.pem. From the description (and by
inspecting the key parameters), we know that the ciphertext is produced
using RSA in OAEP mode.
The intended solution is simply to perform the RSA-OAEP decryption
locally. OpenSSL’s pkeyutl command can do this
directly:
1 | openssl pkeyutl -decrypt \ |
After running the command, the decrypted plaintext is written to
flag.dec. Viewing this file reveals the flag:
ZJUCTF{RSA_encrypt_with_OAEP_is_secure!}.
WEB::一觉醒来全世界计
This challenge is accessed via WebSocket, so no challenge or solution source code is provided. Refer to the CTF official website for the full protocol details.
From observing the traffic and experimenting with the API, we find
that the ranking is based purely on the elapsed_time field
submitted by players. There is no server-side validation that
elapsed_time must be non-negative or even realistic.
Therefore, we can submit an impossible but very small (negative)
elapsed time to immediately jump to the top of the ranking. For example,
sending a POST request to /submit with the following JSON
payload is sufficient:
1 | { "username": "senpai", "elapsed_time": -1 } |
After the submission succeeds, a subsequent request to
/ranking shows that this entry is now ranked first. The
flag is exactly the username of the top-ranked player, so we obtain:
ZJUCTF{are_you_really_primary_school_student_1bf1fcf1aea1}.
WEB::Submit Your Paper!
In this challenge, a simple paper submission system is provided. The
backend logic in paper.js reveals that the flag is only
appended to a paper’s abstract when the paper’s status is
Accept, and only when the owner views that paper.
1 | router.get('/papers/:id/view', ensureAuthenticated, (req, res) => { |
Looking at the admin interface templates (for example,
views/admin/list.ejs), the abstract field of
each paper is rendered using the EJS unescaped tag
<%- ... %>, which inserts the content as raw HTML
instead of escaping it. This immediately suggests that if we can submit
a paper whose abstract contains malicious HTML/JavaScript, we can
achieve stored cross-site scripting (XSS).
The challenge description states that the administrator is
implemented as a bot running a real Chromium instance. The bot logs in
as admin and visits /admin/papers, viewing the table of all
submitted papers. Any stored XSS payload in the abstract
column is therefore executed in the admin’s browser session.
Our goal is to use this XSS primitive to change the status of our own
paper to Accept. Once the status is updated, visiting the
normal user-side paper view route /papers/:id/view as the
paper owner will include the flag in the abstract.
We can craft an HTML payload that:
- Locates the surrounding table row for the current paper.
- Reads the
data-paper-idattribute to determine the paper ID handled in that row. - Sends a
POSTrequest to the admin endpoint that changes the status of that paper toAccept. - Optionally removes the existing status badge from the row to reflect the change in the admin interface.
One such payload is:
1 | </span> |
Here an image load failure is used to trigger the embedded JavaScript
via the onerror handler. The script walks up the DOM to the
closest <tr> row, reads its
data-paper-id, then issues a POST to
/admin/papers/:id/status with status=Accept.
The final line removes the .badge element within that row
to visually indicate the changed status.
After submitting a paper whose abstract contains this payload and
waiting for the admin bot to visit /admin/papers, the XSS
runs in the admin context and sets our paper’s status to
Accept. When we subsequently view the paper as its owner at
/papers/:id/view, the backend appends the contents of
/flag to the abstract, revealing:
ZJUCTF{congrats_for_being_accepted!_cd7637972512}.
WEB::你说你不懂 Linux
This challenge is also accessed via WebSocket. The HTML response page is available at
你说你不懂Linux/index.htmlin the repository.
The backend is implemented in PHP and takes a file
parameter from the user, subject to several string-based filters. From
reading the source, we can summarize the constraints as:
- The string must not contain
\\. - The string must not contain
flag(case-sensitive). - The string must not contain
txt(case-sensitive). - The string must contain the substring
.log. - In particular, the substring
flag.txt(case-insensitive) must not appear.
At first glance this seems to rule out directly accessing
flag.txt. However, the challenge is running on Windows,
using the NTFS filesystem. On NTFS, both / and
\\ are accepted as path separators, and filename
normalization rules allow certain tricks with trailing dots and
spaces.
Two key observations make the bypass possible:
- Because
/is treated as a valid separator on Windows, we can write directory traversals like/..../instead of\\..\\and still have the path resolve correctly. - Windows normalizes filenames by ignoring trailing spaces and dots.
For example, the path
FlAg.TxT.(note the trailing space) still refers to the fileFlAg.TxTon disk.
Combining these facts, we can construct a file parameter
that:
- Contains
.logto pass the positive check. - Uses
/../../../with dots to traverse directories. - Refers to
FlAg.TxT.with mixed case and a trailing space, so that:- The literal substring
flag.txtdoes not appear in the parameter. - Windows still resolves the path to the actual
flag.txtfile.
- The literal substring
A working value for file is:
114514.log/../../../FlAg.TxT.
Submitting this value successfully bypasses the filters and reads the flag:
ZJUCTF{don't_say_you_are_unfamiliar_with_paths_again!_6232657393ee}.
REVERSE::Hello-reverse
This reverse-engineering warm-up provides a small binary
Hello-reverse/hello-reverse with no additional obfuscation.
Simply opening the executable in a hex editor and searching for the
string ZJUCTF{ immediately reveals the embedded flag:
ZJUCTF{Hello_Let's_Reverse_2025🎉}.
It is interesting to note that the organizers allowed an emoji inside the flag format.
WELCOME::{ALL}
Omitted.