0%

Odoo Challenge

介绍

Odoo Challenge 挑战赛是一个很有意思的项目, 起源于一次面试时, HR发来个链接说测试一下水平.

好了, 开始挑战

LEVEL 1

先随便输一个密码, 点 Check Answer, 很不意外的密码错误, 不过这时候有提示, 可以根据提示的线索来找到密码.

看到提示

1
Hint: if form['the_password'] == form['pwd']: return True

看看网页的源码, 猜测应该在里面能找到.

部分源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<form method="POST" action="/jobs/challenge/submit" role="form">
<input class="d-none" name="csrf_token" value="6ad9541dcf313efc428fa7f50dda9ca079adfe4ao1687487891"/>
<div class="form-group row">
<label for="password" class="col-md-2 offset-md-2 col-form-label">Password</label>
<div class="col-md-6">

<input type="hidden" name="the_password" value="796ff3e72821cf412787389c6d5f301e00e3efb6"/>
<input type="text" required="required" class="form-control" name="pwd" id="password" placeholder=""/>

</div>
</div>
<div class="form-group">
<div>
<button id="submit" type="submit" name="signup" class="btn btn-primary">Check Answer</button>
</div>
</div>
</form>

注意到

1
<input type="hidden" name="the_password" value="796ff3e72821cf412787389c6d5f301e00e3efb6"/>
1
796ff3e72821cf412787389c6d5f301e00e3efb6

就是本关的密码了

LEVEL 2

第二关提示如下

1
Hint: function debug() { [native code] }

猜测应该是和 debug 有关系, 所以可以 F12 打开开发者工具在命令控制台标签页找线索

直接出答案了

LEVEL 3

提示

1
Hint: GET / HTTP/1.1

猜测应该在与服务器交互的请求里寻找.

我们随便输一个密码, 提交一下看看与服务器的交互请求都有哪些

每个请求都点开看一看 request / response 里面有没有什么可以寻找的线索.

可以看到在 next?wrong=oups 这个请求的响应头里面有内容, 红框标记的猜测应该是答案, 即

1
Grow your business with Odoo and more than 985156 apps

LEVEL 4

提示

1
Hint: Classy password

笔者在解决这个问题的时候花费了相当长的时间。最开始我猜测提示经典密码也许有可能是弱口令?

我试了如下弱口令

1
2
3
4
5
6
7
8
admin
root
123456
000000
888888
88888888
00000000
11111111

诸如此类的弱口令发现都没有用。

最后我写了一个脚本, 从 Github 上找了找有没有彩虹表, 常用弱口令之类的仓库.

找到一个 https://github.com/rootphantomer/Blasting_dictionary

挂着跑了一下午, 回来一看跑了大概 2w 个密码仍然没有任何起色. 这个时候我开始怀疑一开始的思路是否正确, 提示经典密码的意思究竟是不是弱口令的意思?

但是此时我又没有更好的思路, 于是每个请求都翻了翻, 看看有没有有用的信息.

真让我翻到了….

1
https://www.odoo.com/jobs/challenge/challenge.css

这个请求的内容就是密码….

呵呵, 踏破铁鞋无觅处…

这个事情告诉我什么道理呢, 如果一个题目看起来很简单, 但是通过暴力方法很难解决时, 一定要寻找其他的突破口….

LEVEL 5

提示

1
Hint: I'm a small piece of data sent from a website and stored on the user's computer

意思是服务器发送了一个数据片段存储到了用户电脑上.

看到这个首先想到 local storage 和 cookies

印证了我的想法, 我在请求

1
https://www.odoo.com/jobs/challenge/next?wrong=oups

里发现了线索, 密码既是方框里的值

1
Odoo-9850101554950100

PS: 事后我发现这个页面的图标是一个饼干.

cookies 的意思正是饼干. 看来还是要多观察不一样的细节.

LEVEL 6

给了一段 js 代码

