Hello Navi

Tech, Security & Personal Notes

Challenge

Captain Kirk has coded this Perl script for all his fellow-captains to automate their logging. This way they don't have to record their logs on tape, but they can type them in and archive them. But this log only seems to log one log?! It automatically deletes all previous logs! Fix the script for him, so they can keep their logs again! Captain Kirk 给同僚写了一个自动记日志的 Perl 脚本,但每次只留下一条日志, 之前的全被删掉;把它修好,让日志能留存下来。

关卡页给出完整脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/perl
# Captain Kirk has coded this Perl script for all his fellow-captains
# to automate their logging.
# This way they don't have to record their logs on tape, but they can type them in
# and archive them. But this log only seems to log one log?!
# It automatically deletes all previous logs! Fix the script for him,
# so they can keep their logs again!
print '> Hello Captain ' . $ENV{'USER'} . '.' . "\n";
open(STARTREKLOG, '>/var/log/startrek');
print '> Please enter your log data here, end with a "." on a single line.' . "\n";
my $LogText;
print '> ';
while (<STDIN>) {
unless ($_ ne '.' . "\n") {
last;
}
$LogText .= $_;
print '> ';
}
print '> Log is being saved to /var/log/startrek' . "\n";
$DateTime = localtime();
print STARTREKLOG ' -- START OF LOG -- ' . "\n";
print STARTREKLOG 'Date/Time: ' . $DateTime . "\n";
print STARTREKLOG 'Log : ' . $LogText;
print STARTREKLOG ' -- END OF LOG -- ' . "\n";
die('> Log saved! Now exiting.' . "\n");

Solution

脚本把交互内容累加进 $LogText,最后用四条 print STARTREKLOG 落盘:

1
2
3
4
print STARTREKLOG ' -- START OF LOG -- ' . "\n";
print STARTREKLOG 'Date/Time: ' . $DateTime . "\n";
print STARTREKLOG 'Log : ' . $LogText;
print STARTREKLOG ' -- END OF LOG -- ' . "\n";

写入语句本身没有问题,本次要记的内容也完整。决定上一次的日志还在不在的是更早的那一行:

1
open(STARTREKLOG, '>/var/log/startrek');

Perl 的两参数 open 把模式写在文件名前面:< 读、> 写、>> 追加。>截断写:打开时先把文件长度清零,文件指针回到开头,于是每次运行都从空文件开始,上一次的内容在这次运行的第一条 print 之前就没了。题面说的 It automatically deletes all previous logs 正是这个行为。

open(STARTREKLOG, >>/var/log/startrek);

Challenge

Bill Gates wrote a Perl script that grants access to the company records; it has a security flaw that lets everyone in. Fix the flaw.

Bill Gates 写了个 Perl 脚本,用来校验访问者有没有权限读取公司记录;这段脚本存在安全缺陷,任何人都能拿到记录。要求把缺陷修掉。

1
2
3
4
5
6
7
8
#!/usr/bin/perl
chomp ( my $User = `/usr/bin/whoami` ) ;
print "Checking your access level...\n" ;
if ( $User == 'BillGates' ) {
print "Authorized! Here are the company records:\n" . `cat /home/BillGates/CompanyRecords.db` ;
die ( "Closing...\n" ) ;
}
die ( "You're not authorized!\n" ) ;

Solution

脚本的逻辑很短:用反引号执行 /usr/bin/whoami 拿到当前用户名存进 $User,然后只有一个 if 决定是否读取 /home/BillGates/CompanyRecords.db。要修好缺陷,改动点必然落在这个比较上;其它行(chompprintdie)都只是输出,不参与授权判断。

Perl 有两套比较运算符,不能混用:

  • ==!=<>数值比较,会先把两侧操作数转成数字;
  • eqneltgt字符串比较,逐字符对比。

脚本用的是 $User == 'BillGates'。两侧都是字符串,却在走数值比较。用 Perl 直接复现这个语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/perl
# Local reproduction of the ExtBasic 8 flaw (run against our own Perl, no
# network). A non-privileged user name stands in for the whoami output.
use strict;
use warnings;

my $User = "some_user"; # what `/usr/bin/whoami` would return

