今天看到了一个有趣的前端解题,想到了许久之前的淘宝UED趣味题(2012.09.09)腾讯前端特工(2013.11.11),毕竟距离上次玩这个都已经过去七八年了,不由手痒。

看到群里有同学还有困惑,那么抛砖引玉,写一些解题参考吧。

写在前面

在答题过程中,和之前做题模式一样,我会在答题过程中尽量不使用鼠标交互来进行关卡的进入,尽可能使用前端代码来完成关卡切换。

另外,今天距离这套题目公开有一周有余,应该不会破坏传播效果。

那么,开始解题啦。

第一关

https://2021.zoo.team/

第一关实际应该被称之第零关,至于原因,我会在后面的解题步骤中提到。为了方便记述,姑且叫它“第一关”吧。

打开控制台,可以看到提示内容:

下一关地址2021.zoo.team/gate

使用 /gate 作为关键字,搜索源代码,可以看到源码这里实现为 hardcode 硬编码,无法使用脚本进行调用。(其实也可以读取整个脚本内容,然后进行正则解析抽取,懒得搞了)

...
componentDidMount() {
  console.log("\u4e0b\u4e00\u5173\u5730\u5740\uff1a".concat(window.location.host, "/gate")),
                this.getUserDevice()
}
...

那么我们也致敬该团队,“硬编码”前往下一关吧。在控制台输入:

location.href='/gate';

第二关

https://2021.zoo.team/gate

第二关作者使用了一个古老的程序梗“点不到的元素”,用户想要点击到元素,但是一旦元素检测到鼠标“进入”,就会“躲开”。

解题方法有很多种,从 CSS 或者 JS 入手都可以。

从 CSS 入手

可以看到当我们在页面上交互的时候,关卡程序会动态设置“门”的位置,门可以被调整位置到处浪的先决条件是“文档中动态定位的元素”。

那么在控制台执行下面的代码,设置元素为非动态定位即可。

document.querySelector('.zoo-door').style.position='static'

代码执行完毕,门将老老实实待着浏览器窗口左上角,任由你随便点击。

从 JS 事件入手

JS 玩法其实也至少有两种玩法,第一种为中规中矩的方式,和元素交互,模拟用户进行点击。

document.querySelector('.zoo-door-component-left-door').click()
document.querySelector('.zoo-door-component-right-door').click()

第二种玩法则是暴力的计时器方案,查看源代码可知,门能这么“浪”主要靠的是 setState 方法。

this.startGame = ()=>{
    var e = window.event
      , t = e.screenX
      , n = e.screenY;
    while (1) {
        var r = Math.random() * (document.body.clientHeight - 300 + 1)
          , o = Math.random() * (document.body.clientWidth - 450 + 1);
        if (!(t >= o && t <= o + 450 && n >= r && n <= r + 300)) {
            this.setState({
                top: r,
                left: o
            });
            break
        }
    }
}

小学二年级老师教育我们(玩笑),不论是 React 还是 Vue ,考虑自身框架性能,对于设置属性都是有节流的,所以我们只要达到设置位置的执行频率大于这个属性设置的频率就能做到让门始终保持在我们想要的位置了。

比如像下面这样,在控制台中执行,也可以让门的位置固定,任由宰割:

var door=document.querySelector('.zoo-door');
setInterval(()=>{door.style.top=0,door.style.left=0;},10)

用户点击左边或者右两边的门,会分别会进入两个不同的套路中,所以接下来需要分别进行攻略,这里使用下面的代码可以直接进入下一关(“左、右”门不敏感,都可以使用):

location.href = document.querySelector('.zoo-door-clue-text a').href

左门:第三关

https://2021.zoo.team/html/first

来到“左门后”的第三关,发现需要输入密码。

仔细检查当前渲染出的 HTML 代码,看到多了一个 <script> 标签,里面的内容是:

<script>
  var a,b,c,d,e,f,g;
  a = 3.14;
  b = a * 2;
  c = a + b;
  d = c / b + a;
  e = c - d * b + a;
  f = e + d /c -b * a;
  g = f * e - d + c * b + a;
  a = g * g;
  a = Math.floor(a);
</script>

重新执行这段代码,或者在控制台中敲“a”,得到一个数字,密码不言而喻:424178

因为框架使用的是响应式的方式监控输入,所以这里我们图省事,只有手动填写内容“424178”,然后点击提交按钮进行下一步了,不过为了“程序化”,可以在输入密码后,在控制台输入:

