{
"title": "Plaid CTF Writeup [Treasure Map/CSS]",
"tags": [
"post",
"CTF",
"技术",
"前端"
],
"summary": "这两道题真是太有趣了!虽然标签是逆向,但是以前端为载体,有很多JS/CSS奇淫巧计,我已经迫不及待地想要和大家分享了。 Treasure Map\n\n题目地址:http://treasure.chal.pwni.ng/\n\nReady your masts…",
"sources": [
"xlog"
],
"external_urls": [
"https://daidr.xlog.app/pctf-writeup-2023"
],
"date_published": "2023-04-17T05:13:43.613Z",
"content": "这两道题真是太有趣了!虽然标签是逆向,但是以前端为载体,有很多JS/CSS奇淫巧计,我已经迫不及待地想要和大家分享了。\n\n## Treasure Map\n\n> 题目地址:http://treasure.chal.pwni.ng/\n>\n> Ready your masts and set sail! Thar be treasure here if we can figure out how to find it.\n>\n> **Buried Treasure**\n>\n> Follow the map and get the booty — a pirate's work is never done.\n\n### 题目\n\n这道题包含了success.js、fail.js和 0.js ~ 199.js 共两百个一模一样的 js 文件(引用的SourceMap有所不同)。\n\n0.js\n\n```js\nconst b64 = `\nA\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ\na\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n+\n/\n=`;\nexport const go = async () => {\n const bti = b64.trim().split(\"\\n\").reduce((acc, x, i) => (acc.set(x, i), acc), new Map());\n const upc = window.buffer.shift();\n const moi = await fetch(import.meta.url).then((x) => x.text())\n const tg = await fetch(moi.slice(moi.lastIndexOf(\"=\") + 1)).then((x) => x.json())\n 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];\n import(`./${fl}`).then((x) => x.go());\n}\n//# sourceMappingURL=0.js.map\n```\n\nfail.js\n```js\nexport const go = () => {\n document.querySelector(\".frame\").classList.add(\"fail\");\n}\n```\n\nsuccess.js\n```\nimport { go as fail } from \"./fail.js\";\nexport const go = () => {\n if (window.buffer.length !== 0) {\n fail();\n } else {\n document.querySelector(\".frame\").classList.add(\"success\");\n }\n}\n```\n\n大概的校验流程是这样的:\n\n- 输入一个有效格式的Flag\n- 存入 window.buffer\n- 调用`go()`方法 (这个方法在上述202个脚本中均存在,网页默认引用了 0.js,所以执行 0.js 里的`go()`方法)\n- 通过**某种算法**找到这202个脚本中的另一个进行载入,执行其中的`go()`方法\n\n### 分析\n\n最终的目的是让脚本能够载入 `success.js` 并执行。但是所有脚本的内容都是一样的,可能需要从SourceMap下手。\n\n```json\n{\n \"version\": 3,\n \"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\"],\n \"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\"\n}\n```\n\n上面是 0.js 的 SourceMap,对当前源码进行了详细的映射,那么具体映射了什么呢?\n\n> SourceMap中的 `mappings` 包含VLQ编码,分号用于表示文件行,逗号表示位置,VLQ编码的部分是一个可变长数组,代表了映射所需的各个增量,具体可以参考文章 http://ruanyifeng.com/blog/2013/01/javascript_source_map.html \n\n根据SourceMap的映射规则,脚本的2-66行(即b64变量的内容)被分别映射到不同的66个文件中,举个简单的例子,0.js的映射关系大概是这样的:\n\n```json\n[[\"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\",\"/\"]]\n```\n\n而0.js-199.js中的代码部分,实际上就是在对SourceMap进行解析,从传入的flag依次取出字符,对应到特定的js文件。\n\n例如对于一个B开头的flag,就会去请求118.js,解析118.js的SourceMap,并处理flag的第二个字符,以此类推。\n\n我们可以尝试去寻找哪个文件包含对 success.js 的映射,这样就可以确定 Flag 的最后一个字符和其对应文件,一步一步反推就能得到最终的Flag。\n\n### 相关脚本\n\n#### 下载文件\n\n首先当然是要把所有 SourceMap 给下载下来,这里提供一个 NodeJS 脚本\n\n```js\nasync function download() {\n const fs = require(\"fs\");\n\n for (let i = 0; i < 200; i++) {\n const url = `http://treasure.chal.pwni.ng/${i}.js.map`;\n console.log(url);\n const data = await fetch(url).then((res) => res.text());\n fs.writeFileSync(`./originmaps/${i}.js.map`, data);\n }\n}\n\ndownload();\n```\n\n脚本会将所有的 SourceMap 下载到 originmaps 文件夹中。(记得提前创建文件夹)\n\n#### 解析 SourceMap\n\n稍微修改一下题目给的 js,解析SourceMap,并将映射表保存到文件中。\n\n```js\n// char2js.js\nconst fs = require(\"fs\").promises;\nconst { VLQDecode, getSource, JS_SOURCE } = require(\"./utils.js\");\n\n(async function () {\n // 遍历 originmaps 所有文件\n const maps = await fs.readdir(\"./originmaps\");\n for (const map of maps) {\n // console.log(map);\n // 读取文件内容\n const content = JSON.parse(\n await fs.readFile(`./originmaps/${map}`, \"utf-8\")\n );\n let _map = getCharFileMap(content);\n // 写入到 char2js 文件夹\n await fs.writeFile(`./char2js/${map}`, JSON.stringify(_map));\n }\n})();\n\nconst getCharFileMap = (content) => {\n const source = JS_SOURCE;\n\n const lines = content.mappings.split(\";\");\n\n const fl = lines\n .flatMap((item, index) => {\n // 位置切分\n const pos = item.split(\",\").filter((x) => !!x);\n\n // 解码\n const decodedPos = pos.map((input) => VLQDecode(input));\n\n return decodedPos\n .map(([c, s, ol, oc, n]) => [\n index,\n c,\n s ?? 0,\n ol ?? 0,\n oc ?? 0,\n n ?? 0,\n ])\n .reduce(\n (acc, e, i) => [\n ...acc,\n [index, e[1] + (acc[i - 1]?.[1] ?? 0), ...e.slice(2)],\n ],\n []\n );\n })\n .reduce(\n (acc, e, i) => [\n ...acc,\n [\n ...e.slice(0, 2),\n ...e.slice(2).map((x, c) => x + (acc[i - 1]?.[c + 2] ?? 0)),\n ],\n ],\n []\n )\n .map(([l, c, s, ol, oc, n], i, ls) => [\n getSource(s),\n source\n .split(\"\\n\")\n .slice(l, ls[i + 1] ? ls[i + 1]?.[0] + 1 : undefined)\n .map((x, ix, nl) =>\n ix === 0\n ? l === ls[i + 1]?.[0]\n ? x.slice(c, ls[i + 1]?.[1])\n : x.slice(c)\n : ix === nl.length - 1\n ? x.slice(0, ls[i + 1]?.[1])\n : x\n )\n .join(\"\\n\")\n .trim(),\n ]);\n\n return fl;\n};\n```\n\n该文件使用了utils.js,可以在这里下载:https://ipfs.4everland.xyz/ipfs/QmSDubw4sHg25kSjzzQu9aotV52bbbRp9nZbxog5KcbfDX\n\n#### 寻找正确的加载路径\n\n```js\nconst fs = require(\"fs\").promises;\nconst jsMaps = {};\nfunction calcPath(curFlagPath, currentJsCursor) {\n if (currentJsCursor == \"0.js\") {\n console.log(curFlagPath.reverse().join(\"\"));\n return;\n }\n for (let map of Object.keys(jsMaps)) {\n let flag = false;\n for (const [file, char] of jsMaps[map]) {\n if (file == currentJsCursor) {\n if(currentJsCursor === map) flag = true;\n flag = true\n let _curFlagPath = [...curFlagPath];\n _curFlagPath.push(char);\n currentJsCursor = map.replace(\".map\", \"\");\n calcPath(_curFlagPath, currentJsCursor);\n }\n }\n if (flag) break;\n }\n}\n\n(async function () {\n // 遍历 char2js 所有文件\n const maps = await fs.readdir(\"./char2js\");\n for (const map of maps) {\n // 读取文件内容\n const content = JSON.parse(await fs.readFile(`./char2js/${map}`, \"utf-8\"));\n jsMaps[map] = content;\n }\n\n calcPath([], \"success.js\");\n})();\n```\n\n#### 错了🤯🤯\n\n最终我们能得到一个23位的Flag: `Nd+a+map/How+about+200!`,但是题目要求25位。说明中间可能会存在多条路径,下面的脚本是柏喵改进的,tql\n\n```js\nconst fs = require(\"fs\").promises;\nconst jsMaps = {};\nlet final = [];\n(async function () {\n // 遍历 char2js 所有文件\n const maps = await fs.readdir(\"./char2js\");\n for (const map of maps) {\n // 读取文件内容\n const content = JSON.parse(await fs.readFile(`./char2js/${map}`, \"utf-8\"));\n jsMaps[map] = content;\n }\n\n let currentJsCursor = [{\"cur\":\"success.js\",\"path\":[]}];\n let nextJsCursor = [];\n for (i = 0; i < 25; i++) {\n for (let map of Object.keys(jsMaps)) {\n for (const [file, char] of jsMaps[map]) {\n currentJsCursor.filter((item) => item.cur === file)\n .forEach(\n (item) => nextJsCursor.push({cur:map.replace('.map',''),path:[...item.path,char]}\n ));\n }\n }\n currentJsCursor = nextJsCursor.reduce((acc, cur) => {\n if (acc.findIndex((item) => item.cur === cur.cur) === -1) {\n acc.push(cur);\n }\n return acc;\n }, []);\n nextJsCursor = [];\n }\n currentJsCursor.filter((item) => item.cur === \"0.js\").forEach((item) => console.log(item.path.reverse().join(\"\"))); \n})();\n```\n\n之前错误 Flag 的路径是 `0.js`->`137.js`->`160.js`->`192.js`->... (Nd+a...)\n\n正确 Flag 的路径是 `0.js`->`137.js`->`23.js`->`137.js`->`160.js`->`192.js`->... (Need+a...)\n\n得到正确的 Flag `PCTF{Need+a+map/How+about+200!}`\n\n## CSS\n\n> 题目地址:https://plaidctf.com/files/css.74486b61b22e49b3d8c5afebee1269e37b50071afbf1608b8b4563bf8d09ef92.html\n> \n> 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.\n\n![image](ipfs://bafkreie5rw5nqflgpt7fodmgiv5mhvaxzmcjpj36k4ht5kb7lyov6tgjmi)\n\n这道题是一个完全由CSS构成的密码锁。前天我就[发了推](https://twitter.com/imdaidr/status/1647306484542500864)吐槽这道题的CSS样式。(能整出这种活的人真是太牛了)\n\n### 分析\n\nFlag 包含小写字母`a`-`z`以及下划线`_`,以三个字符为一组,分成了 14 组共 42 位。\n\n字符的选择是通过 details 标签来实现的,details 的子元素拥有不同的高度,使用 css 的`calc`函数来获取高度并运算,得到字符元素的偏移量,达到显示字符的目的。\n\n每3个字符会控制4个SVG蒙版。拿了8个来举例子:\n\n![image](ipfs://bafkreianpukfpksy3qa6mu2r4xf65r76dl3v6bikfakk3zqskhd75gpidy)\n\n灰色部分是每个SVG透明的位置,倘若每个SVG的位置正确,最终应该是这个效果:\n\n![image](ipfs://bafkreidk7n6xvu6gem3ytknhapdbrfxotk5tbef5h4vqjjm3ruczpltrwe)\n\n由于details的伸缩与展开会影响到父容器高度,SVG蒙版的父元素也在这个容器中,高度也会发生改变,而SVG的 `top` 属性通过父元素高度计算得来。\n\n庆幸的是,SVG以dataurl的方式作为背景图片,但是这个元素的高度是固定的,所以不需要考虑背景的各种填充方式(`cover`/`contain`等),换句话说,想要解出正确的Flag,SVG 最终的 `top` 值一定只和其透明位置的高度有关。\n\n### 解题\n\n我没有去看各个 detail 标签和 SVG 容器的高度是如何变化的,这实在太多了(或许可以尝试使用 Typed OM 辅助分析?)\n\n不过每组SVG只由3个字符控制,也就是最终只需要尝试27^3种情况,决定直接通过暴力遍历的方式来解决。\n\n这里就有一个问题:如何知道SVG已经到了正确的位置?\n\n同学想用无头浏览器进行图像匹配,这显然是行不通的,效率太低,且需要对每组SVG进行隔离才能正确识别。\n\n我想到的是,如果能够获取到每个SVG的`top`值,那么就可以通过计算得到其透明位置的高度,然后与预期的高度进行比较,如果相等,那么就说明这个SVG已经到了正确的位置。\n\n这其实很好办,分成下面几步:\n\n1. 如何拿到当前SVG的 `top` 值?\n\nSVG 的 `top` 样式是通过 `calc` 计算得来的,可能一开始会觉得很难获取,但实际上,浏览器提供了接口 `window.getComputedStyle`,通过这个接口,能够得到元素计算之后的样式数值。\n\n```js\nconst getCurrentPosByIndex = (index) => {\n return window.getComputedStyle(\n document\n .querySelectorAll('[style*=\"url(\\'data:image/svg+xml;base64,\"]')[index])\n .top.slice(1,-2) - 0\n}\n```\n\n2. 每个SVG中透明区域的位置在哪?\n\n```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"540\"><path fill=\"#fff\" d=\"M0 0H200V540H0ZM2 22V38H198V22Z\"/></svg>\n```\n\n上面这个SVG的透明区域是`M2 22V38H198V22Z`,不难看出,这个区域的上边界是 `22`,不过由左边界 `2` 可以看出,开发者可能希望透明区域具有 `2px` 的边距,大胆猜测,透明区域的上边界应该是 `20`。\n\n所有SVG蒙版都被处理成 dataurl ,以内联背景样式的形式嵌入到页面中,这也为编写脚本提供了极大的便利。只需要按次序取出蒙版,然后使用正则匹配出透明区域的上边界即可。\n\n```js\n[\n ...document.querySelectorAll('[style*=\"url(\\'data:image/svg+xml;base64,\"]'),\n].reduce((acc, el) => {\n const svg = el.style.backgroundImage\n .replace(/^url\\(\"data:image\\/svg\\+xml;base64,/, \"\")\n .replace(/\"\\)$/, \"\");\n return acc.push(/ZM\\d+\\s(\\d+)/.exec(atob(svg))[1] - 2), acc;\n}, []);\n```\n\n得到的结果是:\n\n```js\n[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]\n```\n\n3. 每个SVG最终正确的 `top` 值应该是多少?\n\n需要注意的是:原HTML中绿色 `currect!` 字样在距离顶部 `60px` 处,这个值也需要考虑进去。\n\n```js\n[\n ...document.querySelectorAll('[style*=\"url(\\'data:image/svg+xml;base64,\"]'),\n].reduce((acc, el) => {\n const svg = el.style.backgroundImage\n .replace(/^url\\(\"data:image\\/svg\\+xml;base64,/, \"\")\n .replace(/\"\\)$/, \"\");\n return acc.push(60 - (/ZM\\d+\\s(\\d+)/.exec(atob(svg))[1] - 2)), acc;\n}, []);\n```\n\n可以得到所有SVG最终所需的正确 `top` 值:\n\n```js\n[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]\n```\n\n我们尝试将每个 SVG 的 `top` 值设置为正确的值,显示出了绿色的 `currect!` 字样,说明这些位置是正确的!\n\n~~后来想想其实`getBoundingClientRect().y`也能拿到,绕了个大弯~~\n\n4. 如何改变某一位字符?\n\n这是最关键的,因为我们需要通过改变某一位字符来改变SVG的 `top` 值,从而达到移动SVG的目的。\n\n然而这个网页完全由CSS实现,想直接修改字符当然是行不通的。\n\n那么模拟点击两个红色上下箭头能行么?也不行。网页在每个箭头处堆叠了26个detail标签,通过给detail设置偏移来实现红色箭头位置在每次点击时都能操作到不同的detail。\n\n所以,只能尝试直接用js去操作detail标签属性。当detail打开时,其元素本身会具有 `open` 属性。我们只需要操纵这个属性,就能实现打开和关闭detail标签的效果。\n\n寻找规律能发现,倒数第一个 detail 打开时,字符为 a;倒数后二个 detail 打开时,字符为 b,以此类推。\n\n下面是一个简单的脚本,用于改变某一位字符:\n\n`setCharOfSlot(0,'b')` 即将第`0`位设置为`b`\n\n```js\nlet containers = document.querySelectorAll('[style*=\"transform:rotate(0deg)\"]');\nlet charMap = \"abcdefghijklmnopqrstuvwxyz_\";\n\nconst setBits = (bits, char) => {\n let index = charMap.indexOf(char);\n for (let i = 0; i < bits.length; i++) {\n if (i === index) {\n bits[i].setAttribute(\"open\", \"\");\n } else {\n bits[i].removeAttribute(\"open\");\n }\n }\n};\n\nconst setCharOfSlot = (slot, char) => {\n const container = containers[Math.floor(slot / 3)];\n const bits = [...container.children].slice(\n (slot % 3) * 26,\n ((slot % 3) + 1) * 26\n );\n\n setBits(bits, char);\n};\n```\n\n接下来,万事俱备,可以开始尝试遍历了!使用 requestAnimationFrame 来控制遍历速度避免卡顿,同时支持了进度保存,跑出结果大概需要 3 分钟。\n\n```js\nlet correctPosition = [\n 0, 20, -380, -60, 40, -20, -180, -80, -80, -80, -40, -60, -20, -240, -140,\n -100, -20, -20, -120, -160, -380, 20, -20, -160, -200, -80, -60, -60, 60,\n -140, -60, -240, 60, -80, -180, -60, 40, -60, -240, -60, -220, 40, -260, 0,\n -20, -60, -120, 60, -240, 40, -60, -20, 40, -60, 20, 40,\n];\n\nconst allMasks = document.querySelectorAll(\n '[style*=\"url(\\'data:image/svg+xml;base64,\"]'\n);\n\nconst getCurrentPosByIndex = (index) => {\n return allMasks[index].offsetTop;\n};\n\nlet containers = document.querySelectorAll('[style*=\"transform:rotate(0deg)\"]');\nlet charMap = \"abcdefghijklmnopqrstuvwxyz_\";\n\nconst setCharOfSlot = (slot, char) => {\n const container = containers[~~(slot / 3)];\n\n let start = (slot % 3) * 26\n let end = ((slot % 3) + 1) * 26\n let index = charMap.indexOf(char);\n for (let i = start; i < end; i++) {\n if (i - start < index) {\n container.children[i].setAttribute(\"open\", \"\");\n } else {\n container.children[i].removeAttribute(\"open\");\n }\n }\n};\n\n// 使用 charMap 生成 3 位的所有可能\nconst allChars = [];let reversedCharMap = \"_zyxwvutsrqponmlkjihgfedcba\";\nfor (let i = 0; i < reversedCharMap.length; i++) {\n for (let j = 0; j < reversedCharMap.length; j++) {\n for (let k = 0; k < reversedCharMap.length; k++) {\n allChars.push(reversedCharMap[i] + reversedCharMap[j] + reversedCharMap[k]);\n }\n }\n}\n\nconst map2string = (map) => {\n // map转json\n let json = JSON.stringify([...map]);\n return json;\n};\n\nconst string2map = (str) => {\n // json转map\n let map = new Map(JSON.parse(str));\n return map;\n};\n\nlet currentCharCase = ~~localStorage.getItem(\"currentCharCase\") || 0;\nlet _tmp = localStorage.getItem(\"solvedGroup\");\nlet solvedGroup = _tmp ? string2map(_tmp) : new Map();\nlet curSolvedGroup = new Set();\nlet cacheCount = 0;\nlet first = true;\n\nconst bruteForce = () => {\n if (\n ([...solvedGroup.keys()].length === 14 ||\n currentCharCase === allChars.length) &&\n !first\n ) {\n console.log(\"done\");\n return;\n }\n first = false;\n let chars = allChars[currentCharCase];\n if (cacheCount++ === 100) {\n localStorage.setItem(\"currentCharCase\", currentCharCase);\n cacheCount = 0;\n }\n\n for (let group = 0; group < 14; group++) {\n if (curSolvedGroup.has(group)) continue;\n if (solvedGroup.has(group)) {\n let char = solvedGroup.get(group);\n setCharOfSlot(group * 3, char[0]);\n setCharOfSlot(group * 3 + 1, char[1]);\n setCharOfSlot(group * 3 + 2, char[2]);\n curSolvedGroup.add(group);\n continue;\n }\n let solvedMask = 0;\n for (let j = 0; j < 4; j++) {\n let currentPos = getCurrentPosByIndex(group * 4 + j);\n let correctPos = correctPosition[group * 4 + j];\n\n if (Math.abs(correctPos - currentPos) < 4) {\n solvedMask += 1;\n }\n }\n if (solvedMask === 4) {\n console.log(\n `Group ${group} is solved, chars are \"${allChars[currentCharCase - 1]}\"`\n );\n solvedGroup.set(group, allChars[currentCharCase - 1]);\n curSolvedGroup.add(group);\n localStorage.setItem(\"solvedGroup\", map2string(solvedGroup));\n continue;\n } else {\n setCharOfSlot(group * 3, chars[0]);\n setCharOfSlot(group * 3 + 1, chars[1]);\n setCharOfSlot(group * 3 + 2, chars[2]);\n }\n }\n currentCharCase++;\n requestAnimationFrame(bruteForce);\n};\n\nrequestAnimationFrame(bruteForce);\n```\n\n![image](ipfs://bafkreieskixf5udraudorsyenpgc6upv64u4zpnmnx3pxidv2tmt4j2474)",
"attributes": [
{
"value": "pctf-writeup-2023",
"trait_type": "xlog_slug"
}
]
}