my $target = "BillGates";

print "user : $User\n";
print "target : $target\n";
print "num(user) : ", $User + 0, "\n";
print "num(target): ", $target + 0, "\n";
printf "flawed \$User == \$target -> %s\n", ($User == $target) ? "TRUE" : "FALSE";
printf "fixed \$User eq \$target -> %s\n", ($User eq $target) ? "TRUE" : "FALSE";
1
2
3
4
5
6
7
8
9
$ perl -w perl_demo.pl
Argument "some_user" isn't numeric in addition (+) at perl_demo.pl line 13.
Argument "BillGates" isn't numeric in addition (+) at perl_demo.pl line 14.
user : some_user
target : BillGates
num(user) : 0
num(target): 0
flawed $User == $target -> TRUE
fixed $User eq $target -> FALSE

Perl 把非数字开头的字符串转成数字时取前导数字部分,没有前导数字就是 0(同时抛 isn't numeric 警告)。whoami 返回的是用户名,BillGates 也是裸字符串,两者的数值转换结果都是 0

授权条件退化成 0 == 0,恒为真。任何用户名(rootnobody、普通用户都一样)都会走进 if 分支,打印记录再 die("Closing...")。真正的字符串判定应该用 eq'some_user' eq 'BillGates' 为假,只有用户名恰好是 BillGates 时才通过。

if ($User eq BillGates)

Challenge

修正一个 PHP 页面里同时带有 bug 和漏洞的那一行,提交修正后的整行。

关卡页给出的是一段数据录入代码,要求:There is only one line that has a vuln, correct it. The output does not have to be valid XHTML and assume that a mysql connection has been made already. There is a bug as well as a vuln. You MUST fix both.

1
2
3
4
5
6
7
8
9
10
11
<?php
if(!empty($_POST['data']))
{
$data = mysql_real_escape_string($_POST['data']);
mysql_query("INSERT INTO tbl_data (data) VALUES ('$data')");
}
?>
<form name="grezvahfvfnjuvavatovgpu" action="<?=$_SERVER['PHP_SELF']?>" method="get">
<input type="text" name="data" />
<input type="submit" />
</form>

Solution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#!/usr/bin/env python3
"""HackThisSite Extended Basic 7 (playit) live solver.

The level prints a tiny PHP page and asks for the one line that carries both a
bug and a vulnerability:

<form name="grezvahfvfnjuvavatovgpu" action="<?=$_SERVER['PHP_SELF']?>" method="get">

* vuln: ``$_SERVER['PHP_SELF']`` is echoed raw into the ``action`` attribute, so
a path like ``/x.php/"><script>alert(1)</script>`` is reflected as markup and
becomes XSS. ``htmlspecialchars()`` fixes it.
* bug: the form submits with ``method="get"`` while the handler only reads
``$_POST['data']``, so the INSERT never runs. ``method="post"`` fixes it.

The corrected line is posted to ``/missions/extbasic/template.php`` together with
the per-load ``formkey`` and ``lvl``. A ``Referer`` pointing at the level page is
mandatory, otherwise the endpoint answers ``Invalid Referer`` and the attempt does
not count. The session cookie comes from ``HTS_COOKIE`` and is never persisted.
"""
import os
import re
import urllib.parse
import urllib.request

UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36")
BASE = "https://www.hackthissite.org"
LEVEL = BASE + "/missions/playit/extbasic/7/"
TEMPLATE = BASE + "/missions/extbasic/template.php"
CK = os.environ["HTS_COOKIE"]

ANSWER = (
'<form name="grezvahfvfnjuvavatovgpu" '
'action="<?=htmlspecialchars($_SERVER[\'PHP_SELF\'])?>" method="post">'
)

def get(url):
req = urllib.request.Request(url, headers={"Cookie": CK, "User-Agent": UA})
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")

def main():
page = get(LEVEL)
formkey = re.search(r'name="formkey" value="([^"]+)"', page).group(1)
data = urllib.parse.urlencode(
{"formkey": formkey, "lvl": "7", "pass": ANSWER}).encode()
req = urllib.request.Request(
TEMPLATE, data=data,
headers={"Cookie": CK, "User-Agent": UA,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": LEVEL})
resp = urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")
print("go-on link to level 8:", "/missions/playit/extbasic/8" in resp)

if __name__ == "__main__":
main()

Challenge

This site is run by a new sysadmin who does not know much about web configuration. The script is located at http://moo.com/moo.php Attempt to make the script think you are authed by entering the correct URI. 进入正确的 URI,让脚本以为你已经通过认证。

关卡页给出脚本 me.php 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$user = $_GET['user'];
$pass = $_GET['pass'];
if (isAuthed($user,$pass))
{
$passed = TRUE;
}
if ($passed == TRUE)
{
echo 'you win';
}
?>
<form action="me.php" method="get">
<input type="text" name="user" />
<input type="password" name="pass" />
</form>
<?php
function isAuthed($a,$b)
{
return FALSE;
}
?>

页面底部还有一行全局提醒:All missions are case sensitive. I tried to keep them lowercase however.。答案大小写敏感。

Solution

isAuthed($a,$b) 的返回值写死为 FALSE,所以走正常表单提交 user/passif (isAuthed($user,$pass)) 永远不成立,$passed = TRUE 这一行根本不会执行。想从凭据这条路进去是没有出口的。

脚本只从 $_GET 里取了 userpass$passed 在整个文件里没有任何赋值默认值的语句:

1
2
3
4
$user = $_GET['user'];
$pass = $_GET['pass'];
if (isAuthed($user,$pass)) { $passed = TRUE; }
if ($passed == TRUE) { echo 'you win'; }

如果 $passed 只是一个普通的未定义局部变量,第二个 if 恒为假,这题无从下手。题面第一句正是钥匙:new sysadmin who does not know much about web configuration,一个不懂 Web 配置的管理员,对应的就是最典型的一项 PHP 配置错误:register_globals = On

register_globals 打开时,PHP 会在脚本运行前把查询字符串里的每个键自动导入成同名全局变量。于是 URL 里的 passed 会直接落到脚本的 $passed 上,完全绕过 isAuthed() 这条分支。

要让 $passed == TRUE 成立,只需在 URL 上挂一个 passed 参数:

1
http://moo.com/moo.php?passed=TRUE

PHP 的松散比较 == 在两侧类型不同时会做类型转换:字符串和布尔值比较时,字符串被转成布尔值,任何非空且非 "0" 的字符串都为真。因此 "TRUE" == TRUE 求值为 true,脚本进入 if 分支并输出 you win

值按源码里惯用的写法给 TRUE

formkey 每次加载关卡页都会变,取页面和提交必须在同一次运行里完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import os
import re
import urllib.parse
import urllib.request

UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36")
BASE = "https://www.hackthissite.org"
LEVEL = BASE + "/missions/playit/extbasic/6/"
TEMPLATE = BASE + "/missions/extbasic/template.php"
COOKIE = os.environ["HTS_COOKIE"]
ANSWER = "http://moo.com/moo.php?passed=TRUE"

def fetch(url, referer=None):
headers = {"Cookie": COOKIE, "User-Agent": UA}
if referer:
headers["Referer"] = referer
req = urllib.request.Request(url, headers=headers)
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")

def main():
page = fetch(LEVEL)
formkey = re.search(r'name="formkey"\s+value="([^"]+)"', page).group(1)
data = urllib.parse.urlencode(
{"formkey": formkey, "lvl": "6", "pass": ANSWER}).encode()
req = urllib.request.Request(
TEMPLATE, data=data,
headers={"Cookie": COOKIE, "User-Agent": UA,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": LEVEL})
print(urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace"))

if __name__ == "__main__":
main()

Key points

  • register_globals 会把查询字符串的键自动提升为全局变量,攻击者可以覆盖脚本本应自己掌控的标志位(如 $passed
  • 判断是否登录/是否通过的标志必须由服务端在可信逻辑里赋值,不能依赖任何用户可控的名字
  • 认证分支不可达时,漏洞往往在缺失的初始化宽松比较上,而不是在比较内容上
  • == 的类型转换让 "TRUE" == TRUE 为真;权限判断应使用 === 并显式校验类型
  • register_globals 早已在 PHP 5.4 中被移除,现代 PHP 不再存在这条路径

Challenge

ExtBasic Mission 05 — fix the broken line in a shell script that is supposed to patch a PHP page. 修正 Sam 用来修补 PHP 页面的 shell 脚本里被写错的那一行。

关卡给出一个 PHP 页面:本该调用 safeeval() 包装函数的地方,写成了裸的 eval()

1
2
3
4
5
6
7
8
9
<?php
include ('safe.inc.php');
if ($access=="allowed") {
eval($_GET['cmd']);
if (!empty($_GET['cmd2'])) {
eval($_GET['cmd2']);
}
}
?>

Sam 写了个 shell 脚本,用 sedeval 批量改写成 safeeval

1
2
3
4
5
6
#!/bin/sh
rm OK
sed -E "s/eval/safeeval/" <exec.php >tmp && touch OK
if [ -f OK ]; then
rm exec.php && mv tmp exec.php
fi

页面说明 Sam 的系统是 freeBSD 6.9,顶部横幅另外提醒:不要写 sed -r,BSD 上要用 sed -E

任务是修正 shell 脚本里写错的那一行。

Solution

横幅专门提醒 -r-E 的区别,说明出题人预期玩家会先怀疑 sed 标志。脚本里已经是 sed -E,在 FreeBSD 上语法合法。这一行的问题不在方言,而在替换命令本身。

s/eval/safeeval/ 没有写全局标志 g

  • 不带 g:sed 对每一行只替换第一个匹配;
  • g:替换行内所有匹配。
sed -E s/eval/safeeval/g <exec.php >tmp && touch OK

Challenge

A program written in a made-up language called F.ake; work out its output when the user types 6,7.

第四关给出一段用自造语言 F.ake 写的程序,要求算出用户输入 6,7 时它的输出。

1
2
3
4
5
{user types 6,7}
BEGIN F.ake
var int as in
int var as in
out var int
67

Challenge

playit 型关卡,题面给出一段用作者自造语言写的 5 行程序,要求给出它的输出:

1
2
3
4
5
BEGIN notr.eal
CREATE int AS 2
DESTROY int AS 0
ANS var AS Create + TO
out TO

Solution

  • BEGIN notr.eal 只是程序头,notr.eal 读作 not real,这门语言本身是虚构的,没有任何公开语法可查。

  • CREATEDESTROYANSTO 在这个语言里都只是普通标识符,是一组自指单词;它们的字面含义(创建 / 销毁 / 答案 / 到)是纯粹的误导。

  • 真正起作用的语法骨架只有三点:<标识符> <类型> AS <表达式> 做声明赋值、+ 做整数加法、out 做输出。

  • AS 是赋值分隔符,int / var 只是类型标注,不参与数值。

  • CREATE int AS 2:把 2 绑定到标识符 CREATE

  • DESTROY int AS 0:把 0 绑定到标识符 DESTROY

  • ANS var AS Create + TO:计算 Create + TO。标识符归一化后 Create 就是第 2 行的 CREATE(值为 2),TO 从未被赋值、按未绑定标识符计 0,所以 ANS = 2 + 0 = 2

  • out TOout 是输出语句,行尾的 TO 指出输出通道而不是变量,打印当前答案。

把上面的规则写成一个最小解释器,复现整条数值流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import re

PROGRAM = "\n".join([
"BEGIN notr.eal",
"CREATE int AS 2",
"DESTROY int AS 0",
"ANS var AS Create + TO",
"out TO",
])

def value_of(expr, env):
total = 0
for term in expr.split("+"):
term = term.strip()
if re.fullmatch(r"\d+", term):
total += int(term)
else:
total += env.get(term.lower(), 0)
return total

def run(source):
env = {}
answer = None
for line in source.splitlines():
parts = line.split()
if not parts or parts[0] == "BEGIN":
continue
if parts[0] == "out":
print("out %s -> %s" % (parts[1], answer))
continue
name, declared_type, _as = parts[0], parts[1], parts[2]
expr = " ".join(parts[3:])
answer = value_of(expr, env)
env[name.lower()] = answer
print("%-8s %-4s AS %-14s => %d" % (name, declared_type, expr, answer))
return answer

if __name__ == "__main__":
print(run(PROGRAM))

运行输出

1
2
3
4
5
CREATE   int  AS 2              => 2
DESTROY int AS 0 => 0
ANS var AS Create + TO => 2
out TO -> 2
2

Challenge

You have this function, provide the value which must be POST-ed as filename to obtain the desired results: Get the source code of hackthissite.org/index.php

给出一个把 POST 参数 filename 拼上 .php 后读取文件的函数,要求给出 filename 的值,读到站点根 index.php 的源码。

1
2
3
<?php
$lvl_text = file_get_contents($_POST['filename'].'.php');
?>

Solution

file_get_contents 把一个路径当文件读成字符串,返回的是原始字节,不经过 PHP 解释器,所以读 .php 文件拿到的是源码。这正是题面要的 source code:读 index.php 得到它磁盘上的内容,而不是它渲染出来的页面。

路径完全由 $_POST['filename'] 决定,程序只做两件事:拼上 .php、然后读。没有任何目录白名单,也没有过滤 ..

函数固定追加 .php,乍看像扩展名黑名单。但 file_get_contents 不执行 PHP,读 .php 文件本来就是拿源码;而目标 index.php 的文件名里正好带 .php:追加的后缀恰好对得上,不构成障碍。真正要解决的是路径:提交处理脚本在 /missions/extbasic/ 下,目标是站点根的 index.php

处理脚本位于站点根的下面两级,往上爬两级再指名 index

1
/missions/extbasic/  ->  ..  ->  /missions/  ->  ..  ->  /

Key points

  • $_POST['filename'] 未经校验进入 file_get_contents,构成路径遍历;用户控制的路径要先规范化再校验是否落在允许目录内(realpath 后做前缀比对)。
  • file_get_contents 读的是源码而不是执行结果,所以给 .php 目标补 .php 后缀不构成防护:这里的扩展名过滤是错觉。
  • ../ 的层数取决于处理脚本相对目标的深度:本关处理脚本在 /missions/extbasic/,距站点根两级,故用 ../../
  • 现代 PHP 里 %00 空字节截断早已失效(PHP 5.3.4 起字符串不再被 NUL 截断),本关也不需要它:目标本身就以 .php 结尾。
../../index

Challenge

You have to give input to a C program which gives you the length of the string. How would you crash it?

给一个会给出字符串长度的 C 程序喂输入,怎样把它搞崩?

1
2
3
4
5
void blah(char *str)
{
char lol[200];
strcpy(lol, str);
}

Solution

blah 实质只有两步:在栈上声明 200 字节的 lol,把调用方的字符串整段拷进去。strcpy 的原型是 strcpy(char *dst, const char *src),它只拿到源和目标两个地址,不接收目标容量,于是拷贝多少字节完全由 src 里 NUL 的位置决定:写 strlen(src) + 1 个字节(含结尾的 NUL),中途不做任何边界检查。

lolblah 栈帧里的局部数组,紧邻它的更深处是保存的帧指针和返回地址。当 strlen(str) >= 200 时,strcpy 写出的字节越过 lol 的边界压到相邻栈数据上;返回地址一旦被非预期字节覆盖,blah 执行 ret 时就跳向垃圾地址,进程随即崩溃。

Challenge

JS Obfuscation. FTW!

Find the password:

索引名 JS Obfuscation. FTW!,难度 moderate。密码比较逻辑藏在一个运行时由十六进制字符串数组加 String.fromCharCode(...) 生成出来的 <button> 里。要先把混淆还原出来。

Solution

1
2
3
$ curl -s -b "$HTS_COOKIE" \
-H "Referer: https://www.hackthissite.org/missions/javascript/7/" \
"https://www.hackthissite.org/missions/javascript/7/" -o lvl7.html
1
var _0x4e9d=["\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65","\x77\x72\x69\x74\x65"];document[_0x4e9d[0x1]](String[_0x4e9d[0x0]](0x3c,0x62,0x75,0x74,0x74,0x6f,0x6e,0x20,0x6f,0x6e,0x63,0x6c,0x69,0x63,0x6b,0x3d,0x27,0x6a,0x61,0x76,0x61,0x73,0x63,0x72,0x69,0x70,0x74,0x3a,0x69,0x66,0x20,0x28,0x64,0x6f,0x63,0x75,0x6d,0x65,0x6e,0x74,0x2e,0x67,0x65,0x74,0x45,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x42,0x79,0x49,0x64,0x28,0x22,0x70,0x61,0x73,0x73,0x22,0x29,0x2e,0x76,0x61,0x6c,0x75,0x65,0x3d,0x3d,0x22,0x6a,0x30,0x30,0x77,0x31,0x6e,0x22,0x29,0x7b,0x61,0x6c,0x65,0x72,0x74,0x28,0x22,0x59,0x6f,0x75,0x20,0x57,0x49,0x4e,0x21,0x22,0x29,0x3b,0x77,0x69,0x6e,0x64,0x6f,0x77,0x2e,0x6c,0x6f,0x63,0x61,0x74,0x69,0x6f,0x6e,0x20,0x2b,0x3d,0x20,0x22,0x3f,0x6c,0x76,0x6c,0x5f,0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x3d,0x22,0x2b,0x64,0x6f,0x63,0x75,0x6d,0x65,0x6e,0x74,0x2e,0x67,0x65,0x74,0x45,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x42,0x79,0x49,0x64,0x28,0x22,0x70,0x61,0x73,0x73,0x22,0x29,0x2e,0x76,0x61,0x6c,0x75,0x65,0x7d,0x65,0x6c,0x73,0x65,0x20,0x7b,0x61,0x6c,0x65,0x72,0x74,0x28,0x22,0x57,0x52,0x4f,0x4e,0x47,0x21,0x20,0x54,0x72,0x79,0x20,0x61,0x67,0x61,0x69,0x6e,0x21,0x22,0x29,0x7d,0x27,0x3e,0x43,0x68,0x65,0x63,0x6b,0x20,0x50,0x61,0x73,0x73,0x77,0x6f,0x72,0x64,0x3c,0x2f,0x62,0x75,0x74,0x74,0x6f,0x6e,0x3e));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python3
"""Recover the JavaScript Mission 7 password from the obfuscated source.

The level ships two hex-string arrays and rebuilds a <button> at run time by
turning a numeric char-code list into HTML:

var _0x4e9d=["\\x66\\x72\\x6F\\x6D\\x43\\x68\\x61\\x72\\x43\\x6F\\x64\\x65",
"\\x77\\x72\\x69\\x74\\x65"];

Step 1 unescapes the \\xNN literals into identifier names
("fromCharCode","write"); step 2 turns the numeric char-code list into the
button HTML and reads the compared literal out of it -- the password.
"""
import re

src = open("lvl7.html", encoding="utf-8", errors="replace").read()

# Step 1: \xNN string literals -> identifier names
arr = re.search(r"_0x4e9d=\[(.*?)\]", src, re.S).group(1)
names = [bytes(b, "latin1").decode("unicode_escape")
for b in re.findall(r'"((?:\\x[0-9a-fA-F]{2})+)"', arr)]
print("identifiers:", names)

# Step 2: rebuild the button HTML from the String.fromCharCode() code list
call = re.search(r"String\[_0x4e9d\[0x0\]\]\(([^)]*)\)", src, re.S).group(1)
codes = [int(x, 16) for x in re.findall(r"0x([0-9a-fA-F]+)", call)]
button = "".join(chr(c) for c in codes)
print("button HTML:")
print(button)

# Step 3: the compared literal inside the button is the password
password = re.search(r'value=="([^"]+)"', button).group(1)
print("password:", password)
1
2
3
4
5
$ uv run python decode7.py
identifiers: ['fromCharCode', 'write']
button HTML:
<button onclick='javascript:if (document.getElementById("pass").value=="j00w1n"){alert("You WIN!");window.location += "?lvl_password="+document.getElementById("pass").value}else {alert("WRONG! Try again!")}'>Check Password</button>
password: j00w1n
j00w1n