These two problems are really interesting! Although the tags are reversed, using the front end as a carrier, there are many clever tricks with JS/CSS, and I can't wait to share them with everyone.
Treasure Map#
Problem address: http://treasure.chal.pwni.ng/
Ready your masts and set sail! Thar be treasure here if we can figure out how to find it.
Buried Treasure
Follow the map and get the booty — a pirate's work is never done.
Problem#
This problem includes success.js, fail.js, and 200 identical js files from 0.js to 199.js (the referenced SourceMap is different).
0.js
const b64 = `
A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
P
Q
R
S
T
U
V
W
X
Y
Z
a
b
c
d
e
f
g
h
i
j
k
l
m
n
o
p
q
r
s
t
u
v
w
x
y
z
0
1
2
3
4
5
6
7
8
9
+
/
=`;
export const go = async () => {
const bti = b64.trim().split("\n").reduce((acc, x, i) => (acc.set(x, i), acc), new Map());
const upc = window.buffer.shift();
const moi = await fetch(import.meta.url).then((x) => x.text())
const tg = await fetch(moi.slice(moi.lastIndexOf("=") + 1)).then((x) => x.json())
const fl = tg.mappings.split(";").flatMap((v, l) =>v.split(",").filter((x) => !!x).map((input) => input.split("").map((x) => bti.get(x)).reduce((acc, i) => (i & 32 ? [...acc.slice(0, -1), [...acc.slice(-1)[0], (i & 31)]] : [...acc.slice(0, -1), [[...acc.slice(-1)[0], i].reverse().reduce((acc, i) => (acc << 5) + i, 0)]].map((x) => typeof x === "number" ? x : x[0] & 0x1 ? (x[0] >>> 1) === 0 ? -0x80000000 : -(x[0] >>> 1) : (x[0] >>> 1)).concat([[]])), [[]]).slice(0, -1)).map(([c, s, ol, oc, n]) => [l,c,s??0,ol??0,oc??0,n??0]).reduce((acc, e, i) => [...acc, [l, e[1] + (acc[i - 1]?.[1]??0), ...e.slice(2)]], [])).reduce((acc, e, i) => [...acc, [...e.slice(0, 2), ...e.slice(2).map((x, c) => x + (acc[i - 1]?.[c + 2] ?? 0))]], []).map(([l, c, s, ol, oc, n], i, ls) => [tg.sources[s],moi.split("\n").slice(l, ls[i+1] ? ls[i+1]?.[0] + 1 : undefined).map((x, ix, nl) => ix === 0 ? l === ls[i+1]?.[0] ? x.slice(c, ls[i+1]?.[1]) : x.slice(c) : ix === nl.length - 1 ? x.slice(0, ls[i+1]?.[1]) : x).join("\n").trim()]).filter(([_, x]) => x === upc).map(([x]) => x)?.[0] ?? tg.sources.slice(-2, -1)[0];
import(`./${fl}`).then((x) => x.go());
}
//# sourceMappingURL=0.js.map
fail.js
export const go = () => {
document.querySelector(".frame").classList.add("fail");
}
success.js
import { go as fail } from "./fail.js";
export const go = () => {
if (window.buffer.length !== 0) {
fail();
} else {
document.querySelector(".frame").classList.add("success");
}
}
The general verification process is as follows:
- Input a valid format Flag
- Store it in window.buffer
- Call the
go()
method (this method exists in all 202 scripts, and the webpage defaults to referencing 0.js, so it executes thego()
method in 0.js) - Use some algorithm to find another script among these 202 scripts to load and execute its
go()
method
Analysis#
The ultimate goal is to allow the script to load success.js
and execute it. However, all scripts have the same content, so it may be necessary to start with the SourceMap.
{
"version": 3,
"sources":["0.js","1.js","2.js","3.js","4.js","5.js","6.js","7.js","8.js","9.js","10.js","11.js","12.js","13.js","14.js","15.js","16.js","17.js","18.js","19.js","20.js","21.js","22.js","23.js","24.js","25.js","26.js","27.js","28.js","29.js","30.js","31.js","32.js","33.js","34.js","35.js","36.js","37.js","38.js","39.js","40.js","41.js","42.js","43.js","44.js","45.js","46.js","47.js","48.js","49.js","50.js","51.js","52.js","53.js","54.js","55.js","56.js","57.js","58.js","59.js","60.js","61.js","62.js","63.js","64.js","65.js","66.js","67.js","68.js","69.js","70.js","71.js","72.js","73.js","74.js","75.js","76.js","77.js","78.js","79.js","80.js","81.js","82.js","83.js","84.js","85.js","86.js","87.js","88.js","89.js","90.js","91.js","92.js","93.js","94.js","95.js","96.js","97.js","98.js","99.js","100.js","101.js","102.js","103.js","104.js","105.js","106.js","107.js","108.js","109.js","110.js","111.js","112.js","113.js","114.js","115.js","116.js","117.js","118.js","119.js","120.js","121.js","122.js","123.js","124.js","125.js","126.js","127.js","128.js","129.js","130.js","131.js","132.js","133.js","134.js","135.js","136.js","137.js","138.js","139.js","140.js","141.js","142.js","143.js","144.js","145.js","146.js","147.js","148.js","149.js","150.js","151.js","152.js","153.js","154.js","155.js","156.js","157.js","158.js","159.js","160.js","161.js","162.js","163.js","164.js","165.js","166.js","167.js","168.js","169.js","170.js","171.js","172.js","173.js","174.js","175.js","176.js","177.js","178.js","179.js","180.js","181.js","182.js","183.js","184.js","185.js","186.js","187.js","188.js","189.js","190.js","191.js","192.js","193.js","194.js","195.js","196.js","197.js","198.js","199.js","fail.js","success.js"],
"mappings":";A4DAA;A0DAA;AzEAA;AsDAA;AmGAA;AtIAA;ApBAA;A8DAA;AZAA;AxDAA;AyDAA;ALAA;A9EAA;A6HAA;AoBAA;A1BAA;A7BAA;AvCAA;AwEAA;AFAA;AuBAA;A8BAA;AHAA;AnGAA;AvBAA;A+GAA;A2BAA;A/EAA;A7CAA;ALAA;ArCAA;AqJAA;AxCAA;AoDAA;AGAA;AtEAA;AtDAA;AjEAA;AYAA;AiFAA;AhBAA;ArEAA;AkJAA;AlCAA;A9GAA;AkHAA;AnFAA;AMAA;A5CAA;AgCAA;AyJAA;AhDAA;AjFAA;AoDAA;A/FAA;A+HAA;AzIAA;A6CAA;AsBAA;A4FAA;AvFAA;A4BAA;A1DAA;A4CAA;AoGAA"
}
Above is the SourceMap of 0.js, which provides a detailed mapping of the current source code. So what exactly is mapped?
The
mappings
in the SourceMap contains VLQ encoding, where semicolons indicate file lines, and commas indicate positions. The VLQ encoded part is a variable-length array representing the various increments needed for mapping. For more details, refer to the article http://ruanyifeng.com/blog/2013/01/javascript_source_map.html
According to the mapping rules of the SourceMap, lines 2-66 of the script (i.e., the content of the b64 variable) are mapped to different 66 files. For example, the mapping relationship of 0.js is roughly as follows:
[["60.js","A"],["118.js","B"],["45.js","C"],["99.js","D"],["198.js","E"],["64.js","F"],["44.js","G"],["106.js","H"],["94.js","I"],["38.js","J"],["95.js","K"],["90.js","L"],["12.js","M"],["137.js","N"],["157.js","O"],["131.js","P"],["102.js","Q"],["63.js","R"],["135.js","S"],["133.js","T"],["156.js","U"],["186.js","V"],["183.js","W"],["84.js","X"],["61.js","Y"],["172.js","Z"],["199.js","a"],["120.js","b"],["75.js","c"],["70.js","d"],["33.js","e"],["182.js","f"],["142.js","g"],["194.js","h"],["197.js","i"],["127.js","j"],["73.js","k"],["8.js","l"],["20.js","m"],["101.js","n"],["85.js","o"],["16.js","p"],["162.js","q"],["128.js","r"],["18.js","s"],["132.js","t"],["49.js","u"],["55.js","v"],["11.js","w"],["43.js","x"],["196.js","y"],["148.js","z"],["67.js","0"],["119.js","1"],["24.js","2"],["151.js","3"],["14.js","4"],["59.js","5"],["81.js","6"],["173.js","7"],["86.js","8"],["114.js","9"],["56.js","+"],["100.js","/"]]
The code in 0.js-199.js is actually parsing the SourceMap, extracting characters from the input flag in sequence, and mapping them to specific js files.
For example, for a flag starting with B, it will request 118.js, parse the SourceMap of 118.js, and process the second character of the flag, and so on.
We can try to find which file contains the mapping to success.js, so we can determine the last character of the Flag and work backwards to get the final Flag.
Related Scripts#
Download Files#
First, of course, we need to download all the SourceMaps. Here is a NodeJS script:
async function download() {
const fs = require("fs");
for (let i = 0; i < 200; i++) {
const url = `http://treasure.chal.pwni.ng/${i}.js.map`;
console.log(url);
const data = await fetch(url).then((res) => res.text());
fs.writeFileSync(`./originmaps/${i}.js.map`, data);
}
}
download();
The script will download all the SourceMaps into the originmaps folder. (Remember to create the folder in advance)
Parse SourceMap#
Slightly modify the provided js to parse the SourceMap and save the mapping table to a file.
// char2js.js
const fs = require("fs").promises;
const { VLQDecode, getSource, JS_SOURCE } = require("./utils.js");
(async function () {
// Traverse all files in originmaps
const maps = await fs.readdir("./originmaps");
for (const map of maps) {
// console.log(map);
// Read file content
const content = JSON.parse(
await fs.readFile(`./originmaps/${map}`, "utf-8")
);
let _map = getCharFileMap(content);
// Write to char2js folder
await fs.writeFile(`./char2js/${map}`, JSON.stringify(_map));
}
})();
const getCharFileMap = (content) => {
const source = JS_SOURCE;
const lines = content.mappings.split(";");
const fl = lines
.flatMap((item, index) => {
// Position splitting
const pos = item.split(",").filter((x) => !!x);
// Decoding
const decodedPos = pos.map((input) => VLQDecode(input));
return decodedPos
.map(([c, s, ol, oc, n]) => [
index,
c,
s ?? 0,
ol ?? 0,
oc ?? 0,
n ?? 0,
])
.reduce(
(acc, e, i) => [
...acc,
[index, e[1] + (acc[i - 1]?.[1] ?? 0), ...e.slice(2)],
],
[]
);
})
.reduce(
(acc, e, i) => [
...acc,
[
...e.slice(0, 2),
...e.slice(2).map((x, c) => x + (acc[i - 1]?.[c + 2] ?? 0)),
],
],
[]
)
.map(([l, c, s, ol, oc, n], i, ls) => [
getSource(s),
source
.split("\n")
.slice(l, ls[i + 1] ? ls[i + 1]?.[0] + 1 : undefined)
.map((x, ix, nl) =>
ix === 0
? l === ls[i + 1]?.[0]
? x.slice(c, ls[i + 1]?.[1])
: x.slice(c)
: ix === nl.length - 1
? x.slice(0, ls[i + 1]?.[1])
: x
)
.join("\n")
.trim(),
]);
return fl;
};
This file uses utils.js, which can be downloaded here: https://ipfs.4everland.xyz/ipfs/QmSDubw4sHg25kSjzzQu9aotV52bbbRp9nZbxog5KcbfDX
Find the Correct Loading Path#
const fs = require("fs").promises;
const jsMaps = {};
function calcPath(curFlagPath, currentJsCursor) {
if (currentJsCursor == "0.js") {
console.log(curFlagPath.reverse().join(""));
return;
}
for (let map of Object.keys(jsMaps)) {
let flag = false;
for (const [file, char] of jsMaps[map]) {
if (file == currentJsCursor) {
if(currentJsCursor === map) flag = true;
flag = true
let _curFlagPath = [...curFlagPath];
_curFlagPath.push(char);
currentJsCursor = map.replace(".map", "");
calcPath(_curFlagPath, currentJsCursor);
}
}
if (flag) break;
}
}
(async function () {
// Traverse all files in char2js
const maps = await fs.readdir("./char2js");
for (const map of maps) {
// Read file content
const content = JSON.parse(await fs.readFile(`./char2js/${map}`, "utf-8"));
jsMaps[map] = content;
}
calcPath([], "success.js");
})();
Wrong 🤯🤯#
In the end, we can get a 23-character Flag: Nd+a+map/How+about+200!
, but the problem requires 25 characters. This indicates that there may be multiple paths in between, and the following script is an improved version by Bai Miao, tql.
const fs = require("fs").promises;
const jsMaps = {};
let final = [];
(async function () {
// Traverse all files in char2js
const maps = await fs.readdir("./char2js");
for (const map of maps) {
// Read file content
const content = JSON.parse(await fs.readFile(`./char2js/${map}`, "utf-8"));
jsMaps[map] = content;
}
let currentJsCursor = [{"cur":"success.js","path":[]}];
let nextJsCursor = [];
for (i = 0; i < 25; i++) {
for (let map of Object.keys(jsMaps)) {
for (const [file, char] of jsMaps[map]) {
currentJsCursor.filter((item) => item.cur === file)
.forEach(
(item) => nextJsCursor.push({cur:map.replace('.map',''),path:[...item.path,char]}
));
}
}
currentJsCursor = nextJsCursor.reduce((acc, cur) => {
if (acc.findIndex((item) => item.cur === cur.cur) === -1) {
acc.push(cur);
}
return acc;
}, []);
nextJsCursor = [];
}
currentJsCursor.filter((item) => item.cur === "0.js").forEach((item) => console.log(item.path.reverse().join("")));
})();
The previous incorrect Flag path was 0.js
->137.js
->160.js
->192.js
->... (Nd+a...)
The correct Flag path is 0.js
->137.js
->23.js
->137.js
->160.js
->192.js
->... (Need+a...)
The correct Flag is PCTF{Need+a+map/How+about+200!}
CSS#
Problem address: https://plaidctf.com/files/css.74486b61b22e49b3d8c5afebee1269e37b50071afbf1608b8b4563bf8d09ef92.html
I found this locked chest at the bottom o' the ocean, but the lock seems downright... criminal. Think you can open it? We recommend chrome at 100% zoom. Other browsers may be broken.
This problem is a password lock completely made of CSS. The day before yesterday, I tweeted about the CSS styles of this problem. (People who can create this kind of work are really amazing.)
Analysis#
The Flag contains lowercase letters a
-z
and underscores _
, grouped into 14 groups of three characters, totaling 42 characters.
The character selection is achieved through the details tag, where the child elements of details have different heights, using the CSS calc
function to obtain the height and calculate the character element's offset, achieving the purpose of displaying characters.
Every three characters control four SVG masks. Taking eight as an example:
The gray part is the transparent position of each SVG. If the position of each SVG is correct, the final effect should be:
Fortunately, the SVG is embedded as a background image in dataurl format, but the height of this element is fixed, so there is no need to consider various background filling methods (cover
/contain
, etc.). In other words, to solve the correct Flag, the final top
value of the SVG must only relate to the height of its transparent position.
Solving the Problem#
I didn't look at how the heights of each detail tag and SVG container change; there are simply too many (perhaps using Typed OM for assistance?).
However, since each group of SVG is controlled by three characters, it ultimately only requires trying 27^3 combinations, so I decided to solve it through brute force.
Here, a question arises: how to know if the SVG has reached the correct position?
Some students wanted to use a headless browser for image matching, but this is clearly impractical, as it would be too inefficient and would require isolating each group of SVGs for accurate recognition.
I thought that if I could obtain the top
value of each SVG, I could calculate the height of its transparent area and compare it with the expected height. If they are equal, it indicates that this SVG is in the correct position.
This can be done in a few steps:
- How to get the current SVG's
top
value?
The top
style of the SVG is calculated using calc
, which may initially seem difficult to obtain, but in fact, the browser provides an interface window.getComputedStyle
, which allows you to get the computed style values of elements.
const getCurrentPosByIndex = (index) => {
return window.getComputedStyle(
document
.querySelectorAll('[style*="url(\'data:image/svg+xml;base64,"]')[index])
.top.slice(1,-2) - 0
}
- Where is the transparent area in each SVG?
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="540"><path fill="#fff" d="M0 0H200V540H0ZM2 22V38H198V22Z"/></svg>
The transparent area of the above SVG is M2 22V38H198V22Z
. It is not difficult to see that the upper boundary of this area is 22
, but the left boundary 2
suggests that the developer may want the transparent area to have a 2px
margin, so it is reasonable to guess that the upper boundary of the transparent area should be 20
.
All SVG masks are processed into dataurl format and embedded in the page as inline background styles, which greatly facilitates script writing. We just need to sequentially extract the masks and use regex to match the upper boundary of the transparent area.
[
...document.querySelectorAll('[style*="url(\'data:image/svg+xml;base64,"]'),
].reduce((acc, el) => {
const svg = el.style.backgroundImage
.replace(/^url\("data:image\/svg\+xml;base64,/, "")
.replace(/"\)$/, "");
return acc.push(/ZM\d+\s(\d+)/.exec(atob(svg))[1] - 2), acc;
}, []);
The result is:
[60, 40, 440, 120, 20, 80, 240, 140, 140, 140, 100, 120, 80, 300, 200, 160, 80, 80, 180, 220, 440, 40, 80, 220, 260, 140, 120, 120, 0, 200, 120, 300, 0, 140, 240, 120, 20, 120, 300, 120, 280, 20, 320, 60, 80, 120, 180, 0, 300, 20, 120, 80, 20, 120, 40, 20]
- What should the correct
top
value be for each SVG?
It is important to note that the green text currect!
in the original HTML is located 60px
from the top, and this value also needs to be considered.
[
...document.querySelectorAll('[style*="url(\'data:image/svg+xml;base64,"]'),
].reduce((acc, el) => {
const svg = el.style.backgroundImage
.replace(/^url\("data:image\/svg\+xml;base64,/, "")
.replace(/"\)$/, "");
return acc.push(60 - (/ZM\d+\s(\d+)/.exec(atob(svg))[1] - 2)), acc;
}, []);
We can obtain the required correct top
values for all SVGs:
[0, 20, -380, -60, 40, -20, -180, -80, -80, -80, -40, -60, -20, -240, -140, -100, -20, -20, -120, -160, -380, 20, -20, -160, -200, -80, -60, -60, 60, -140, -60, -240, 60, -80, -180, -60, 40, -60, -240, -60, -220, 40, -260, 0, -20, -60, -120, 60, -240, 40, -60, -20, 40, -60, 20, 40]
We try to set the top
value of each SVG to the correct value, and the green text currect!
appears, indicating that these positions are correct!
Later I thought that getBoundingClientRect().y
could also be used, which was a roundabout way.
- How to change a character in a specific position?
This is the key point, as we need to change a character in a specific position to alter the top
value of the SVG, thereby moving the SVG.
However, this webpage is entirely CSS-based, so directly modifying characters is obviously not feasible.
So, would simulating clicks on the two red up and down arrows work? That won't work either. The webpage has stacked 26 detail tags at each arrow, and by offsetting the details, the red arrow can operate on different details each time it is clicked.
Thus, we can only try to manipulate the detail tag properties directly with JS. When a detail is opened, its element itself will have the open
attribute. We just need to manipulate this attribute to achieve the effect of opening and closing the detail tags.
By observing the pattern, we can find that when the last detail is opened, the character is a
; when the last two details are opened, the character is b
, and so on.
Here is a simple script to change a character in a specific position:
setCharOfSlot(0,'b')
sets the 0
position to b
.
let containers = document.querySelectorAll('[style*="transform:rotate(0deg)"]');
let charMap = "abcdefghijklmnopqrstuvwxyz_";
const setBits = (bits, char) => {
let index = charMap.indexOf(char);
for (let i = 0; i < bits.length; i++) {
if (i === index) {
bits[i].setAttribute("open", "");
} else {
bits[i].removeAttribute("open");
}
}
};
const setCharOfSlot = (slot, char) => {
const container = containers[Math.floor(slot / 3)];
const bits = [...container.children].slice(
(slot % 3) * 26,
((slot % 3) + 1) * 26
);
setBits(bits, char);
};
Next, everything is ready, and we can start trying to iterate! Using requestAnimationFrame to control the iteration speed to avoid lag, while supporting progress saving, it takes about 3 minutes to get the result.
let correctPosition = [
0, 20, -380, -60, 40, -20, -180, -80, -80, -80, -40, -60, -20, -240, -140,
-100, -20, -20, -120, -160, -380, 20, -20, -160, -200, -80, -60, -60, 60,
-140, -60, -240, 60, -80, -180, -60, 40, -60, -240, -60, -220, 40, -260, 0,
-20, -60, -120, 60, -240, 40, -60, -20, 40, -60, 20, 40,
];
const allMasks = document.querySelectorAll(
'[style*="url(\'data:image/svg+xml;base64,"]'
);
const getCurrentPosByIndex = (index) => {
return allMasks[index].offsetTop;
};
let containers = document.querySelectorAll('[style*="transform:rotate(0deg)"]');
let charMap = "abcdefghijklmnopqrstuvwxyz_";
const setCharOfSlot = (slot, char) => {
const container = containers[~~(slot / 3)];
let start = (slot % 3) * 26
let end = ((slot % 3) + 1) * 26
let index = charMap.indexOf(char);
for (let i = start; i < end; i++) {
if (i - start < index) {
container.children[i].setAttribute("open", "");
} else {
container.children[i].removeAttribute("open");
}
}
};
// Generate all possible 3-character combinations using charMap
const allChars = [];let reversedCharMap = "_zyxwvutsrqponmlkjihgfedcba";
for (let i = 0; i < reversedCharMap.length; i++) {
for (let j = 0; j < reversedCharMap.length; j++) {
for (let k = 0; k < reversedCharMap.length; k++) {
allChars.push(reversedCharMap[i] + reversedCharMap[j] + reversedCharMap[k]);
}
}
}
const map2string = (map) => {
// Convert map to json
let json = JSON.stringify([...map]);
return json;
};
const string2map = (str) => {
// Convert json to map
let map = new Map(JSON.parse(str));
return map;
};
let currentCharCase = ~~localStorage.getItem("currentCharCase") || 0;
let _tmp = localStorage.getItem("solvedGroup");
let solvedGroup = _tmp ? string2map(_tmp) : new Map();
let curSolvedGroup = new Set();
let cacheCount = 0;
let first = true;
const bruteForce = () => {
if (
([...solvedGroup.keys()].length === 14 ||
currentCharCase === allChars.length) &&
!first
) {
console.log("done");
return;
}
first = false;
let chars = allChars[currentCharCase];
if (cacheCount++ === 100) {
localStorage.setItem("currentCharCase", currentCharCase);
cacheCount = 0;
}
for (let group = 0; group < 14; group++) {
if (curSolvedGroup.has(group)) continue;
if (solvedGroup.has(group)) {
let char = solvedGroup.get(group);
setCharOfSlot(group * 3, char[0]);
setCharOfSlot(group * 3 + 1, char[1]);
setCharOfSlot(group * 3 + 2, char[2]);
curSolvedGroup.add(group);
continue;
}
let solvedMask = 0;
for (let j = 0; j < 4; j++) {
let currentPos = getCurrentPosByIndex(group * 4 + j);
let correctPos = correctPosition[group * 4 + j];
if (Math.abs(correctPos - currentPos) < 4) {
solvedMask += 1;
}
}
if (solvedMask === 4) {
console.log(
`Group ${group} is solved, chars are "${allChars[currentCharCase - 1]}"`
);
solvedGroup.set(group, allChars[currentCharCase - 1]);
curSolvedGroup.add(group);
localStorage.setItem("solvedGroup", map2string(solvedGroup));
continue;
} else {
setCharOfSlot(group * 3, chars[0]);
setCharOfSlot(group * 3 + 1, chars[1]);
setCharOfSlot(group * 3 + 2, chars[2]);
}
}
currentCharCase++;
requestAnimationFrame(bruteForce);
};
requestAnimationFrame(bruteForce);