This is a collection of challenges in PlaidCTF2022.
According the code, the first and the second sample ID(if the panel is too quickly to be closed, see it in the Devtools) are converted to matrix
So, after get sample IDs by the first and the second player, and run the below code, I got the flag.
first_id = 'UXJGAIEVIK_EFWPQXWHWBNGMJ'
second_id = 'XUAHFBZMCOQLUTMSLKJRDVW!O'
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ_!?"
def str2mat(s):
return matrix(Zmod(29), 5, 5, [alphabet.index(c) for c in s]).T
def mat2str(m):
return ''.join(alphabet[m[i,j]] for i in range(5) for j in range(5))
A = str2mat(first_id)
B = str2mat(second_id)
print(mat2str(B*~A))
# TEST_POSITIVE!_FR_GRAVEL?
username = username.replace("'", "''");
password = password.replace("'", "''");
// Max length of username and password is 32
username = username.substring(0, 32);
password = password.substring(0, 32);
pool.query<{ id: number; username: string }>(
`SELECT id, username FROM users WHERE username = '${username}' AND password = '${password}'`
)
This challenge is a simple SQL inject test.
Notice that the ''
at the index 31 and 32 will be cut into '
, so we can use 1111111111111111111111111111111'
as username and union select 1,* from flag;--
as password, which result in SELECT id, username FROM users WHERE username = '1111111111111111111111111111111'' AND password = 'union select 1,* from flag;--'
, to get the flag.
Use IDA to decompile the engine file and read the logic.
It require us repeat what he said 5 times and then the task succuess. But for this challenge, you need to pass the secret 6663232
and play a "pick stones" game with a random computer.
It's easy to satisfy these requirements by hand in the game panel.
It's easy to find the stack overflow bug at 0x4013B2. And this program is no canary and pie, so just change the return address to the print_flag
is OK.
So, the payload is "11111111111111111111111\u00bd\u0014@\u0000\u0000\u0000\u0000\u0000"
.
The code of amongst provided a easy interface to input the payload at PurchaseSnackPanel.tsx#L22, we can input the payload by setVendingMachineInput("11111111111111111111111\u00bd\u0014@\u0000\u0000\u0000\u0000\u0000")
and press the vend key, then we got the flag.
Go to the device to download the file, and then use open(URL.createObjectURL(new Blob([new Uint8Array(FileTransferController.downloadMap.get(15).split('').map(x => x.charCodeAt()))], {type: 'application/file'})))
to download the content.
Open the file by wireshark, we can found a lot of HTTP request with 206 response, so I use a script to combine them into a picture file.
PS: Since this challenge is solved by my teammate in the contest, I'm not sure whether the answer is correct or not.
In this challenge, we've been required to move an alive player to the satellite device and open its panel to get the flag.
Hints:
@amongst/game-common
be lookin' mad sus (and I'm not referring to anything inconstants.ts
)- It was nice of me to leave some comments in the code, but unfortunately, you can't believe everything you read. There is at least one hoaxer amongst the comments.
- I'm not saying that one of the comments is sus, I'm saying that one of the comments is stating something that's just plainly false.
- For
// Choose a random player to be a hoaxer
: It's not that one lol.
According to these hints, we focus on LevelMap::applyWalls
in packages/game-common/src/LevelMap.ts
public applyWalls(point: Point): Point {
const playerSize = new Point(PlayerWidth, PlayerWidth);
const testBox = new Box(point.sub(playerSize.scale(0.5)), point.add(playerSize.scale(0.5)));
const walls = this.wallMap.getPossibleCollisions(testBox);
for (const wall of walls) {
point = wall.pushPlayer(point);
}
return point;
}
and Wall::pushPlayer
in packages/game-common/src/Wall.ts
.
public pushPlayer(point: Point): Point {
// Compute the required displacement as if the player is a unit circle at the origin
const start = this.start.sub(point).scale(2 / PlayerWidth, 2 / PlayerHeight);
const end = this.end.sub(point).scale(2 / PlayerWidth, 2 / PlayerHeight);
const lengthVector = end.sub(start);
const lengthUnitVector = lengthVector.unit();
const depthUnitVector = new Point(lengthUnitVector.y, -lengthUnitVector.x);
const playerDepth = -depthUnitVector.dot(start);
if (playerDepth < -1 || playerDepth > 1) {
// Circle doesn't intersect the line
return point;
}
const projectionDist = -lengthUnitVector.dot(start) / lengthVector.mag();
if (projectionDist < 0 || projectionDist > 1) {
// Circle intersects the line but not the segment
return point;
}
const amountToPush = -1 - playerDepth;
const pushVector = depthUnitVector.scale(amountToPush).scale(PlayerWidth / 2, PlayerHeight / 2);
return point.add(pushVector);
}
There are one comment is plainly false: // Circle intersects the line but not the segment
. The actual judgment here is whether the projection of the line from point
to the wall is on the wall, not the circle.
However, I read the code over and over again, and cannot find any exploitation.
So, I decided to use the simplest black box technique to find the problem.
Just run the code(follow the instruction in the comment), I get the below picture firstly:
In this picture, I use all points whose coordinates can be divided by 0.1 into the pushPlayer function, and draw black dot in all point it output.
There is no doubt that the corner of walls is suspective.
Can we escape the wall by using delta which can be divided by 0.1 and which is equals to 0.5+1e-8
?
To answer this question, I also draw all points that can be reach in a BFS search:
Obviously, the answer is no.
So I want to know why the suspective shapes in the corner of wall of the first picture exist?
I located these point by check whether using the output point as the input for the applyWall function again will change the output point again, and marked them in green. And I marked the original point of these points in red:
It's easy to find a solution from this map:
For more accuracy, now I explain why it can work.
We can got the wall check area(where player will be pushed out by the wall) by reading the source code. The answer is the check area of a horzontal wall is a rectangle whose midpoints of its left and right edges are the start and end points of these wall, with a height of 1, and the check area of a vertical wall is a rectangle whose midpoints of its top and bottom edges are the start and end points of these wall, with a width of 2.
So the corner is looked like below:
That's say what will happen if we go to such a corner which will check the vertical wall before the horizontal wall(Only such walls are vulnerable) like [24.5,19]
and then keeping move with a delta of [0.00009, 0.5+1e-9]
.
For each tick:
- The player begin at (1).
- And then it move with a delta of
[0.00009, 0.5+1e-9]
, i.e. move from (1) to (2). - (Vertical Wall).pushPlayer, i.e. still at (2).
- (Horizontal Wall).pushPlayer, i.e. move from (2) to (3).
So for each tick, we can go a little bit right. And after a certain tick, the player can be at (4).
Then, move with a delta of [0.00009, 0.5+1e-9]
again, the player is at (5), so the player is out of the wall.
Note that each tick cost 50ms, so the exploit procedure cost about 10 min.
Write these steps by a script(Usage: Create a room and join three players and start the game, then run node flag2NodeScript.mjs <roomId>
), then we can got the flag.
Due to the ping to the server is really high in my location, I rent a ECS instance near the server, and run the script at that server.
And the script for Incite Conspiracy can be used here for keeping other players' connection alive.
Since the Euclidean distance of a single movement cannot exceed 0.5+1e-8, it is easy to have the illusion that once the absolute value of y component exceeds 0.5, the absolute value of x component cannot be higher than 1e-8.
But in fact, if the limitation of the Euclidean distance is
This trap impressed me long time ago.
I use two way to get this.
In local test, I just add (window as any).game = game;
into the codebase.
In the remote, I use a hook technique to get the game from the exposed FileTransferController
class.
Firstly, run this code in Devtools to hook a function of controllers.
let hooked = Object.getPrototypeOf(FileTransferController).prototype.detach;
globalThis.leakOut = [];
Object.getPrototypeOf(FileTransferController).prototype.detach = function (...args) {
globalThis.leakOut = args;
return hooked.apply(this, args);
};
After close any panel, run the code below to get game object.
let [game, self] = globalThis.leakOut;
Additional, I put some junk code in this repo.
In this part, I just reproduce other player's solution, so there is no analysis process.
It's really useful to draw walls in this challenge, my code to draw the dropship and the both can be found in this repo. Put the code into the Devtools for about:blank
, then you can see the output.
In this challenge, we've been required to move an alive player to the conspiracy-board device and open its panel to get the flag.
Notice 5 points in the code:
- The player's
recentLocation
won't be cleared when the player enter the shelld from dropship. - The order in the movement system is apply the delta -> applyWall -> applyBound -> deal with killing(including location change in killing).
- The check allow a hoaxer to kill anoher player when: the distance between the hoaxer-provided point and the hoaxer is quite close; the hoaxer-provided point is equal to the current location of the player or one of its
recentLocation
; there are no wall between(nothasWallBetween
) the hoaxer-provided point and the hoaxer. - When the player interact other system, the
recentLocation
won't be updated anymore.(Same goal can be achieved by always sending invalid delta in movement system.) - Part of the positions that players can reach in dropship are very close to the Conspiracy room in shelld.
So, if a player go to a useful point in dropship and when the game start, this player immediately interacts with the immediate emergency button system, its recentLocation
can always contains a certain position where player cannot reach directly in shelld.
A script to achieve such a goal can be found in the repo. The usage is create a room and run node flag1NodeScript.mjs <roomId>
and just keep it running.
Note that for debug purpose, we can add a new API to get the recentLocation from the local server.
Now, to bypass hasWallBetween
, we can select a position(2) near or on the horizontal wall we cannot reach directly. And then let the hoaxer(initially at (1)) kill this player to get its location(2)(due to the order of movement system, this gap won't applyWall
), and then move with a delta of [0,-0.5-1e-8]
to escape the wall check zone(3).
To ensure the next tick of kill can be move with a delta of [0,-0.5-1e-8]
, use this script(follow the instruction in comment).
Just solve any problem above.