1
2
3
4
5
6
7
8
9
10
// JS
var idx = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
var tmp = "";
for (var i=0; i < pwd.length; i++)
tmp += " "+idx.indexOf(pwd.charAt(i));

if(tmp == " 2 38 39 36 4 14 11 8 5 12 1 3 37 7 1 0 5 14 8 0")
{
$.post('/jobs/challenge/submit', {pwd: pwd}).always(function(x) { window.location = '/jobs/challenge/next'});
}

看来一下逻辑, tmp 变量存储的应该是 idx 字符串的索引, 我们把索引对应的字符取出来即可.

可以用 Python 实现一个简单的解码函数

1
2
3
4
idx = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
tmp = "2 38 39 36 4 14 11 8 5 12 1 3 37 7 1 0 5 14 8 0".split(" ")
pwd = "".join([idx[int(index)] for index in tmp])
print(pwd)

得到密文

1
2CDA4eb85c13B7105e80

本关通过

LEVEL 7

哈哈, 从现在开始难度增加了…..

提示

1
Hint: ascii

ascii 码? 这个提示…..简单到等于没什么提示…

还是去翻一翻请求, 看看 request / response / cookies 有什么线索吧.

首先在控制台看到打印了一串信息

翻译了一下大概是说需要调用另一个 URL 来执行下一个任务.

然后在请求

1
https://www.odoo.com/jobs/challenge/next?wrong=oups

里发现了不少信息.

这个地方有一个奇怪的信息, 我们先记一下.

name value
It-Is-The-Part-2-Of-Url “/jobs/challenge//1d1/prime.json”

请求头也有一个奇怪的信息

name value
It-Is-The-Part-1-Of-Url “/jobs/challenge/ebe//prime.json”

这时候我们可以分析, 这个 URL 被分为了两部分, 放到一起看一下

1
2
/jobs/challenge/<part1>/1d1/prime.json
/jobs/challenge/ebe/<part2>/prime.json

那么完整的 URL 应该是

1
/jobs/challenge/ebe/1d1/prime.json

我们尝试访问这个 URL

得到一串 JSON

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
{
"instructions": "Remove non prime numbers from data then right shift [222th decimal of the constant quantity that determines the area of a circle by multiplying it by the radius squared] bits from the remaining numbers, it will give you the alphanumeric password of the next level",
"numbers": [
52191,
25453,
13613,
54064,
25111,
22425,
12821,
19580,
25163,
46513,
40022,
57039,
9167,
13697,
26041,
47396,
13577,
11932,
50399,
14591,
13323,
46296,
20704,
53379,
24889,
22289,
24989,
51709,
47000,
294,
26249,
52972,
51191,
44807,
43176,
6419,
26161,
13339,
48093,
12547,
4258,
14479,
42054,
13913,
22828,
59633,
40834,
37100,
34715,
17108,
39104,
24790,
14407,
24967,
64736,
561,
38676,
14479,
17092,
62096,
42309,
26471,
59563,
25609,
13459,
2233,
14303,
52568,
50193,
28990,
34498,
25321,
21884,
26183,
13441,
25541,
57043,
4395,
25943,
25057,
41090,
14057,
38058,
7060,
26773,
37842,
25867,
33069,
8468,
46003,
24411,
20484,
19630,
3410,
35367,
26177,
25471,
35463,
12959,
25903,
25153,
13999,
44393,
25411,
2829,
51176,
25913,
13913,
52113,
17144,
14533
]
}

翻译一下, 大概意思是, 把这个数组里的非素数删除了, 然后将剩余的数字右移x位(也许是位运算?), 然后就得到了密码

x 位指的是应该是 pi 的第 222 位的数字

至此, 我们梳理一下完整的逻辑, 把上面给定的数组里的非素数删除, 然后找到 pi 的第 222 位的值 x, 然后将数组中剩余的数字按位运算右移 x 位.

网上找一个高效的判断是否位素数的函数

