14 hallazgos · soluciones técnicas concretas · tu opinión/orden se guarda automático en este navegador
| ID | Sev | Problema | Explicación | Solución propuesta | Tu opinión / orden |
|---|---|---|---|---|---|
| F-01 | Critical | archivos_cuandeoro/.env con credenciales texto plano |
ARCHIVOS_USER=marco y ARCHIVOS_PASS=cuandeoro2026 visibles a cualquiera con read en el server. Viola R90-KMS. |
1. Rotar passwordNEWPASS=$(openssl rand -base64 24)2. Guardar cifrado en Vault Transitvault write transit/encrypt/profitnew-master \
plaintext=$(echo -n "$NEWPASS" | base64) \
-format=json | jq -r .data.ciphertext > ARCHIVOS_PASS.enc3. Sustituir .envARCHIVOS_USER=marco
ARCHIVOS_PASS_ENC=vault:v1:kjdf...4. server.py lee y descifra al boot vía AppRole |
|
| F-02 | Critical | Keypair Solana hardcoded en bridge.mjs |
Línea 7: path absoluto al keypair JSON. Si comprometen el código, drenan la wallet GDGQ...AUF. |
1. Subir keypair a Vault KVvault kv put secret/cuandeoro/wallets/dallas_hot \
keypair=@/home/admin/.velocityquant_secrets/dallas_hot_keypair.json2. bridge.mjs usa node-vaultimport vault from 'node-vault';
const v = vault({ endpoint:'http://127.0.0.1:8200', token:process.env.VAULT_TOKEN });
const { data } = await v.read('secret/data/cuandeoro/wallets/dallas_hot');
const keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(data.data.keypair)));3. Eliminar archivo plano del filesystem tras validar. |
|
| F-03 | Critical | Semilla BIP-39 deployer en TOML accesible | finish.mjs:12 y open_trustline.mjs:5 leen seed phrase. Control TOTAL wallet GBL4...HVVA. |
1. Mover seed phrase a Vault KVvault kv put secret/cuandeoro/deployer \
seed_phrase="word1 word2 ... word24"2. finish.mjs / open_trustline.mjs leen asíconst { data } = await v.read('secret/data/cuandeoro/deployer');
const seed = data.data.seed_phrase;
const keypair = StellarSdk.Keypair.fromSeed(seed);3. Borrar TOMLshred -uvz /home/admin/.velocityquant_secrets/cuandeoro_deployer_mainnet.toml |
|
| F-04 | Critical | setup_vault_cuandeoro_master.sh imprime credenciales a stdout |
Líneas 73 y 78 hacen echo $SECRET_ID. Quedan en bash_history y logs systemd. |
Reemplazar bloque inseguro por escritura cifrada a archivo:# En vez de:
echo "ROLE_ID=$ROLE_ID"
echo "SECRET_ID=$SECRET_ID"
# Usar:
umask 077
cat > /etc/vault.d/approle.env < |
|
| F-05 | Critical | VAULT_TOKEN como env var visible en ps aux |
Script pide export VAULT_TOKEN=hvs.xxxxx. Token visible a otros usuarios + en bash_history. |
Usar archivo con permisos 600:# Al inicio del script en vez de "export":
TOKEN_FILE="${HOME}/.vault-token"
if [ ! -f "$TOKEN_FILE" ]; then
read -rs -p "VAULT_TOKEN: " VAULT_TOKEN
echo
umask 077
echo "$VAULT_TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
fi
export VAULT_TOKEN=$(cat "$TOKEN_FILE")Después borrar de bash_history:history -d $(history | grep "VAULT_TOKEN" | awk '{print $1}')
history -w |
| ID | Sev | Problema | Explicación | Solución propuesta | Tu opinión / orden |
|---|---|---|---|---|---|
| F-06 | High | bridge.mjs:45 envía tx sin validar contenido |
sdk.bridge.rawTxBuilder.send(sendParams) sin checks previos. Tx malformada → fondos perdidos/bloqueados. |
Validar sendParams con Zod o validación manual antes del send:import { z } from 'zod';
const SendSchema = z.object({
amount: z.string().regex(/^\d+(\.\d{1,7})?$/), // Stellar 7 decimales max
destination: z.string().regex(/^G[A-Z2-7]{55}$/), // ed25519 pubkey
asset: z.union([z.literal('XLM'), z.string().regex(/^[A-Z0-9]{1,12}:G[A-Z2-7]{55}$/)]),
memo: z.string().max(28).optional()
});
const validated = SendSchema.parse(sendParams);
if (parseFloat(validated.amount) <= 0) throw new Error('amount must be > 0');
if (validated.destination === SOURCE_PUBKEY) throw new Error('self-send blocked');
const rawTx = await sdk.bridge.rawTxBuilder.send(validated); |
| ID | Sev | Problema | Explicación | Solución propuesta | Tu opinión / orden |
|---|---|---|---|---|---|
| F-07 | High | Sumas i128 sin checked_add |
Struct Amounts (lib.rs:120-130). Atacante manda amounts que sumen > balance → overflow → estado parcial. |
En lib.rs función prepare(), reemplazar sum por checked:// Antes:
// let total: i128 = amounts.iter().map(|a| a.value).sum();
// Después:
let mut total: i128 = 0;
for a in amounts.iter() {
total = total
.checked_add(a.value)
.ok_or(Error::AmountOverflow)?;
}
// Y verificar contra balance:
let buyer_balance = token_client.balance(&buyer);
if total > buyer_balance {
return Err(Error::InsufficientBalance.into());
} |
|
| F-08 | High | Admin key sin time-lock ni multi-sig | initialize requiere auth pero upgrade es instantáneo. Si admin key se compromete, atacante reemplaza contrato. |
Time-lock 48h en upgrades + multi-sig 2-of-3:#[contracttype]
pub struct UpgradeProposal {
new_wasm_hash: BytesN<32>,
proposed_at: u64,
signatures: Vec<Address>, // requiere 2 de 3 admins
}
const TIMELOCK_SECS: u64 = 172_800; // 48h
const REQUIRED_SIGS: u32 = 2;
pub fn propose_upgrade(env: Env, new_wasm: BytesN<32>, admin: Address) {
admin.require_auth();
env.storage().instance().set(&UPGRADE_KEY, &UpgradeProposal {
new_wasm_hash: new_wasm,
proposed_at: env.ledger().timestamp(),
signatures: vec![&env, admin],
});
}
pub fn execute_upgrade(env: Env) {
let p: UpgradeProposal = env.storage().instance().get(&UPGRADE_KEY).unwrap();
let elapsed = env.ledger().timestamp() - p.proposed_at;
if elapsed < TIMELOCK_SECS { panic!("timelock not elapsed"); }
if p.signatures.len() < REQUIRED_SIGS { panic!("insufficient sigs"); }
env.deployer().update_current_contract_wasm(p.new_wasm_hash);
} |
| ID | Sev | Problema | Explicación | Solución propuesta | Tu opinión / orden |
|---|---|---|---|---|---|
| F-09 | High | Sin audit_log de lecturas en kyc_records |
Exfiltración masiva de PII cifrado sin trazabilidad. Si roban KEK Vault después → descifran todo. | Vista controlada + función SECURITY DEFINER que loggea:-- 1. Revoke direct access
REVOKE SELECT ON kyc_records FROM app_role;
-- 2. Función SECURITY DEFINER que loggea cada acceso
CREATE OR REPLACE FUNCTION kyc_read(p_user_id UUID)
RETURNS TABLE (passport_enc BYTEA, tax_id_enc BYTEA, status TEXT)
LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO kyc_audit_log(actor, action, target_user_id, ts)
VALUES (current_user, 'READ_KYC', p_user_id, NOW());
RETURN QUERY SELECT k.passport_number_enc, k.tax_id_enc, k.kyc_status
FROM kyc_records k WHERE k.user_id = p_user_id;
END $$;
-- 3. Solo app puede llamar la función
GRANT EXECUTE ON FUNCTION kyc_read(UUID) TO app_role; |
|
| F-10 | High | gdpr_purge_kyc_record editable evade auditoría |
Función modificable. Atacante con permisos comenta el INSERT al audit log → borra KYC sin evidencia. | Ownership inmutable + revoke ALTER:-- 1. Mover función a role auditor protegido
CREATE ROLE kyc_auditor NOLOGIN;
ALTER FUNCTION gdpr_purge_kyc_record(UUID) OWNER TO kyc_auditor;
-- 2. Revoke ALTER de app_role
REVOKE ALL ON FUNCTION gdpr_purge_kyc_record(UUID) FROM app_role;
GRANT EXECUTE ON FUNCTION gdpr_purge_kyc_record(UUID) TO app_role;
-- 3. SECURITY DEFINER + search_path fijo (anti hijack)
CREATE OR REPLACE FUNCTION gdpr_purge_kyc_record(p_user_id UUID)
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER
SET search_path = pg_catalog, public AS $$
BEGIN
INSERT INTO kyc_audit_log(actor, action, target_user_id, ts)
VALUES (current_user, 'GDPR_PURGE', p_user_id, NOW());
UPDATE kyc_records SET
passport_number_enc = NULL, tax_id_enc = NULL,
deleted_at = NOW(), purge_reason = 'GDPR_ART17'
WHERE user_id = p_user_id;
END $$; |
|
| F-11 | High | Borrado físico sin soft delete | Conflicto GDPR Art.17 (borrar) vs AML5 (conservar 5 años). Solución híbrida: borrar PII pero retener metadata mínima. | ALTER table + política híbrida:-- 1. Añadir columnas tombstone
ALTER TABLE kyc_records
ADD COLUMN deleted_at TIMESTAMPTZ,
ADD COLUMN purge_reason TEXT,
ADD COLUMN tx_hash_anchor BYTEA; -- hash on-chain como prueba
-- 2. Modificar gdpr_purge: borra PII, mantiene anchor
UPDATE kyc_records SET
passport_number_enc = NULL,
tax_id_enc = NULL,
full_name_enc = NULL,
-- mantenemos: user_id, deleted_at, tx_hash_anchor, kyc_outcome
deleted_at = NOW(),
purge_reason = 'GDPR_ART17'
WHERE user_id = $1;
-- 3. Excluir borrados de queries normales por defecto
CREATE VIEW kyc_active AS SELECT * FROM kyc_records WHERE deleted_at IS NULL;
-- 4. Job nocturno: hard-delete después de 5 años AML
DELETE FROM kyc_records
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '5 years'; |
| ID | Sev | Problema | Explicación | Solución propuesta | Tu opinión / orden |
|---|---|---|---|---|---|
| F-12 | Critical | Slippage 800 bps VE + 500 bps AR explotable | Anchor malicioso cotiza barato → sube tasa en ejecución → fuerza reembolso → arbitraje encubierto drena wallet. | Bajar tolerance + max-loss-per-tx + verificar oracle dual:# slippage_strategy.md update
country_tolerance:
VE: 300 # antes 800 - bajar a 3%
AR: 200 # antes 500 - bajar a 2%
default: 100
# código offramp - antes de aceptar quote:
def validate_quote(quote, market_oracle, secondary_oracle):
drift_primary = abs(quote.rate - market_oracle.rate) / market_oracle.rate * 10000
drift_secondary = abs(quote.rate - secondary_oracle.rate) / secondary_oracle.rate * 10000
if drift_primary > country_tolerance[quote.country]:
raise SlippageExceeded(country=quote.country, bps=drift_primary)
if drift_secondary > country_tolerance[quote.country] * 1.2:
raise OracleDiscrepancy(primary=market_oracle.rate, secondary=secondary_oracle.rate)
if quote.amount * drift_primary / 10000 > MAX_LOSS_PER_TX_USD:
raise MaxLossExceeded()
return True |
|
| F-13 | High | Oracle secundario "implementación pendiente" | Three-tier price oracle dice "pendiente" para validación cross-source. Mientras no exista, anchor único reporta tasas falsas sin contradicción. | Implementar oracle secundario antes del primer offramp real:# Opciones gratuitas/baratas para FX rates:
# 1. exchangerate.host (free, sin API key)
# 2. Frankfurter API (free, ECB rates)
# 3. Wise/Revolut públicos (rate limit)
import httpx
from typing import Tuple
async def fetch_secondary_oracle(base: str, quote: str) -> float:
async with httpx.AsyncClient(timeout=5.0) as c:
# ECB via Frankfurter (no API key)
r = await c.get(f"https://api.frankfurter.app/latest?from={base}&to={quote}")
return r.json()["rates"][quote]
async def get_dual_oracle(base: str, quote: str) -> Tuple[float, float]:
primary = await anchor_quote(base, quote)
secondary = await fetch_secondary_oracle(base, quote)
return primary, secondary
# Bloqueo si discrepancia > 5%
primary, secondary = await get_dual_oracle("USD", "VES")
if abs(primary - secondary) / secondary > 0.05:
alert_ops("Oracle divergence", primary=primary, secondary=secondary)
raise OracleDiscrepancy() |
|
| F-14 | High | quote_id sin nonce / sin uso-único |
Atacante intercepta quote_id válido y lo reenvía después de cambiar tasa → reembolsos repetidos. | Nonce + TTL + tabla quotes_consumed UNIQUE:-- DB
CREATE TABLE quotes (
quote_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nonce BYTEA NOT NULL DEFAULT gen_random_bytes(32),
amount NUMERIC NOT NULL,
rate NUMERIC NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
CONSTRAINT one_use UNIQUE (quote_id) -- ya es PK pero explícito
);
CREATE INDEX idx_quotes_expiry ON quotes(expires_at) WHERE consumed_at IS NULL;
-- Función atómica: consume si válido, lanza si ya usado o expirado
CREATE FUNCTION consume_quote(p_quote_id UUID, p_nonce BYTEA) RETURNS BOOLEAN
LANGUAGE plpgsql AS $$
DECLARE rows_updated INT;
BEGIN
UPDATE quotes SET consumed_at = NOW()
WHERE quote_id = p_quote_id
AND nonce = p_nonce
AND consumed_at IS NULL
AND expires_at > NOW();
GET DIAGNOSTICS rows_updated = ROW_COUNT;
RETURN rows_updated = 1;
END $$;
-- TTL corto típico
-- TTL = 90 segundos para tx normal, 30s para mercados VE/AR volátiles |
Eso cierra los críticos de credenciales + los más explotables. ~1.5h con qwen-coder. F-07/F-08 (Soroban) y F-12/F-13/F-14 (offramp) son estructurales, mejor sesión dedicada.