document.querySelector('.btn').click()

左门:第四关

https://2021.zoo.team/decrypt

第四关,使用了另外一个老梗,“隐藏文本”。

CTRL+A,可以看到隐藏的“SHA256”的提示,结合控制台中输出的ZooTeam:hey! 你有我的签名吗~,不难联想到我们需要计算这段文本的部分或全部的SHA256值。

前文说到,我们要使用前端的方式来攻略这里,所以这里使用https://geraintluff.github.io/sha256/的 JS 实现方案。

var s = document.createElement('script');s.src="https://geraintluff.github.io/sha256/sha256.min.js";document.body.append(s);

// 等待脚本资源加载完毕后,执行
sha256('ZooTeam');

如果你无法加载 github.io 的资源,也可以使用下面的方式,直接将实现了sha256算法的函数输入到控制台:

var sha256=function a(b){function c(a,b){return a>>>b|a<<32-b}for(var d,e,f=Math.pow,g=f(2,32),h="length",i="",j=[],k=8*b[h],l=a.h=a.h||[],m=a.k=a.k||[],n=m[h],o={},p=2;64>n;p++)if(!o[p]){for(d=0;313>d;d+=p)o[d]=p;l[n]=f(p,.5)*g|0,m[n++]=f(p,1/3)*g|0}for(b+="\x80";b[h]%64-56;)b+="\x00";for(d=0;d<b[h];d++){if(e=b.charCodeAt(d),e>>8)return;j[d>>2]|=e<<(3-d)%4*8}for(j[j[h]]=k/g|0,j[j[h]]=k,e=0;e<j[h];){var q=j.slice(e,e+=16),r=l;for(l=l.slice(0,8),d=0;64>d;d++){var s=q[d-15],t=q[d-2],u=l[0],v=l[4],w=l[7]+(c(v,6)^c(v,11)^c(v,25))+(v&l[5]^~v&l[6])+m[d]+(q[d]=16>d?q[d]:q[d-16]+(c(s,7)^c(s,18)^s>>>3)+q[d-7]+(c(t,17)^c(t,19)^t>>>10)|0),x=(c(u,2)^c(u,13)^c(u,22))+(u&l[1]^u&l[2]^l[1]&l[2]);l=[w+x|0].concat(l),l[4]=l[4]+w|0}for(d=0;8>d;d++)l[d]=l[d]+r[d]|0}for(d=0;8>d;d++)for(e=3;e+1;e--){var y=l[d]>>8*e&255;i+=(16>y?0:"")+y.toString(16)}return i};
sha256('ZooTeam')

两段代码无论选择那段执行,得到的结果都会是“afb111ae9f3569141b2cfa77cf7e1722b10f9bba421a5c939bbcb06d4f1cb812”。

输入答案,会看到下一关入口提示,输入下面的代码,来到第五关。