1
2
3
4
5
6
7
8
9
10
11
def is_prime(x: int) -> bool:
"""check if is prime
referencen link https://zhuanlan.zhihu.com/p/107300262"""
if x == 2 or x == 3:
return True
if x % 6 != 1 and x % 6 != 5:
return False
for i in range(5, int(x ** 0.5) + 1, 6):
if x % i == 0 or x % (i + 2) == 0:
return False
return True

查一下 pi 的第 222 位是几, 这里可以自己写函数计算也可以直接在网上查, 这里仅仅需要知道该值即可, 所以我选择了直接在网上查

直接在网站

1
http://pai.babihu.com/

查的 pi 的 222 位为 8

还记的最开始的提示吗?

1
Hint: ascii

这里运算完的应该是 ascii 码, 我们计算出对应的字符就得到了最后的密文, 至此我们开始编写解码函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

def is_prime(x: int) -> bool:
"""check if is prime
referencen link https://zhuanlan.zhihu.com/p/107300262"""
if x == 2 or x == 3:
return True
if x % 6 != 1 and x % 6 != 5:
return False
for i in range(5, int(x ** 0.5) + 1, 6):
if x % i == 0 or x % (i + 2) == 0:
return False
return True

def decrypt(arr: list) -> str:
# 移除非素数
arr = list(filter(lambda x: is_prime(x), arr))
# 将数组中的每个数字按位右移8, 按 ascii 码表取对应的字符
# 最后将字符数组拼接成字符串, 即可得到密码
dec = "".join(list(map(lambda x: chr(x >> 8), arr)))
return dec

解密得密码

1
c5b2b5e58aaff41868a8d7bf4cea6efc2eb6ce68

本关通过

LEVEL 8

本关注意到这个图片变得奇怪了, 事出反常必有妖!

我们仔细翻翻跟服务器的请求里有没有什么奇怪的东西.

这个就是请求的图片

看起来非常像密码对不对? 突破口一定在这副怪异的图片里.

该请求响应了一个 HTML, 里面全都是 span 标签, 我们尝试把 span 标签的 style 属性去除了(因为我分析应该和密文关系不大),

然后删除 # 这个字符, 看看最后还能剩下什么.

我意外的发现在 Chrome 开发工具的预览窗口可以直接过滤掉 style 属性, 这下就好办了.

我们把这些内容复制出来, 然后删除 #

已经可以看出来密码了

稍微整理下得到

1
Password:'7f593a77ef76c1f04d1cc5bc51aebf265559b965'    

通关

LEVEL 9

注意红框框住的地方