location.href=document.querySelector('.crypto-go-on').innerText.replace(/.*\//,'/')

左门:第五关

https://2021.zoo.team/rotate

这里默认展示了一个看起来小时候玩的华容道拼图的界面,鼠标戳戳点点发现有的内容可以转动,有的不可以,元素不可移动,结合这关的路径名字“route”,猜测应该是要将所有元素转为正确的角度,完成通关密码的提示图案。

因为一共是九个图片元素,所以这里猜测容器尺寸是不正确的,应该先做调整。

document.querySelector('.zoo-card-box').style="width:348px;height:348px;"

在输入了上面这段代码后,图片展示为了 3x3 的模式,但是因为有的元素不可旋转,所以我们无法得到正确的答案。

源码中包含正确的通关密码,也包含了旋转逻辑,这块对于具体业务实现不感兴趣,直接给出如何旋转为正确答案的代码吧。

var degs = [180,90,270,180,180,270,180,180,90];
Array.from(document.querySelectorAll('.zoo-card-box img')).map((n,i)=>{
	n.style.transform = "rotate("+degs[i]+"deg)";
})

当图片旋转正确,答案也明确的展示到了页面上:“FA4m9YQ”。

输入答案,继续执行下面的代码,前往第六关。

location.href=document.querySelector('.rotate-it-mask a').href

左门:第六关

https://2021.zoo.team/identity

第六关的提示非常明显,需要我们进行 Cookie 设置,使用第二关提示中的 Cookie 进行设置即可。

document.cookie="user=ZooTeam; path=/";

至此,左门后的谜题就都攻略完毕了,接下来试试右边的门后的内容吧。

右门:第三关

https://2021.zoo.team/html/error

这个页面打开后,展示了一个标准的“ 404 ”,检查渲染后的 DOM 结构后可以看到,有一个样式被设置为 display: none;<p> 标签中包含了下一关的地址。

控制台直接执行下面的代码即可:

location.href = document.querySelector('p').innerText.replace(location.host,'')

右门:第四关

https://2021.zoo.team/decrypt\_2

打开这关页面后,会看到醒目的“Any application that can be written in JavaScript, will eventually be written in JavaScript”。

在控制台中会看到一行输出提示“Atwood:ayplctotacnerteijvsrpwleetalbwitnnaacitnapiainhtabwitnnaacitilvnulyerteijvsrp”。

继续检查 DOM 元素会看到 <HTML> 标签上默认属性写着“请搜索:Rail-fence Cipher”,以及一个被设置隐藏,但是类名上写着需要我们解密的文本字符串。

<div class="you-find-it it-looks-like-a-ciphertext hide">
nciyurraighsomshvwreototdcyttogauainhnxlvlsaiaidxiefoaeedntiyuutaeokduhwoerpicnrtltoteeteeimgclne
</div>

对“Rail-fence Cipher”密文进行加解密,可以选择使用在线工具 http://www.online.crypto-it.net/eng/rail-fence.html

或者下面的代码对字符串进行分析和“解密”:

// 修改自 http://0x2013.blogspot.com/2013/10/rail-fence-cipher-html-javascript.html
function encode(input, lineCount) {
  const maxLineNumber = lineCount - 1;
  let text = input.toLowerCase().replace(/[^a-z]/g, "");
  let result = "";
  for (let line = 0; line < maxLineNumber; line++) {
    let skip = 2 * (maxLineNumber - line);
    for (let i = line, j = 0; i < text.length; j++) {
      result += text.charAt(i);
      if (line == 0 || j % 2 == 0) {
        i += skip;
      } else {
        i += 2 * maxLineNumber - skip;
      }
    }
  }
  for (i = line; i < text.length; i += 2 * maxLineNumber)
    result += text.charAt(i);
  return result;
}

function decode(input, lineCount) {
  const text = input.toLowerCase().replace(/[^a-z]/g, "");
  const maxLineNumber = lineCount - 1;
  var result = new Array(text.length);
  let k = 0;
  for (let line = 0; line < maxLineNumber; line++) {
    let skip = 2 * (maxLineNumber - line);
    for (let i = line, j = 0; i < text.length; j++) {
      result[i] = text.charAt(k++);
      if (line == 0 || j % 2 == 0) {
        i += skip;
      } else {
        i += 2 * maxLineNumber - skip;
      }
    }
  }
  for (i = line; i < text.length; i += 2 * maxLineNumber)
    result[i] = text.charAt(k++);

  return result.join("");
}

分别执行下面的代码,可以确认加解密算法和“KEY”长度是有效的。

encode("Any application that can be written in JavaScript, will eventually be written in JavaScript", 2);
// 获得和控制台一致的密文
"ayplctotacnerteijvsrpwleetalbwitnnaacitnapiainhtabwitnnaacitilvnulyerteijvsrp"

decode("ayplctotacnerteijvsrpwleetalbwitnnaacitnapiainhtabwitnnaacitilvnulyerteijvsrp", 2);
// 获得原文的“去空格版”
"anyapplicationthatcanbewritteninjavascriptwilleventuallybewritteninjavascript"

继续执行下面的代码,获取下一关的进一步提示:

decode(document.querySelector('.it-looks-like-a-ciphertext').innerText, 2)

"niceifyouarereadingthisyoumusthaveworkedouthowtodecryptitcongratulationthenextlevelismagicalindex"

将字符串适当添加空格后,得到提示文本:

nice if you are reading this you must have worked out how to decrypt it congratulation the next level is magicalindex

最后在控制台执行代码,进入下一关:

location.href="/magicalindex"

右门:第五关

https://2021.zoo.team/magicalindex

直接在控制台中检查 DOM 元素会看到下面的内容:

<div class="container___3lZea">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="img___49vkp" alt="img">
    <img src="..." class="question___20202" alt="img">
</div>

而界面中仅展示了九个元素,结合关卡名称,判断第十个元素可能是线索。继续进行分析,会看到样式规则很有意思,刻意将这个元素的 z-index 设置为了负数。

.question___20202 {
    width: 266px;
    height: 180px;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%,-50%);
    z-index: -2;
}

使用控制台代码修改这条样式规则,可以看到通关提示:eikooC。

document.querySelector('.question___20202').style.zIndex=1

在控制台中输入代码,看到默认 cookie 中有一条名为 nextUrl 的设置内容:

document.cookie

"nextUrl=/level/5"

既然得到了下一关的提示,在控制台中执行下面的代码即可:

location.href=document.cookie.replace(/.*=/,'')

右门:第六关

https://2021.zoo.team/level/5

打开控制台,发现默认有一个接口请求报错。

POST https://2021.zoo.team/api/zoo/key 405
Uncaught (in promise) Error: Request failed with status code 405
    at e.exports (umi.js:1)
    at HrI/.e.exports (umi.js:1)
    at XMLHttpRequest.d.onreadystatechange (umi.js:1)

在控制台网络面板中检查该请求细节,会看到相应内容为:

{"success":false,"result":"未 GET 你的请求~"}

这里如此刻意,显然是提示我们请求方法不正确,使用 GET 方式再次请求接口。

fetch('https://2021.zoo.team/api/zoo/key', {method:'GET'}).then(r=>r.json()).then(r=>console.log(r))

提示果然和预期中一样,有了变化。

{success: false, result: "请继续寻找 key~"}

根据提示将第二关的内容拼合到请求中。

fetch('https://2021.zoo.team/api/zoo/key?code=86749',{method:'GET'}).then(r=>r.json()).then(r=>console.log(r))

发现结果没有变化,点击页面提示按钮,看到原来需要两个参数。检查之前页面默认发出的请求,看到请求头中有一个请求特别可疑:key: ZooTeam

将这个内容拼在请求地址上:

fetch('https://2021.zoo.team/api/zoo/key?code=86749&key=ZooTeam',{method:'GET'}).then(r=>r.json()).then(r=>console.log(r))

服务端输出了我们期待已久的结果:

{success: true, result: "https://2021.zoo.team/ending"}

控制台输入最后的代码,再次前往通关页面。

fetch('https://2021.zoo.team/api/zoo/key?code=86749&key=ZooTeam',{method:'GET'}).then(r=>r.json()).then(r=>location.href=r.result);

真实的第五关

在第一关的时候,我曾说第一关实际是第零关,原因是这个第六关的路由地址是“/level/5”。或许是作者在生成的程序的时候,做了顺序调整,但是更大的可能性是,在做这个游戏的最初,计数方式就是符合程序员“从零开始”的经典套路,而这个路由就是佐证之一。

通关

通关成功

通关页面好像没有什么特别的,基本都是招聘信息和团队介绍云云,这里略。

其他:作弊方案

经常看代码的同学会发现这个程序基本由“纯前端”方案构成,所以一切的秘密都在源代码中,打开页面引用的脚本,搜索刚刚题目中的路由,不难找到下面一段“路由定义”。

var r = n("xYkL")
    , o = n("bCY9")
    , i = [{
    path: "/",
    component: n("QeBL").default,
    exact: !0
}, {
    path: "/ending",
    component: n("NoKT").default,
    exact: !0
}, {
    path: "/gate",
    component: n("+xzV").default,
    exact: !0
}, {
    path: "/decrypt",
    component: n("7kX6").default,
    exact: !0
}, {
    path: "/decrypt_2",
    component: n("4ZDs").default,
    exact: !0
}, {
    path: "/rotate",
    component: n("zRzk").default,
    exact: !0
}, {
    path: "/level/5",
    component: n("lsGM").default,
    exact: !0
}, {
    path: "/identity",
    component: n("l2Te").default,
    exact: !0
}, {
    path: "/magicalindex",
    component: n("LAmj").default,
    exact: !0
}, {
    path: "/html/first",
    component: n("ypg8").default,
    exact: !0
}, {
    path: "/html/error",
    component: n("f+ft").default,
    exact: !0
}];
o["a"].applyPlugins({
    key: "patchRoutes",
    type: r["ApplyPluginsType"].event,
    args: {
        routes: i
    }
})

代码中的 “Path” 字段早已将所有的地址告诉了浏览器,和我们。

最后

突然很怀念那段单纯的写前端程序的日子,有些怀念杭州的小伙伴,淘宝的战友们。

谢谢政采云团队,虽然游戏还是有些简单,但是还是带来了一段愉悦的时光。

–EOF