1
b'iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAABmJLR0QA/wD/AP+gvaeTAAAE/klEQVR4nO3dwW7bOBSG0clg3v+VM7suhJSQTH4UW5yzbFxJcX4QNH15+fX9/f0PrPbv2w/A30mwSAgWCcEiIVgkBIvEfz/+69fX157bXxY7Lvd99NOxR7/R+MoLH/KQ33fG757KiEVCsEgIFgnBIvHz5P1i4feJM5PKmYnwzKeEzsKnGr85+/+CRiwSgkVCsEgIFolbk/eLhavY24yfeWa5fOGVH71XM2/shr+gEYuEYJEQLBKCReKTyXtnZgW8W1ufKW6ZudHMU73OiEVCsEgIFgnBInHW5P2iq4jvprp/xENuYMQiIVgkBIuEYJH4ZPL+1sx3/OKF91344plLdcUtGz4WGLFICBYJwSIhWCRuTd63beB85FGBysyS98Kyme6nY/v/gkYsEoJFQrBICBaJnyfvhxRsnPmhYaE/sS7oJiMWCcEiIVgkBIvE7m4zC1fAFy61P9Itpm9bW19Yj6TPO1sJFgnBIiFYJHZvWF04ie7KZg6xsPfOwvvevLIRi4RgkRAsEoJFYvfkfeakoRlvHU+6cEvq2CGV+L8YsUgIFgnBIiFYJL5+nHzNTLEX/t+ZJeBtnxIe3WjbGzvzXi15c4xYJASLhGCRECwSC2reZxa1u0nltqKatwpUZn7Bmcew8s6bBIuEYJEQLBKfdJvp9kbOVHmPrzzzf7ct8XfNZx5R8865BIuEYJEQLBI/l82E98u6P26b215s69V+cXhBjhGLhGCRECwSgkXi55X3biNlV8Y+vm83xe6WyxeW+nQFOb9jxCIhWCQEi4Rgkbi18t4tEG9bLn9rjfuQif/+v4IRi4RgkRAsEoJF4pNuM2OHrK2PXzy28DNEt7m3az6zhBGLhGCRECwSgkXik5X3t2aR25pQjv0R3xZcbPvy4BcjFgnBIiFYJASLxIKymf0Twzv/d2zbttLxfcfe+v5jzIZV3iRYJASLhGCRuFU281Zjk0dP9Uh3qYW2FSA9eoybjFgkBIuEYJEQLBILWkVuK+vedojoIeXz3UeoR/R55yCCRUKwSAgWiVutIruF6YW15zMe3XfbFwBvbRdwwirnEiwSgkVCsEj8PHm/eGuNe+bFM0ebztx3Yb3Ktt70C3/6ixGLhGCRECwSgkViQavI/ad33rnRX998pttVe2HlnYMIFgnBIiFYJD7ZsDp+8dghW0O7qX13suu2BjIXNqxyEMEiIVgkBIvErbKZR7q9oNt2ii58jJkbXXTfFox/qs87BxEsEoJFQrBILFh573SbXceXGtu2YfWtZjtLGLFICBYJwSIhWCTW93l/5Myl5/29We7c9y1q3jmIYJEQLBKCRWJBn/eFth3/9Nbmz7G3Osg/otsMbxIsEoJFQrBI3Orz3hl3Np+51MXC6Wp3OtIhh0MtqcAxYpEQLBKCRUKwSNzasHrIcUgXCyekC1+8cNZ8udRbJ1h9xohFQrBICBYJwSLxSbeZbhZ5SIX4wi8Auq6TF9u+WrjJiEVCsEgIFgnBIrG+VeRCj+by29qgd11uxkvtXdl+0YvGiEVCsEgIFgnBInH05H1m6fmQxooL6+W3ndk0puadNwkWCcEiIVgkPpm8dxPDmdORZirEH1lY29Md9LqwXOczRiwSgkVCsEgIFolbk/e3Wid2x7GO57bbTli96NbW928mMGKRECwSgkVCsEic1eedv4YRi4RgkRAsEoJFQrBICBYJwSLxP3LS5YIohRy8AAAAAElFTkSuQmCC'

这里是一串不知名的代码, 不过看开头非常像 PNG 格式的图片 Base64 编码后的格式

随便输一个密码看看有没有什么提示.

提示

1
Hint: png

我现在很确信这是一个 png 格式的图片 base64 编码后的代码

直接在网上还原该图片得到了一个二维码

继续识别一下该二维码则直接得到结果

1
f870f6a65d94a6f65a772b7f4734be95278e28fd

通关

LEVEL 10

本关给了一个加密函数的两个版本, js 和 python, 我比较熟悉 python 我们就来看下这个 python 函数的逻辑

1
2
def r(pwd, fr, to, by):
return ''.join([pwd[i] for i in range(fr, to, by)])

逻辑很简单, 该函数每次取出密码指定位的字符.

再看看输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> print(r(pwd, 0, 4, 1))
edac
>>> print(r(pwd, 4, 20, 2))
e76716c1
>>> print(r(pwd, 5, 30, 3))
1646561d3
>>> print(r(pwd, 5, 40, 4))
12b52d3fd
>>> print(r(pwd, 6, 40, 6))
711283
>>> print(r(pwd, 7, 8, 1))
e
>>> print(r(pwd, 10, 40, 3))
7bc5adfced
>>> print(r(pwd, 10, 40, 5))
7a6d85
>>> print(r(pwd, 15, 40, 6))
a2ffd
>>> print(r(pwd, 20, 40, 3))
61d3b5c

观察函数 r 的第三个参数, 我们发现最大值位 40, 该参数对应 range() 函数的第二位即 max.

于是我们可以知道 pwd 的长度位 40, 解码函数的逻辑就很清楚了.

创建一个长度 40 的数组, 然后将上述输出的结果依次填到数组的指定位置即可

开始编写解码函数

1
2
3
4
5
6
7
8
# 创建一个 40 长度的数组
_pwd = [ "" for _ in range(40) ]

#
def decrypt(text, fr, to ,by):
"""text 就是执行 r 函数的输出结果"""
for i, j in enumerate(range(fr, to, by)):
_pwd[j] = text[i]

然后依次执行

1
2
3
4
5
6
7
8
9
10
decrypt("edac", 0, 4, 1)
decrypt("e76716c1", 4, 20, 2)
decrypt("1646561d3", 5, 30, 3)
decrypt("12b52d3fd", 5, 40, 4)
decrypt("711283", 6, 40, 6)
decrypt("e", 7, 8, 1)
decrypt("7bc5adfced", 10, 40, 3)
decrypt("7a6d85", 10, 40, 5)
decrypt("a2ffd", 15, 40, 6)
decrypt("61d3b5c", 20, 40, 3)

拼接结果

1
print("".join(_pwd))

得到密码

1
edace17e62741b6ac51562a12ddff38cbfe53dcd

LEVEL 11

最后一关就有点难度了

因为你如果密码不正确的话是无法提交的, 也就是说没有任何的提示. 跟服务器也没有什么交互.

我们只能先取看看网页源码里有什么线索没有

果然发现了端倪, 源码里有一段 js 脚本, 看逻辑应该是处理提交密码的, 仔细分析下.

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
<script type="text/javascript">
$( document ).ready(function() {
$("#submit").on('click', function(e) {
e.preventDefault();
if (window.location.search.indexOf('challenge')!=-1) {debugger;}
pwd = $("#pwd").val();
ts = $("#pwd").data('ts')+'';
stmnt = getBiskuit('X-Odoo');
multi = true;
$(ts.substr(0,5).split('')).each(function( i , j) {
multi *= stmnt[parseInt(j) +1].charCodeAt(0);
});
if (parseInt(pwd.slice(-(--([,,,undefined].join()).length))[0]) * parseInt(pwd.slice(0 - - - 1 - - - - - 1 - - - - 0)[1]) * stmnt.split("All").length == ts.slice(eval(""+''+""+ƒ(1<0)+""+"-"+''+""+ƒ(0<1)+"-"+ƒ(1>0)))) {
$.ajax("./70/"+ pwd, {
success: function (o) {
0===pwd.lastIndexOf(multi.toString().substr(1,4)+stmnt.substring(2,6),0)&&(
$.post('submit', {pwd: o, csrf_token:'ce1db5808eeed02fc22e46b80ef4651e39b7cd59o1687498808'}).always(function(){window.location.href='/jobs/challenge/next'})
);
},
error: function (o) {
console.error('To be or not to be... ');
}
});
}
});
});
</script>

有几个变量我们需要注意下

1
2
3
ts
stmnt
multi

我们直接在浏览器的控制台执行下面的代码看看有什么结果没有

1
2
3
4
5
6
ts = $("#pwd").data('ts')+'';
stmnt = getBiskuit('X-Odoo');
multi = true;
$(ts.substr(0,5).split('')).each(function( i , j) {
multi *= stmnt[parseInt(j) +1].charCodeAt(0);
});

然后我们往下看是一个条件判断

1
(parseInt(pwd.slice(-(--([,,,undefined].join()).length))[0]) * parseInt(pwd.slice(0 - - - 1 - - - - - 1 - - - - 0)[1]) * stmnt.split("All").length == ts.slice(eval(""+''+""+ƒ(1<0)+""+"-"+''+""+ƒ(0<1)+"-"+ƒ(1>0))))

太长了我们可以拆分一下

1
2
3
parseInt(pwd.slice(-(--([,,,undefined].join()).length))[0])
parseInt(pwd.slice(0 - - - 1 - - - - - 1 - - - - 0)[1])
stmnt.split("All").length

简单来讲就是上面三行的乘积需要等于下面该行的值

1
ts.slice(eval(""+''+""+ƒ(1<0)+""+"-"+''+""+ƒ(0<1)+"-"+ƒ(1>0)))

因为我们现在需要反推pwd这个值, 所以上面不涉及pwd变量的我们可以直接在控制台执行看看具体的值是多少

code value
stmnt.split(“All”).length 2
ts.slice(eval(“”+’’+””+ƒ(1<0)+””+”-“+’’+””+ƒ(0<1)+”-“+ƒ(1>0))) ‘70’

这里注意到变量类型不同, 但是我们也看到了这个条件匹配用的是 ==, 即不是严格模式! 所以我们可以把他们都当成数字来处理

接下来我们处理包含 pwd 变量的条件

我们把

1
parseInt(pwd.slice(-(--([,,,undefined].join()).length))[0])

记作 a

1
parseInt(pwd.slice(0 - - - 1 - - - - - 1 - - - - 0)[1])

记作 b

则上述条件可以变为

1
a * b * 2 == 70

则可知 a b 的可选值表

a b
1 35
35 1
5 7
7 5

我们把

1
-(--([,,,undefined].join()).length)

在控制台里执行下, 然后得到值 -2

于是

1
2
3
parseInt(pwd.slice(-(--([,,,undefined].join()).length))[0])
// 等价于
parseInt(pwd.slice(-2)[0])

于是我们可以分析出 a 表达式为 pwd 变量的倒数第二字符所代表的值

同理我们分析 b 表达式

1
2
3
parseInt(pwd.slice(0 - - - 1 - - - - - 1 - - - - 0)[1])
// 等价于
parseInt(pwd.slice(-2)[1])

b 表达式为 pwd 变量的倒数第一字符所代表的值

仔细观察 js 脚本还记不记得这句

1
pwd = $("#pwd").val();

意思是 pwd 取到的是一个字符串变量, 观察我们刚才的 a, b 取值表.

我们发现按下标从一个字符串里取值, 取到的一定是一个字符, 不可能为两个字符, 于是现在 a / b 取值表就变为

a b
5 7
7 5

也就是说 pwd 的最后两位一定是 5775

我们再往下看

1
0===pwd.lastIndexOf(multi.toString().substr(1,4)+stmnt.substring(2,6),0)

我们先看一下里面的具体值是什么.

1
2
3
multi.toString().substr(1,4)+stmnt.substring(2,6)
// 执行得到, 这个东西看起来非常的像密码
'4380Odoo'

查看一下 lastIndexOf 方法的说明

参考链接

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/String/lastIndexOf

lastIndexOf() 方法返回调用String 对象的指定值最后一次出现的索引,在一个字符串中的指定位置 fromIndex处从后向前搜索。如果没找到这个特定值则返回-1 。

该方法将从尾到头地检索字符串 str,看它是否含有子串 searchValue。开始检索的位置在字符串的 fromIndex 处或字符串的结尾(没有指定 fromIndex 时)。如果找到一个 searchValue,则返回 searchValue 的第一个字符在 str 中的位置。str中的字符位置是从 0 开始的。

简单来讲, 就是想要

1
0 === pwd.lastIndexOf(multi.toString().substr(1,4)+stmnt.substring(2,6),0)

这个表达式成立, pwd 这个变量一定是 multi.toString().substr(1,4)+stmnt.substring(2,6) 所表示的值 4380Odoo 作为开头

又因为 pwd 的最后两位一定是 5775, 所以可以猜测密码应该为

1
2
4380Odoo57
4380Odoo75

这两个其中的一个

大功告成!

Congratulations !